wei-wuwei 0.4.0 → 0.6.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/bin/wuwei.js CHANGED
@@ -22,6 +22,20 @@
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 brief <path>… [--json | --html | --md] [--write] [--open] [--stale 72h] [--since 24h|7d|<date>] [--mark] [--if-due HH:MM] every wuwei repo under the paths, fetched and
26
+ * read at origin: what got done since the window began (--since,
27
+ * else the repo's mark, else 24h), what is ready today, what is left,
28
+ * what waits on a human, and with gh the PRs — on a terminal one self-contained
29
+ * page, written to ~/.wuwei/brief/brief.html and opened; otherwise markdown
30
+ * (--md asks for it on a terminal), --json for a routine, --html the page on stdout;
31
+ * --write saves the page whatever stdout holds, --open (with --write) shows it, on a terminal or off;
32
+ * --mark moves each repo's marks (git, GitHub) that answered, and the record's ranAt for the folders given
33
+ * (with --if-due, a run that moved none writes triedAt: the next one waits an hour);
34
+ * --if-due HH:MM (the routines') does nothing new — one line, the page shown with --open — when a --mark run over
35
+ * the same folders has run since the latest HH:MM (or is running: ~/.wuwei/brief/run.lock), so a routine that runs
36
+ * late runs once and a second one beside it skips
37
+ * wuwei brief <path>… --schedule [launchd|cron|claude-desktop] [--at HH:MM] [--stale 72h]
38
+ * print the daily routine for a scheduler, to paste; installs nothing
25
39
  * wuwei review-collect [--session <id>] each subagent's transcript and report into
26
40
  * the bundle's reports/, and what each one spent
27
41
  * wuwei digest-check <file> wuwei-review §7's arithmetic before publishing: count,
@@ -43,16 +57,16 @@
43
57
  */
44
58
  import {
45
59
  readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync, rmSync, statSync,
46
- chmodSync,
60
+ chmodSync, realpathSync, renameSync, accessSync, linkSync, constants,
47
61
  } from 'node:fs'
48
- import { join, dirname, resolve } from 'node:path'
62
+ import { join, dirname, basename, resolve, delimiter, isAbsolute, sep } from 'node:path'
49
63
  import { homedir, tmpdir } from 'node:os'
50
64
  import { fileURLToPath } from 'node:url'
51
65
  import { createInterface } from 'node:readline/promises'
52
- import { spawnSync, spawn } from 'node:child_process'
66
+ import { spawnSync, spawn, execFile } from 'node:child_process'
53
67
  import { createRequire } from 'node:module'
54
68
  import { createServer } from 'node:http'
55
- import { createHash } from 'node:crypto'
69
+ import { createHash, randomBytes } from 'node:crypto'
56
70
 
57
71
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
58
72
  const CONFIG = JSON.parse(readFileSync(join(ROOT, 'wuwei.config.json'), 'utf8'))
@@ -658,6 +672,10 @@ async function cmdInstall(target, dry) {
658
672
  // vanishes into a redirected file/pipe and we'd block forever on an answer
659
673
  // nobody can see or give
660
674
  const isInteractive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY)
675
+ // stdout is a person's terminal, one that can show more than text (not TERM=dumb): the markdown's OSC 8 links, --schedule's colour
676
+ const onTerminal = () => Boolean(process.stdout.isTTY) && process.env.TERM !== 'dumb'
677
+ // …and its width: what it reports, else COLUMNS, which a shell sets and a pseudo-terminal may leave the only word on it, else 80 — one read, brief's and --schedule's
678
+ const termCols = () => Number(process.stdout.columns) || Number(process.env.COLUMNS) || 80
661
679
 
662
680
  // npm needs shell:true on Windows to resolve npm.cmd; a missing npm binary
663
681
  // (result.error, e.g. ENOENT) is surfaced distinctly from a clean non-zero exit
@@ -759,10 +777,14 @@ async function cmdUninstall(target) {
759
777
  // directories and flags every mismatch it can prove — and nothing it can't:
760
778
  // "done" is defined by e2e evidence, which is skill territory, so the
761
779
  // done/total column is reported as recorded, never recomputed here.
762
- function readStateRows(stateFile) {
763
- if (!existsSync(stateFile)) return null
780
+ // a path (string) or `{ text }` — told apart by type, never by content, so a
781
+ // path can never be read as a table nor a table as a path. `brief` passes the
782
+ // text of a STATE.md that lives on a branch, not in the working tree
783
+ function readStateRows(src) {
784
+ const text = typeof src === 'string' ? (existsSync(src) ? readFileSync(src, 'utf8') : null) : src.text
785
+ if (text == null) return null
764
786
  const rows = []
765
- for (const line of readFileSync(stateFile, 'utf8').split('\n')) {
787
+ for (const line of text.split('\n')) {
766
788
  const t = line.trim()
767
789
  if (!t.startsWith('|') || t === STATE_HEADER_LINE || isTableSeparatorLine(t)) continue
768
790
  const cells = t.split('|').map(c => c.trim())
@@ -772,6 +794,9 @@ function readStateRows(stateFile) {
772
794
  }
773
795
  return rows
774
796
  }
797
+ // a stream's row: the one its name is, else the first whose cell starts with it, ended by a space or a bracket (U1: `pwa-redesign(M-A)`).
798
+ // One rule for every reader — brief's blocked cell and 🔧 progress, plan-check's count
799
+ const stateRow = (rows, name) => rows?.find(r => r.stream === name) ?? rows?.find(r => r.stream.split(/[\s((]/)[0] === name)
775
800
 
776
801
  function cmdStatus(target) {
777
802
  const dest = resolve(target ?? process.cwd())
@@ -1097,9 +1122,20 @@ async function cmdUpdate(dry, noFetch) {
1097
1122
  const phaseNo = s => String(s).match(/(?:^|\/)(\d+)(?:-|\.md|$)/)?.[1] ?? null
1098
1123
 
1099
1124
  // three labelled fields under SPEC.md ## Sign-off (plan-phases step 8)
1125
+ const signOffLines = spec => (spec.split(/^## Sign-off\s*$/m)[1]?.split(/^## /m)[0] ?? '').split('\n')
1126
+ // the value of the Sign-off line whose LABEL is `label` — a table row's first cell, or a list item's key (`- label: …`, the colon fullwidth too), bold or not —
1127
+ // its first `code`, else its first word, ended by a space or by punctuation, fullwidth or not (`wuwei/x,已推`). A line that only mentions the label, a dated
1128
+ // note, is not it (signOffField takes the first line that does)
1129
+ function signOffValue(spec, label) {
1130
+ for (const l of signOffLines(spec)) {
1131
+ const t = l.trim(), [key, value = ''] = t.startsWith('|') ? t.split('|').slice(1, 3) : t.match(/^(?:[-*+]\s+)?((?:\*\*|__)?[^::*_]+(?:\*\*|__)?)[::](.*)$/)?.slice(1) ?? []
1132
+ if (key?.replace(/\*\*|__/g, '').trim().toLowerCase() !== label) continue
1133
+ return value.match(/`([^`]+)`/)?.[1] ?? (value.replace(/\*\*|__/g, '').trim().split(/[\s((,;、。,;]/)[0] || null)
1134
+ }
1135
+ return null
1136
+ }
1100
1137
  function signOffField(spec, label) {
1101
- const section = spec.split(/^## Sign-off\s*$/m)[1]?.split(/^## /m)[0] ?? ''
1102
- const line = section.split('\n').find(l => new RegExp(label, 'i').test(l))
1138
+ const line = signOffLines(spec).find(l => new RegExp(label, 'i').test(l))
1103
1139
  if (!line) return null
1104
1140
  return line.match(/`([^`]+)`/)?.[1] ?? line.split(/[:|]/).map(x => x.trim()).filter(Boolean).at(-1) ?? null
1105
1141
  }
@@ -1115,44 +1151,128 @@ function setFrontmatter(text, fields) {
1115
1151
  return head + text.slice(end)
1116
1152
  }
1117
1153
 
1118
- function claimPhases(dest, stream) {
1119
- const git = (args, opts) => quiet('git', ['-C', dest, ...args], opts)
1154
+ // git as `brief` runs it in a checkout — a repo it has no reason to trust, whose .git/config, hooks and
1155
+ // attributes name commands git will run. Every git call brief makes there carries this prefix (`-c` outranks
1156
+ // the repo's own config). One prefix centralises the CALL SITES — a git call added later cannot forget it —
1157
+ // not the KEYS: those are a deny-list, and a command git can run that nobody has listed yet stays open until
1158
+ // a review finds it and a key is added here.
1159
+ // Off: every hook (a fetch fires reference-transaction, a gc pre-auto-gc); the hook core.fsmonitor names, run
1160
+ // by anything that reads the index (a backstop: with submodule recursion off, no call brief makes today does,
1161
+ // and test/brief.sh pins the two only as a pair); the command core.alternateRefsCommand names, run by a fetch
1162
+ // of a repo with alternates (`true`: git runs it through sh, where it is a builtin that lists no refs. An empty
1163
+ // one does not skip quietly, it prints `error: cannot run :` before a failed fetch's real reason, and that line
1164
+ // is what `notes` would say; a bare key crashes git); gpg.program, run by log and show on a signed commit,
1165
+ // whose output also lands in front of `%ct`; the submodules' own fetches, whose remotes and configs the origin
1166
+ // picks; and the gc / maintenance a fetch ends in, which rewrites the repo (gc.auto before git 2.30,
1167
+ // maintenance.auto from it: gc.auto=0 stops the gc task, not the loose-objects one, and test/brief.sh pins
1168
+ // maintenance.auto=false alone — gc.auto=0 matters only below 2.30, and no fixture can show it: dropping it
1169
+ // stays green, as for the fsmonitor pair). And one key that is no command but changes what a read returns:
1170
+ // log.diffMerges, which `-m` follows — a checkout's own, or the user's global `off`, `none` or `remerge`, and a
1171
+ // merge commit stops matching `-G^claimed-by:`, so a stream merged by one reads undated; `first-parent` is the diff
1172
+ // every one of those walks means (git 2.32 and later; an older git ignores the key and diffs a merge as `-m`
1173
+ // always did). Still on, because a fetch
1174
+ // needs it and T-3 accepts it with a warning: the TRANSPORT — core.sshCommand, remote.*.uploadpack, a
1175
+ // credential helper, a proxy; `ext::`, a remote URL that is a command (the checkout's own config can allow it,
1176
+ // protocol.ext.allow); and the lazy fetch of a partial clone, which any read below can start for an object it
1177
+ // lacks, from the promisor remote, over the same transport. (core.askPass is off, as is the environment's
1178
+ // GIT_ASKPASS, for the fetch and for every read: git runs an askpass program on a 401 before it looks at
1179
+ // GIT_TERMINAL_PROMPT=0, which switches the terminal prompt off, not a program, and an empty GIT_ASKPASS ends
1180
+ // git's search for one. It is an environment variable, not a `-c`, so it cannot be left out of a call that
1181
+ // carries the prefix. `claim`'s reads share it: in the user's own repo, a lazy fetch that wanted a login then
1182
+ // asks on the terminal if there is one, or fails, where it would have run their askpass.) No `-c` stops a
1183
+ // textconv driver: `--no-textconv` where a diff is read. Nor an attribute that marks markdown `-diff` or
1184
+ // binary (.git/info/attributes, a worktree's .gitattributes, core.attributesFile), which blinds `log -G` to
1185
+ // every line of it: `--text` where a search is made, which diffs the file as text whatever they say
1186
+ const UNTRUSTED_GIT = [
1187
+ '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'core.alternateRefsCommand=true',
1188
+ '-c', 'log.showSignature=false', '-c', 'fetch.recurseSubmodules=false', '-c', 'gc.auto=0', '-c', 'maintenance.auto=false',
1189
+ '-c', 'log.diffMerges=first-parent',
1190
+ ]
1191
+ // No shell on any platform: `quiet` adds one on Windows, for optional tools that are .cmd shims (npm, semgrep —
1192
+ // see runCmd), and git is an .exe that needs none. Through cmd.exe the `^` of `-G^claimed-by:` is eaten, a `&` in a
1193
+ // ref or stream name the remote chose is a second command, and a path with a space (`First Last`) is two arguments.
1194
+ // gitFetch spawns git directly, shell-less. Other callers of quiet('git', …) (claim, review-prep) keep theirs
1195
+ // The env is NO_ENV_TOKEN's (the user's, less GH_TOKEN, GITHUB_TOKEN and GH_DEBUG, defined below and read only at call time): a read in a
1196
+ // partial clone fetches what it lacks lazily, over the checkout's transport, and a credential helper there would read an env token
1197
+ const untrustedGit = (dir, args, opts) => quiet('git', ['-C', dir, ...UNTRUSTED_GIT, ...args], { shell: false, env: { ...NO_ENV_TOKEN, GIT_ASKPASS: '' }, ...opts })
1198
+
1199
+ // a stream branch with no /base: its phase branches `b/NN-…` are refused while `b` is there (the same path rule), and so is `wuwei/<stream>/base` when b is
1200
+ // `wuwei/<stream>`, so "rename it" alone fails — docs/brief/README.md gives the move in order, tested on a scratch repo. The target is the stream's, whatever b is called
1201
+ // (`wuwei/pwa-redesign-stream` moves to `wuwei/pwa-redesign/base`). Said the same by claim and plan-check
1202
+ const moveToBase = (b, stream) => `git refuses '${b}/NN-…' while '${b}' exists — move it to 'wuwei/${stream}/base', the old branch first: docs/brief/README.md, "Moving a stream branch named before v0.1.0"`
1203
+
1204
+ // `fetch: false` trusts the remote-tracking refs as they are (the caller has
1205
+ // fetched, or fetches once per origin); `spec` is SPEC text the caller read from
1206
+ // a branch — any string, '' included, replaces the working tree's SPEC.md. The reads
1207
+ // are untrustedGit (brief calls this on checkouts it was pointed at); `claim`'s own fetch
1208
+ // is not: that is the repo its user is working in, and its hooks and gc are theirs
1209
+ function claimPhases(dest, stream, { fetch = true, spec: given } = {}) {
1210
+ const git = (args, opts) => untrustedGit(dest, args, opts)
1120
1211
  const dir = join(dest, '.wuwei', 'streams', stream)
1121
- const spec = existsSync(join(dir, 'SPEC.md')) ? readFileSync(join(dir, 'SPEC.md'), 'utf8') : ''
1212
+ const spec = typeof given === 'string' ? given : existsSync(join(dir, 'SPEC.md')) ? readFileSync(join(dir, 'SPEC.md'), 'utf8') : ''
1122
1213
  const streamBranch = signOffField(spec, 'stream branch')
1123
1214
  const integration = signOffField(spec, 'integration branch')
1124
1215
  if (!streamBranch) return { error: `${stream}: no stream branch under SPEC.md ## Sign-off — planning owes it (wuwei-plan-phases step 8)` }
1125
1216
  // phase branches sit beside the stream branch, never under it: git cannot
1126
1217
  // hold `x` and `x/01-…` at once, since a ref name is also a path
1127
1218
  if (!/\/base$/.test(streamBranch))
1128
- 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` }
1219
+ return { error: `${stream}: stream branch '${streamBranch}' cannot have phase branches (wuwei-plan-phases step 8): ${moveToBase(streamBranch, stream)}` }
1129
1220
  const prefix = streamBranch.replace(/\/base$/, '')
1130
- git(['fetch', '--quiet', 'origin'], { timeout: 60000 })
1131
- const remote = `origin/${streamBranch}`
1132
- if (!ok(git(['rev-parse', '--verify', '--quiet', remote])))
1221
+ if (fetch) quiet('git', ['-C', dest, 'fetch', '--quiet', 'origin'], { timeout: 60000 })
1222
+ // every read names the remote-tracking ref in full: `origin/<x>` is a local branch of that name first, if the checkout has one
1223
+ // (git warns the name is ambiguous, and quiet() swallows it). `remote` stays the short name — it is what claim prints and checks out
1224
+ const at = n => `refs/remotes/origin/${n}`
1225
+ const remote = `origin/${streamBranch}`, base = at(streamBranch)
1226
+ if (!ok(git(['rev-parse', '--verify', '--quiet', base])))
1133
1227
  return { error: `${remote} does not exist — the stream branch was recorded but never pushed` }
1134
1228
  const behind = integration
1135
- ? Number(git(['rev-list', '--count', `${remote}..origin/${integration}`]).stdout?.trim() || NaN) : NaN
1229
+ ? Number(git(['rev-list', '--count', `${base}..${at(integration)}`]).stdout?.trim() || NaN) : NaN
1136
1230
  // the phase files as the stream branch has them — that is what every session reads
1137
- const files = git(['ls-tree', '--name-only', `${remote}`, `.wuwei/streams/${stream}/phases/`]).stdout
1138
- ?.split('\n').filter(f => f.endsWith('.md')).sort() ?? []
1231
+ // -z: without it git quotes a name with a non-ASCII byte ("01-caf\303\251.md"), which neither ends in .md nor names
1232
+ // a file to `show` — the phase would silently not exist
1233
+ const files = git(['ls-tree', '-z', '--name-only', base, `.wuwei/streams/${stream}/phases/`]).stdout
1234
+ ?.split('\0').filter(f => f.endsWith('.md')).sort() ?? []
1139
1235
  const phases = files.map(f => {
1140
1236
  const name = f.split('/').pop().replace(/\.md$/, '')
1141
- const { data } = parseFrontmatter(git(['show', `${remote}:${f}`]).stdout ?? '')
1237
+ const { data } = parseFrontmatter(git(['show', `${base}:${f}`]).stdout ?? '')
1142
1238
  const branch = `${prefix}/${name}`
1143
- const onRemote = ok(git(['rev-parse', '--verify', '--quiet', `origin/${branch}`]))
1144
- // merged — three traces, strongest first: the pushed branch is inside the
1145
- // stream branch; or the branch is gone and the claim commit (written only
1146
- // on a phase branch) reached the stream branch, which a squash merge leaves
1147
- // too; or a merge commit names the branch
1148
- const how = onRemote
1149
- ? (git(['merge-base', '--is-ancestor', `origin/${branch}`, remote]).status === 0 ? 'branch inside the stream branch' : null)
1150
- : data['claimed-by'] ? 'its claim reached the stream branch'
1151
- : git(['log', '--merges', '--oneline', '-1', '--fixed-strings', `--grep=${branch}`, remote]).stdout.trim() ? 'a merge commit names it'
1239
+ const onRemote = ok(git(['rev-parse', '--verify', '--quiet', at(branch)]))
1240
+ // merged — three traces, strongest first. (a) The pushed branch is inside the stream
1241
+ // branch. (b) The stream branch's own copy of the phase file carries a claim — written
1242
+ // only on a phase branch, so it got there by a merge — which is all a squash merge
1243
+ // leaves of a kept branch. A branch still on origin must also have nothing newer than
1244
+ // that arrival: a claim that reached the stream branch says the phase WAS merged, not
1245
+ // that the branch has not been pushed to since. (c) A merge commit names the branch —
1246
+ // only for a branch that is gone: the match is a substring (`01-api` finds
1247
+ // `01-api-spike`), and a live branch outside the stream branch is not merged however
1248
+ // many merge commits mention it
1249
+ const reworked = () => {
1250
+ // the branch tip's commit time against the newest first-parent commit that changed a line
1251
+ // starting `claimed-by:` in the file. -G, not -S, which counts the string anywhere: a squash
1252
+ // that adds the claim and rewords a prose mention of it would be no hit. And `^`, or a
1253
+ // sentence that mentions `claimed-by:` mid-line, reworded on the base after the branch was
1254
+ // pushed to again, would read as a later claim. It is a line, not the frontmatter: a line
1255
+ // starting `claimed-by:` further down the file counts too.
1256
+ // Limits: a rework committed before the merge but pushed after reads as merged (as does
1257
+ // an equal second, or a time git cannot give); a rework squashed in a second time that
1258
+ // leaves the claim line alone reads claimed until its branch is deleted. --no-textconv: a
1259
+ // pickaxe runs the checkout's own diff.<driver>.textconv (.git/config, .git/info/attributes),
1260
+ // which no -c in UNTRUSTED_GIT stops. --text: the same attributes can mark the file `-diff` or
1261
+ // binary, and -G then finds nothing — `reached` is NaN and a reworked phase reads merged
1262
+ const tip = Number(git(['log', '-1', '--format=%ct', at(branch)]).stdout?.trim() || NaN)
1263
+ const reached = Number(git(['log', '--first-parent', '-m', '-1', '--no-textconv', '--text', '-G^claimed-by:', '--format=%ct', base, '--', f]).stdout?.trim() || NaN)
1264
+ return tip > reached
1265
+ }
1266
+ // the three sentences are `wuwei claim`'s output AND the keys of brief's phaseDate (collectRepo), which looks the
1267
+ // trace up by this text: reword one and the phase reads merged but undated (a note, never a wrong date). test/brief.sh's
1268
+ // E5 dates a phase by each of the three, so it guards all of them; claim.golden pins two (not the merge-commit one)
1269
+ const how = onRemote && git(['merge-base', '--is-ancestor', at(branch), base]).status === 0 ? 'branch inside the stream branch'
1270
+ : data['claimed-by'] && !(onRemote && reworked()) ? 'its claim reached the stream branch'
1271
+ : !onRemote && git(['log', '--merges', '--oneline', '-1', '--fixed-strings', `--grep=${branch}`, base]).stdout.trim() ? 'a merge commit names it'
1152
1272
  : null
1153
1273
  const merged = Boolean(how)
1154
1274
  // a claim lives on the pushed phase branch: read it there first
1155
- const claimed = onRemote ? parseFrontmatter(git(['show', `origin/${branch}:${f}`]).stdout ?? '').data : data
1275
+ const claimed = onRemote ? parseFrontmatter(git(['show', `${at(branch)}:${f}`]).stdout ?? '').data : data
1156
1276
  return {
1157
1277
  name, no: phaseNo(name), file: f, branch, onRemote, merged, how,
1158
1278
  dependsOn: [].concat(data['depends-on'] ?? []).map(phaseNo).filter(Boolean),
@@ -1236,9 +1356,11 @@ function cmdClaim(argv) {
1236
1356
  : r.behind === 0 ? `current with origin/${r.integration}`
1237
1357
  : `${r.behind} commit(s) behind origin/${r.integration}`
1238
1358
  console.log(`stream ${stream} · ${r.remote} · ${sync}\n`)
1239
- const w = Math.max(...r.phases.map(p => p.name.length))
1359
+ // a phase's file name and its claimed-by come off a branch somebody pushed: `plain`, as brief's output is (a `\n` is a
1360
+ // Cc too, so it is each piece that is cleaned, never a line that already holds the structure)
1361
+ const w = Math.max(...r.phases.map(p => plain(p.name).length))
1240
1362
  for (const p of r.phases)
1241
- console.log(` ${p.name.padEnd(w)} ${p.state}${p.review === 'human' ? ' ← review: human' : ''}`)
1363
+ console.log(` ${plain(p.name).padEnd(w)} ${plain(p.state)}${p.review === 'human' ? ' ← review: human' : ''}`)
1242
1364
 
1243
1365
  const policy = modelPolicy(existsSync(join(streamsDir, stream, 'SPEC.md')) ? readFileSync(join(streamsDir, stream, 'SPEC.md'), 'utf8') : '')
1244
1366
  if (policy.error) { console.error(`\n✗ ${policy.error} — fix ## Sign-off (wuwei plan-check)`); process.exit(1) }
@@ -1252,7 +1374,7 @@ function cmdClaim(argv) {
1252
1374
  const p = r.phases.find(x => x.no === phaseNo(phaseArg) || x.name === phaseArg)
1253
1375
  if (!p) { console.error(`✗ ${phaseArg} is not a phase of ${stream}`); process.exit(1) }
1254
1376
  const takeOver = argv.includes('--take-over')
1255
- const refuse = msg => { console.error(`\n✗ ${p.name}: ${msg}`); process.exit(1) }
1377
+ const refuse = msg => { console.error(`\n✗ ${plain(p.name)}: ${plain(msg)}`); process.exit(1) }
1256
1378
  if (p.merged) refuse('already merged')
1257
1379
  if (p.state.startsWith('waiting') || p.state.startsWith('blocked')) refuse(p.state)
1258
1380
  if ((p.claimedBy || p.onRemote) && !takeOver)
@@ -1265,15 +1387,18 @@ function cmdClaim(argv) {
1265
1387
  if (quiet('git', ['status', '--porcelain', '--untracked-files=no']).stdout.trim())
1266
1388
  refuse('uncommitted changes in this checkout — commit or stash first')
1267
1389
 
1268
- const by = flag('--by') ?? `${process.env.USER || process.env.USERNAME || 'unknown'}@${(quiet('hostname', []).stdout || 'host').trim()}`
1269
- const at = new Date().toISOString().replace(/\.\d+Z$/, 'Z')
1390
+ // Claude Code (2.1.x) exports its session's id to the commands it runs as CLAUDE_CODE_SESSION_ID — observed, not documented: it is the local transcript's file name. Written beside the claimer
1391
+ // when no --by names one and it is a uuid, so the claimed-by that brief reads names the session (claimedSession); anything else in it is the environment's text and is ignored, never written
1392
+ const me = `${process.env.USER || process.env.USERNAME || 'unknown'}@${(quiet('hostname', []).stdout || 'host').trim()}`, sid = process.env.CLAUDE_CODE_SESSION_ID
1393
+ const by = flag('--by') ?? (!argv.includes('--by') && UUID.test(sid ?? '') ? `${me} (session ${sid})` : me)
1394
+ const at = isoOf(Date.now() / 1000)
1270
1395
  const dry = argv.includes('--dry')
1271
1396
  const steps = [
1272
1397
  p.onRemote ? `check out ${p.branch} from origin (taking over)` : `cut ${p.branch} from ${r.remote}`,
1273
1398
  `claimed-by: ${by} · claimed-at: ${at}${takeOver ? ' · takeover recorded' : ''}`,
1274
1399
  `commit and push ${p.branch}`,
1275
1400
  ]
1276
- console.log(`\n${dry ? '[dry] ' : ''}claim ${p.name}:\n${steps.map(s => ` ${s}`).join('\n')}`)
1401
+ console.log(`\n${dry ? '[dry] ' : ''}claim ${plain(p.name)}:\n${steps.map(s => ` ${plain(s)}`).join('\n')}`)
1277
1402
  if (dry) return
1278
1403
  const run = (args, what) => { const x = quiet('git', args); if (!ok(x)) refuse(`${what} failed: ${firstLine(x.stderr)}`) }
1279
1404
  run(['checkout', '-B', p.branch, p.onRemote ? `origin/${p.branch}` : r.remote], 'checkout')
@@ -1285,7 +1410,3009 @@ function cmdClaim(argv) {
1285
1410
  run(['add', p.file], 'git add')
1286
1411
  run(['commit', '-q', '-m', `claim(${stream}): ${p.name}${takeOver ? ' — taken over' : ''}`], 'commit')
1287
1412
  run(['push', '-q', '-u', 'origin', p.branch], 'push')
1288
- console.log(`\n✓ claimed ${p.name} on ${p.branch}${p.tracker ? ` · tracker ${p.tracker}: move it under step 1's write rule` : ''}`)
1413
+ console.log(`\n✓ claimed ${plain(p.name)} on ${plain(p.branch)}${p.tracker ? ` · tracker ${plain(p.tracker)}: move it under step 1's write rule` : ''}`)
1414
+ }
1415
+
1416
+ // ── brief: every wuwei repo under the paths given — found, fetched, read at origin ──
1417
+ // writes ~/.wuwei/brief.json (and `.bad` beside it, where an unreadable one is kept) and,
1418
+ // in each repo, what `git fetch --prune --no-tags --no-write-fetch-head --refmap= origin <refspecs>` writes — the
1419
+ // refspecs being the checkout's own, see gitFetch: the objects it fetches, remote-tracking refs under
1420
+ // refs/remotes/origin/ (pruned ones removed, inside what the refspecs track), and a commit-graph if the repo's own
1421
+ // config asks for one (fetch.writeCommitGraph); in a partial clone, also the objects a read fetches when it lacks
1422
+ // one (from the promisor remote). Nothing else — no tags, no FETCH_HEAD (the user's own last fetch's, which `git
1423
+ // merge FETCH_HEAD` reads; needs git ≥ 2.29), and auto-maintenance is off, so no repack, prune or gc. This is its
1424
+ // plumbing (discover → fetch → record); collectRepo and the renderers sit on it
1425
+
1426
+ // a repo is its origin URL normalised (SPEC Constraints): scheme, user, port, `.git`
1427
+ // and a trailing `/` dropped, host lowercased, `git@host:o/r` read as `host/o/r` — an
1428
+ // SSH and an HTTPS clone are one repo. The port goes too: `ssh://host:2222/o/r` and
1429
+ // `https://host/o/r` are the same repo, and a collapse of two servers sharing a host
1430
+ // and a path is the lesser risk. A path or file:// URL has no host (`/srv/git/r`);
1431
+ // `dir` resolves a relative one the way git would. Dropping the user also keeps an
1432
+ // embedded token out of the record and the output — when the URL is malformed too: a
1433
+ // bad escape keeps the path as written, an unparseable URL the text as given, still
1434
+ // deduped against itself, with its user cut by hand: from the `//` through the LAST `@`, since
1435
+ // a password need not be escaped — it can hold `@`, `/`, a space, a line break (hence the `s` flag:
1436
+ // `.` stops at \r, \n, U+2028 and U+2029 without it) — and no parser then says where the user ends.
1437
+ // An `@` in the path takes the host with it, which costs less than a leak
1438
+ const noUserinfo = s => s.replace(/\/\/.*@/gs, '//')
1439
+ // …and the same reading, as the text itself: the user as the config wrote it, to cut from a text that quotes the URL
1440
+ const userinfoOf = url => url.match(/\/\/(.*)@/s)?.[1]
1441
+
1442
+ // Text a repo or a remote chose goes to a terminal and to an agent: nothing in it may change how it reads. So
1443
+ // each of these is a space: \p{Cc}, C0 and DEL and C1 (an escape sequence that erases or rewrites a line, a bare
1444
+ // \r that overwrites it, a line feed that starts a heading of the attacker's; U+009B opens an escape sequence in
1445
+ // a terminal that reads 8-bit controls), \p{Cf}, the format characters (a bidi override reorders what a reader
1446
+ // sees; zero-width characters hide text), \p{Zl} and \p{Zp}, the line and paragraph separators (U+2028/9), which
1447
+ // start a new line in a viewer that honours them. JSON.stringify is no help: it escapes C0 and prints the rest.
1448
+ // WHERE it is applied is the point, and it is in three places. Raw text has a job upstream — a path is opened, a
1449
+ // `-z` list is split on NUL, the record's keys are matched on the next run — so it is not cleaned at the source;
1450
+ // the places it is all final, and nothing parses it again, are the object about to be printed (cmdBrief) and
1451
+ // the notice's input (t3Notice, built before any repo is read). The third, gitFetch's error line, is the
1452
+ // exception and stays one: it comes straight off a remote's stderr, nothing reads it but a reader (the
1453
+ // credential check reads the raw stderr, not the line), and fetchAll hands it on, so a caller other than
1454
+ // cmdBrief gets it clean too. cmdBrief's pass over it is then a second one. Walking the whole object, not
1455
+ // naming fields, covers what a later phase adds to it — a key as much as a value, in case one is keyed by a
1456
+ // name a checkout chose (two keys that differ only in the characters cleaned become one, the later winning).
1457
+ // brief.json keeps the raw origin and real paths for that reason, and is never printed
1458
+ const plain = v => typeof v === 'string' ? v.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ' ')
1459
+ : Array.isArray(v) ? v.map(plain)
1460
+ : isPlain(v) ? Object.fromEntries(Object.entries(v).map(([k, x]) => [plain(k), plain(x)])) : v
1461
+ function normalizeOrigin(url, dir) {
1462
+ const u = url.trim()
1463
+ let host = '', path = u
1464
+ const scheme = u.match(/^([a-z][a-z0-9+.-]*):\/\//i)?.[1]
1465
+ if (scheme) {
1466
+ try {
1467
+ const p = new URL(u)
1468
+ host = scheme.toLowerCase() === 'file' ? '' : p.hostname.toLowerCase()
1469
+ try { path = decodeURIComponent(p.pathname) } catch { path = p.pathname }
1470
+ } catch { path = noUserinfo(u) }
1471
+ } else {
1472
+ const scp = u.match(/^(?:[^@/\s]+@)?([^/:\s]+):(.*)$/) // host:path — a colon before any slash
1473
+ if (scp) { host = scp[1].toLowerCase(); path = scp[2] }
1474
+ else path = resolve(dir, u)
1475
+ }
1476
+ return (host ? `${host}/${path.replace(/^\/+/, '')}` : path).replace(/\/+$/, '').replace(/\.git$/, '').replace(/\/+$/, '')
1477
+ }
1478
+
1479
+ // the host an ssh origin hands ssh — `[user@]host:o/r`, or `ssh://[user@]host[:port]/o/r` (`git+ssh://` and `ssh+git://`, git's other names for it) — which may be an alias of the user's ssh config
1480
+ // (`Host github.com-ewe`, one key per GitHub account): ghAccess asks ssh what it stands for. Any other origin has none. Kept beside the origin and never
1481
+ // in it: the origin names the repo (and keys the record), this only says where ssh goes, and it is neither printed nor recorded
1482
+ const sshHostOf = url => {
1483
+ const u = url.trim()
1484
+ if (/^(ssh|git\+ssh|ssh\+git):\/\//i.test(u)) { try { return new URL(u).hostname || undefined } catch { return undefined } }
1485
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(u) ? undefined : u.match(/^(?:[^@/\s]+@)?([^/:\s]+):/)?.[1]
1486
+ }
1487
+
1488
+ // every checkout — a dir holding both `.wuwei/` and `.git` (a file, in a worktree) —
1489
+ // under the paths, grouped by normalised origin: [{ origin, checkouts: [abs path], sshHost? }] (sshHostOf, the first checkout's).
1490
+ // T-4, so a wide path (`~`) cannot walk the disk: 4 levels below each path, no symlink
1491
+ // followed, `node_modules` and dot-dirs below the top skipped. A checkout with no
1492
+ // origin is a group of its own, `origin: null` — and `why`, when it is no missing remote: git refuses `remote get-url`
1493
+ // while remote.origin.fetch holds a refspec it cannot parse. `checkouts[0]` is the one fetchAll
1494
+ // fetches and collectRepo reads: two clones of one origin hold separate remote-tracking
1495
+ // refs, so the one fetched must be the one read. (`discover(kind)` above is the skills')
1496
+ const realOr = p => { try { return realpathSync(p) } catch { return p } }
1497
+ function discoverRepos(paths) {
1498
+ const found = new Map() // real path → path as walked: two names for one dir list once
1499
+ const walk = (dir, depth) => {
1500
+ let ents
1501
+ try { ents = readdirSync(dir, { withFileTypes: true }) } catch { return }
1502
+ if (ents.some(e => e.name === '.wuwei' && e.isDirectory()) && ents.some(e => e.name === '.git')) {
1503
+ const real = realOr(dir)
1504
+ if (!found.has(real)) found.set(real, dir)
1505
+ }
1506
+ if (depth === 4) return
1507
+ for (const e of ents.sort((a, b) => (a.name < b.name ? -1 : 1)))
1508
+ if (e.isDirectory() && e.name !== 'node_modules' && !e.name.startsWith('.')) walk(join(dir, e.name), depth + 1)
1509
+ }
1510
+ for (const p of paths) walk(resolve(p), 0)
1511
+ const groups = new Map()
1512
+ for (const dir of found.values()) {
1513
+ const url = untrustedGit(dir, ['remote', 'get-url', 'origin'])
1514
+ const origin = ok(url) && url.stdout.trim() ? normalizeOrigin(url.stdout, dir) : null
1515
+ const key = origin ?? `\0${dir}` // no origin: never shares a group
1516
+ const why = !ok(url) && /invalid refspec/.test(url.stderr ?? '')
1517
+ ? `git rejects remote.origin.fetch (${firstLine(url.stderr).replace(/^fatal: /, '')}) — origin unknown, so read from the local checkout only: not fetched, and no PRs read` : undefined
1518
+ const sshHost = origin && sshHostOf(url.stdout)
1519
+ if (!groups.has(key)) groups.set(key, { origin, checkouts: [], ...(why ? { why } : {}), ...(sshHost ? { sshHost } : {}) })
1520
+ groups.get(key).checkouts.push(dir)
1521
+ }
1522
+ return [...groups.values()]
1523
+ }
1524
+
1525
+ // What it fetches is the checkout's own business: the refspec is the checkout's. brief wants the refs it reads
1526
+ // and nothing else, so on the command line go the checkout's `remote.origin.fetch` entries that fill
1527
+ // refs/remotes/origin/ and do not start at refs/tags/ (a negative `^…` one narrows the rest, so it stays), plus
1528
+ // `+refs/heads/wuwei/*`: the stream and phase branches are read even in a `--single-branch` clone, whose own
1529
+ // refspec is one branch. A shallow clone (`--depth` implies `--single-branch`) is narrowed on purpose and grows
1530
+ // with every head fetched, so it gets its own entries only: they may bring the stream branches (`--no-single-branch`
1531
+ // tracks every head) or none, which collectRepo says and `reachesStreams` answers, for --mark.
1532
+ // The `wuwei/*` entry rides on a positive entry of the clone's own: with none (a negative entry alone, tags alone, a
1533
+ // mirror's `refs/*:refs/*`) nothing is fetched, not even it, and `remote.origin.mirror` set says the clone is not
1534
+ // brief's to add to. It goes FIRST: two entries for one ref, git keeps the first, and its force flag — a clone's own
1535
+ // `refs/heads/*:refs/remotes/origin/*` without a `+` would otherwise refuse a force-pushed phase branch
1536
+ // A refspec of brief's own, `+refs/heads/*:refs/remotes/origin/*`, was wrong three ways. `--prune`
1537
+ // deletes what another configured refspec put under refs/remotes/origin/ (`+refs/pull/*/head:refs/remotes/origin/pr/*`);
1538
+ // it widened every clone narrowed on purpose; and git still maps the refs it fetched through the CONFIGURED
1539
+ // refspecs — `refs/heads/*:refs/remotes/mirror/*` was written, `refs/heads/*:refs/heads/*` overwrote a local
1540
+ // branch — which `--refmap=` (empty) stops. A refspec on the command line replaces the configured ones, so a
1541
+ // tags entry is out by the filter and fetch.pruneTags / remote.origin.pruneTags are not consulted (git 2.43; the
1542
+ // hz fixture sets both and the harness would go red on a git where they were), and `--prune` prunes only
1543
+ // inside the destinations given. A tag source mapped into refs/remotes/origin/ (`+refs/tags/*:refs/remotes/origin/tags/*`)
1544
+ // is out too, and the usual `refs/heads/*` entry's prune then removes what it put there: refs a plain fetch brings back.
1545
+ // The filter reads the entry's SOURCE and goes no further: a broader glob, `+refs/*:refs/remotes/origin/*`, matches tags
1546
+ // too and stays — it is the only entry that clone has for its branches (which land under origin/heads/), so dropping it
1547
+ // would leave nothing to fetch — and the tags come with it, under refs/remotes/origin/tags/, as they do for the clone's
1548
+ // own `git fetch`. brief's `--no-tags` does not stop a tag an explicit refspec asks for
1549
+ // `--` first: an entry the checkout wrote that starts with `-` is no option
1550
+ const WUWEI_REFSPEC = '+refs/heads/wuwei/*:refs/remotes/origin/wuwei/*'
1551
+ const isShallow = dir => untrustedGit(dir, ['rev-parse', '--is-shallow-repository']).stdout?.trim() === 'true'
1552
+ function fetchRefspecs(dir) {
1553
+ const cfg = untrustedGit(dir, ['config', '--get-all', 'remote.origin.fetch'])
1554
+ const own = (ok(cfg) ? cfg.stdout.split('\n') : []).filter(e => {
1555
+ if (e.startsWith('^')) return true
1556
+ const [src, dst = ''] = e.replace(/^\+/, '').split(':')
1557
+ return src && !src.startsWith('refs/tags/') && dst.startsWith('refs/remotes/origin/')
1558
+ })
1559
+ const mirror = untrustedGit(dir, ['config', '--bool', '--get', 'remote.origin.mirror']).stdout?.trim() === 'true'
1560
+ return own.some(e => !e.startsWith('^')) && !mirror && !isShallow(dir) ? [WUWEI_REFSPEC, ...own] : own
1561
+ }
1562
+
1563
+ // Can a fetch of these entries bring a stream branch to where collectRepo reads it, refs/remotes/origin/wuwei/<s>/base? One
1564
+ // `wuwei: true` or false per fetch, for --mark: a mark moved past a stream branch that merged unseen skips it for good. An entry
1565
+ // counts when its source takes the name of a stream's base — `s` stands for any stream — and puts it there, and no `^` entry
1566
+ // takes it out (git applies those to every positive entry on the command line, brief's own wuwei/* included). git's glob: one `*`
1567
+ // a side, any run of characters, `/` too. An entry that names one stream's base is tried with that name. Whatever cannot be told
1568
+ // — a source that names part of a stream's name (`refs/heads/wuwei/pe*`) — reads as no: the side on which a mark stays put
1569
+ const STREAM_BASE = 'refs/heads/wuwei/s/base'
1570
+ const landing = (e, name) => { // where a fetch of `[+^]src[:dst]` puts `name`, or null when src does not take it
1571
+ const [src, dst = ''] = e.replace(/^[+^]/, '').split(':')
1572
+ const [pre, post, ...more] = src.split('*')
1573
+ const takes = more.length === 0 && (post === undefined ? src === name : name.startsWith(pre) && name.endsWith(post) && name.length >= pre.length + post.length)
1574
+ return takes ? dst.replace('*', name.slice(pre.length, name.length - (post?.length ?? 0))) : null
1575
+ }
1576
+ const reachesStreams = specs => specs.some(e => {
1577
+ if (e.startsWith('^')) return false
1578
+ const src = e.replace(/^\+/, '').split(':')[0], name = /^refs\/heads\/wuwei\/[^/]+\/base$/.test(src) ? src : STREAM_BASE
1579
+ return landing(e, name) === name.replace('refs/heads/', 'refs/remotes/origin/') && !specs.some(n => n.startsWith('^') && landing(n, name) !== null)
1580
+ })
1581
+
1582
+ // one `git fetch` of a checkout, async: a slow remote must not hold up the others.
1583
+ // GIT_TERMINAL_PROMPT=0 — a routine has no one to type a password — and GIT_ASKPASS empty: git runs an askpass
1584
+ // program on a 401 before it looks at GIT_TERMINAL_PROMPT=0, and which one is the checkout's to say
1585
+ // (core.askPass), so none runs; an empty GIT_ASKPASS ends git's search for one (core.askPass and SSH_ASKPASS
1586
+ // are never reached), and the user's own GIT_ASKPASS is off with it. A remote that wants a login then fails
1587
+ // with "terminal prompts disabled", which is what NEEDS_CREDENTIALS reads. brief wants the refs and nothing
1588
+ // else: UNTRUSTED_GIT keeps the repo's commands off (what it still runs is the transport), `--no-tags`
1589
+ // leaves the tag namespace alone (brief reads branches; a later plain fetch still follows the tags,
1590
+ // as they point into history that is already here), and fetchRefspecs says which refs. Never rejects
1591
+ // the environment brief runs gh and its fetches in: the user's, less GH_TOKEN and GITHUB_TOKEN — an env token is often a CI's or a script's, scoped to one
1592
+ // repo or none of the org's (the user's call, 2026-10-16: unsure what it can read, never use it), and a fetch's credential helper (`gh auth git-credential`
1593
+ // after `gh auth setup-git`) would read it as gh does — and less GH_DEBUG, whose lines would come before the one brief reads and could echo a header. Without
1594
+ // case: Windows reads `gh_token` as GH_TOKEN. So gh is its stored login (keyring, or hosts.yml where there is no keyring) and has no env token to echo (T-1)
1595
+ const NO_ENV_TOKEN = Object.fromEntries(Object.entries(process.env).filter(([k]) => !['GH_TOKEN', 'GITHUB_TOKEN', 'GH_DEBUG'].includes(k.toUpperCase())))
1596
+ const NEEDS_CREDENTIALS = /could not read (Username|Password)|terminal prompts disabled|Authentication failed|Permission denied|Invalid username or password|returned error: 40[13]/i
1597
+ function gitFetch(dir, timeout) {
1598
+ return new Promise(done => {
1599
+ let err = ''
1600
+ const url = untrustedGit(dir, ['remote', 'get-url', 'origin'])
1601
+ const user = ok(url) ? userinfoOf(url.stdout.trim()) : undefined
1602
+ const specs = fetchRefspecs(dir)
1603
+ // no positive refspec on the command line and git reads the configured ones — a tags entry among them — or, with --refmap=, refuses
1604
+ if (!specs.some(e => !e.startsWith('^'))) return done({ ok: false, error: 'no remote.origin.fetch entry fills refs/remotes/origin/ — nothing brief reads would be fetched', needsCredentials: false })
1605
+ const c = spawn('git', ['-C', dir, ...UNTRUSTED_GIT, 'fetch', '--no-tags', '--no-write-fetch-head', '--prune', '--quiet', '--refmap=', '--', 'origin', ...specs],
1606
+ { env: { ...NO_ENV_TOKEN, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '' }, stdio: ['ignore', 'ignore', 'pipe'] })
1607
+ c.stderr.setEncoding('utf8')
1608
+ c.stderr.on('data', d => { if (err.length < 65536) err += d })
1609
+ // SIGTERM lets git take its ssh / remote helper down with it; a helper that
1610
+ // outlives it could hold stderr open forever, so settle now and drop the pipe
1611
+ const timer = setTimeout(() => {
1612
+ c.kill()
1613
+ setTimeout(() => c.kill('SIGKILL'), 5000).unref()
1614
+ c.stderr.destroy()
1615
+ done({ ok: false, error: `git fetch timed out after ${timeout / 1000}s`, needsCredentials: false })
1616
+ }, timeout)
1617
+ c.on('error', e => { clearTimeout(timer); done({ ok: false, error: e.message, needsCredentials: false }) })
1618
+ c.on('close', code => {
1619
+ clearTimeout(timer)
1620
+ // an error line can quote the remote URL: never pass a user:password on. Two cuts, in this order.
1621
+ // The origin's own user, in the spelling the config wrote it or percent-decoded (git decodes the userinfo
1622
+ // of an ssh:// URL before ssh echoes it: `p%40ss` comes back as `alice:p@ss@host: Permission denied`),
1623
+ // wherever it stands in stderr and with or without a `//` before it: a password can hold a line feed,
1624
+ // which ends the first line before the `@` that says where the user stops, so only a user known as a
1625
+ // literal is found across it; and OpenSSH names it with no URL around it (`user:pw@host: Permission
1626
+ // denied (publickey).`). A user known and in neither spelling — a transport re-encoded it, a CRLF for
1627
+ // the config's LF — is looked for the other way: with a `//` in stderr, `noUserinfo` over all of it;
1628
+ // with none, everything up to the last `@`. Either may take the host and a later line's `@` with it, and
1629
+ // that is the price of failing closed. Then the first line, cut by `noUserinfo` — for a URL the origin
1630
+ // does not carry, one a transport quotes — and not before it, when there is no user to go by: over all of
1631
+ // stderr the `//.*@` would run from the URL to an `@` in a later line (`hint: ask admin@example.com`)
1632
+ // and take the host with it. (A quoted user the origin lacks, with a line feed in its password, is
1633
+ // beyond both: nothing says where it ends.) The line can come from the server, so it is `plain`
1634
+ const decode = u => { try { return decodeURIComponent(u) } catch { return u } }
1635
+ const spelled = user ? [user, decode(user)].find(u => err.includes(`${u}@`)) : undefined
1636
+ const text = !user ? err : spelled ? err.split(`${spelled}@`).join('') : err.includes('//') ? noUserinfo(err) : err.replace(/^.*@/s, '')
1637
+ done(code === 0 ? { ok: true, wuwei: reachesStreams(specs) }
1638
+ : { ok: false, error: plain(noUserinfo(firstLine(text))).trim() || `git fetch exited ${code}`, needsCredentials: NEEDS_CREDENTIALS.test(err) })
1639
+ })
1640
+ })
1641
+ }
1642
+
1643
+ // f(0) … f(n - 1), `limit` at a time, in index order as each worker frees up: the one pool of the fetches (fetchAll), the access probes (ghAccess) and the chunk searches (ghSearch)
1644
+ const pool = (n, limit, f) => {
1645
+ let next = 0
1646
+ return Promise.all(Array.from({ length: Math.min(limit, n) }, async () => { for (let i = next++; i < n; i = next++) await f(i) }))
1647
+ }
1648
+
1649
+ // each group fetched once — in checkouts[0], where collectRepo reads — `limit` at a
1650
+ // time. Result i answers groups[i]: { ok, fetchedAt, wuwei?, error?, needsCredentials? }.
1651
+ // `wuwei`, on a fetch that worked: whether it could bring the stream branches (reachesStreams).
1652
+ // fetchedAt is the last GOOD fetch of that origin (R11: FETCH_HEAD is rewritten by a
1653
+ // failed fetch): now when ok, else what `record` holds — none → never fetched by
1654
+ // brief — so a failure is stale since then. `record` is read once, before the fetches
1655
+ async function fetchAll(groups, { record = readRecord(), limit = 8, timeout = 60000 } = {}) {
1656
+ const out = new Array(groups.length)
1657
+ await pool(groups.length, limit, async i => {
1658
+ const g = groups[i]
1659
+ if (g.origin == null) { out[i] = { ok: false, error: 'no origin remote — nothing to fetch' }; return }
1660
+ const r = await gitFetch(g.checkouts[0], timeout)
1661
+ const was = record.origins[g.origin]?.fetchedAt
1662
+ out[i] = r.ok ? { ok: true, fetchedAt: isoOf(Date.now() / 1000), wuwei: r.wuwei }
1663
+ : { ...r, ...(was ? { fetchedAt: was } : {}) }
1664
+ })
1665
+ return out
1666
+ }
1667
+
1668
+ // ~/.wuwei/brief.json — { ranAt?, origins: { <normalised origin>: { fetchedAt?, checkouts?,
1669
+ // markedAt?, githubMarkedAt? } } }, SPEC `## Output contract`. `ranAt` (phase 25) is the run's, not a repo's: { <the real paths the routine was given, sorted, joined by ', '>: the second a --mark run that moved
1670
+ // a mark started } (round 1: one for the whole record made two routines over different folders skip each other; an older record's is one string, read as every folders'), what --if-due reads. `checkouts` are the real
1671
+ // paths of the checkouts of that origin the T-3 notice has been printed for. A file that
1672
+ // is there but not that shape reads as empty (`unreadable`), and writeRecord keeps it
1673
+ // aside rather than lose it
1674
+ const recordPath = () => join(wuweiHome(), 'brief.json')
1675
+ const isPlain = v => v && typeof v === 'object' && !Array.isArray(v)
1676
+ function readRecord() {
1677
+ let text
1678
+ try { text = readFileSync(recordPath(), 'utf8') } catch { return { origins: {} } }
1679
+ try {
1680
+ const j = JSON.parse(text)
1681
+ if (isPlain(j) && (j.origins === undefined || isPlain(j.origins))) return { ...j, origins: j.origins ?? {} }
1682
+ } catch {}
1683
+ return { origins: {}, unreadable: true }
1684
+ }
1685
+
1686
+ // a file made whole or not at all: written to a temp file beside it, then renamed over it, so a reader never sees half of one. The temp name is random and the file is made with `wx` (O_EXCL):
1687
+ // a link planted at a guessable name — the old `<file>.<pid>.tmp` — is never followed, and a name that is taken fails the write, never overwrites what it names. `mode` is the new file's, the
1688
+ // umask applying as for any file (none: 0o666); the old file's is not kept, the rename replaces it. Throws if it cannot write; a temp file it made is removed, one it did not make is left alone
1689
+ function writeAtomic(file, data, mode) {
1690
+ const tmp = `${file}.${randomBytes(6).toString('hex')}.tmp`
1691
+ let made = false
1692
+ try { writeFileSync(tmp, data, { mode, flag: 'wx' }); made = true; renameSync(tmp, file) } catch (e) { if (made || e.code !== 'EEXIST') rmSync(tmp, { force: true }); throw e }
1693
+ }
1694
+
1695
+ // the run's one write, at its end: `update` is { <origin>: { fetchedAt?, checkouts?,
1696
+ // markedAt?, githubMarkedAt? } } — only the origins and fields this run has to say, merged
1697
+ // into the file as it is NOW (another run may have written since the one fetchAll read),
1698
+ // field by field, an undefined never erasing and a list (`checkouts`) added to, never
1699
+ // replaced. `top` is the run's own fields (`ranAt`: { <folders>: time }), merged into what the file holds one folders' key at a time — a run over other folders that wrote since is kept, an undefined erases the key, and an
1700
+ // older string `ranAt` is replaced — and the rest of its top level is kept as it is. Through writeAtomic, so a reader never
1701
+ // sees half a file, and at mode 600 — it names the user's repos and checkout paths, as the page does (phase 23, security S2: it was written at the umask, 644 beside a 600 page; an older
1702
+ // 644 file is replaced by the rename, not kept). Throws if it cannot write: the caller says so and carries on
1703
+ function writeRecord(update, top = {}) {
1704
+ const file = recordPath()
1705
+ const { unreadable, ...cur } = readRecord()
1706
+ if (unreadable) renameSync(file, `${file}.bad`)
1707
+ for (const [origin, fields] of Object.entries(update)) {
1708
+ const prev = isPlain(cur.origins[origin]) ? cur.origins[origin] : {}
1709
+ cur.origins[origin] = { ...prev, ...Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined)
1710
+ .map(([k, v]) => [k, Array.isArray(v) ? [...new Set([...[].concat(prev[k] ?? []), ...v])] : v])) }
1711
+ }
1712
+ for (const [field, byFolders] of Object.entries(top)) {
1713
+ const next = { ...(isPlain(cur[field]) ? cur[field] : {}), ...byFolders }
1714
+ for (const k of Object.keys(next)) if (next[k] === undefined) delete next[k]
1715
+ if (Object.keys(next).length) cur[field] = next; else delete cur[field]
1716
+ }
1717
+ mkdirSync(dirname(file), { recursive: true })
1718
+ writeAtomic(file, JSON.stringify(cur, null, 2) + '\n', 0o600)
1719
+ return cur
1720
+ }
1721
+
1722
+ // ── brief: collectRepo — one repo's wuwei state, as origin has it ─────
1723
+ // R2's buckets: a stream is listed once, under the strongest of open (its base is on
1724
+ // origin) > merged > no-stream-branch > unsigned. Everything but the working trees is
1725
+ // read with git at origin/… in checkouts[0] — the checkout fetchAll fetched; the trees
1726
+ // are read only for the two weakest buckets, since a plan not yet pushed lives nowhere else
1727
+
1728
+ // an age as <N>[smhd]: seconds under a minute, minutes under an hour, hours under two days,
1729
+ // days after
1730
+ const fmtAge = ms => {
1731
+ const s = Math.floor(Math.max(ms, 0) / 1000)
1732
+ return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 172800 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d`
1733
+ }
1734
+ // a time as the JSON carries it, to the second: epoch seconds → `2026-10-15T06:08:14Z`
1735
+ const isoOf = s => new Date(s * 1000).toISOString().replace(/\.\d+Z$/, 'Z')
1736
+ // a time as the markdown prints it: MM-DD HH:MM, local
1737
+ const fmtAt = ms => { const d = new Date(ms), p = n => String(n).padStart(2, '0'); return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}` }
1738
+
1739
+ const isSignedOff = spec => /^## Sign-off/m.test(spec) // as cmdStatus reads it
1740
+
1741
+ // R13, and the shapes U1 met (phase 05): what `## Open Questions` still asks. A table row is
1742
+ // answered when its last cell starts with resolved/answered/closed/已答/已解決/✅ (`**` ignored),
1743
+ // its question cell is struck through whole, or that cell carries a bold verdict as a list item
1744
+ // does (U1's re-run); a question whose own text says "resolved" (not bold) stays open. A list item
1745
+ // is answered when it starts with `[x]`, or `~~` (behind a `[ ]` too), or carries a bold verdict,
1746
+ // or a "— resolved" clause. A bold verdict is `**Answered`/`Resolved`/`已答`/`已解決` where a verdict
1747
+ // stands — the text's start (behind a box), or past the question's end (`?` `?` `.` `。` `)` `)` `~~`,
1748
+ // then spaces or a dash) — and ends as one does: `:`, `**`, a date, a `(`, or the text's end. Only
1749
+ // that counts: a bare "answered" mid-sentence, a `the **answered** flag`, or a `**Resolved vs …?**`
1750
+ // is the question's own word, and a question hidden by mistake is worse than one listed twice. The text is the question column
1751
+ // (the header cell that says question/問, else the first cell that is no id — `#`, `3`, `OQ-1`,
1752
+ // `Q3`) or the item without its `[ ] ` box, cut at 60
1753
+ const BOLD_ANSWER = /(?:^(?:\[[ xX]\]\s*)?|(?:[??.。))]|~~)[\s—]*)\*\*(?:answered|resolved|已答|已解決)(?=\s*\d|[::((]|\*\*|$)/i
1754
+ function openQuestions(spec) {
1755
+ const lines = (spec.replace(/\r\n?/g, '\n').split(/^## Open Questions\s*$/m)[1]?.split(/^## /m)[0] ?? '').split('\n')
1756
+ const cut = t => { const c = [...t]; return c.length > 60 ? `${c.slice(0, 60).join('')}…` : t } // by code point: an emoji at the 60th place is kept whole
1757
+ const out = []
1758
+ let col = -1
1759
+ lines.forEach((line, i) => {
1760
+ const t = line.trim()
1761
+ if (t.startsWith('|')) {
1762
+ if (isTableSeparatorLine(t)) return
1763
+ const cells = t.replace(/^\||\|$/g, '').split('|').map(c => c.trim())
1764
+ if (isTableSeparatorLine(lines[i + 1]?.trim() ?? '')) { col = cells.findIndex(c => /question|問/i.test(c)); return } // the header
1765
+ const at = col >= 0 ? col : cells.findIndex(c => !/^#?[A-Za-z]{0,4}-?\d*$/.test(c)), text = cells[at] ?? ''
1766
+ // the status is the last cell — unless that IS the question (`| # | Question |`): a question starting "Closed …" is no status
1767
+ const status = at < cells.length - 1 ? cells.at(-1).replace(/\*\*/g, '').trim() : ''
1768
+ if (text && !/^~~.*~~$/s.test(text) && !BOLD_ANSWER.test(text) && !/^(?:resolved|answered|closed|已答|已解決|✅)/i.test(status)) out.push(cut(text))
1769
+ return
1770
+ }
1771
+ const item = line.match(/^ {0,3}(?:[-*+]|\d+[.)])\s+(.+)$/s)?.[1] // `s`: a U+2028 or U+2029 inside is no end of the item
1772
+ if (item && !/^(?:\[[xX]\]|(?:\[ \]\s*)?~~)|—\s*\**resolved/i.test(item) && !BOLD_ANSWER.test(item))
1773
+ out.push(cut(item.replace(/^\[ \]\s*/, '').trim()))
1774
+ })
1775
+ return out
1776
+ }
1777
+
1778
+ // where a stream's files are read from: a ref on origin, or a checkout's working tree.
1779
+ // `where` is what a person is told; read → text or null, list → the names in a dir
1780
+ const refSource = (git, ref) => ({
1781
+ where: ref.replace(/^refs\/remotes\//, ''),
1782
+ read: path => { const r = git(['show', `${ref}:${path}`]); return ok(r) ? r.stdout : null },
1783
+ list: dir => {
1784
+ const r = git(['ls-tree', '-z', '--name-only', ref, `${dir}/`])
1785
+ return ok(r) ? r.stdout.split('\0').filter(Boolean).map(f => f.split('/').pop()) : []
1786
+ },
1787
+ })
1788
+ const treeSource = dir => ({
1789
+ where: dir,
1790
+ read: path => { try { return readFileSync(join(dir, path), 'utf8') } catch { return null } },
1791
+ list: d => { try { return readdirSync(join(dir, d)) } catch { return [] } },
1792
+ })
1793
+
1794
+ // repo → { all, note }: every stream collectRepo found, in its order, and the "none listed" note it gave a repo that lists none. The git window decides the
1795
+ // git half only (SPEC): ghPlace lists again a merged stream it left out, when one of that stream's PRs has a line to put there, and takes the note back
1796
+ const allStreams = new WeakMap()
1797
+
1798
+ // Which app holds a phase in progress, and its session (phase 21, the user: "首選:能夠跳到對應 app 的 session;若不能,列出是哪個 App 的 session name"). Three reads, each giving what its
1799
+ // app documents and no more. A cloud session has a URL that opens it, `https://claude.ai/code/session_<id>` (the only one documented), and wuwei's commits carry it as a `Claude-Session:` trailer. A
1800
+ // local Claude Code session has no link, but `claude --resume <id>` opens it from any directory, and its transcript's FILE NAME is the id. Orca has neither, so its own CLI's name for the worktree
1801
+ // is what there is. What these read is other people's, and the user's own, and none of it is printed as it was found: a URL is rebuilt from an id that was checked, a command from a uuid that was
1802
+ // checked, a name goes through plain() with the rest of the report (T-2). Never a transcript's content — the docs call its format internal — nor a terminal's `preview` (its screen: ssh
1803
+ // identities, emails, commands) or `title`
1804
+ // The newest commit of the phase branch's own — what the stream's base lacks, or the base's plan commit and every earlier phase's would answer for it — that has a `Claude-Session` trailer, by git's own
1805
+ // reading of one (the last paragraph, `key: value`), through untrustedGit (T-3). Its last value must be exactly CLAUDE_SESSION — `https://claude.ai/code/session_<letters and digits>`, the one shape a link to claude.ai
1806
+ // may have, here and on the page — and is then the URL (the whole value matched, so nothing of it is left over); any other value — another host or scheme, a path past the id, a query, no id, markup — is no link, not the next-newest trailer's
1807
+ const cloudSession = (git, branch, base) => {
1808
+ const log = git(['log', '-n', '200', '--format=%x01%(trailers:key=Claude-Session,valueonly)', `refs/remotes/origin/${branch}`, `^refs/remotes/origin/${base}`])
1809
+ const last = (log.stdout ?? '').split('\x01').slice(1).map(c => c.split('\n').map(l => l.trim()).filter(Boolean).at(-1)).find(Boolean)
1810
+ return last && CLAUDE_SESSION.test(last) ? { url: last } : null
1811
+ }
1812
+ // Claude Code keeps a session's transcript at <config dir>/projects/<the working directory's absolute path, every character but A–Z a–z 0–9 a '-'>/<session id>.jsonl — `CLAUDE_CONFIG_DIR` moves the
1813
+ // config dir off ~/.claude (claudeProjectDirs, which review-collect reads the same way). A name over 200 characters Claude Code cuts and ends with a hash of the path: brief does not guess one, so a worktree
1814
+ // whose name is that long shows nothing. A transcript is a plain file whose name is a uuid (a link is not followed); the one that gives `claude --resume <uuid>` is, in this order (phase 24: the worktree can be the repo's
1815
+ // own checkout, where the newest transcript is some other phase's): (1) the newest of those the phase's `claimed-by` names, by the uuid's start; (2) else the newest whose life spans the phase's `claimed-at` — born no later
1816
+ // (statSync().birthtimeMs; 0 or none, and the file is not a candidate, so the step is skipped on a file system that keeps none), written no earlier; (3) else the newest of all, by modification time. Only the directory's names
1817
+ // and those stats are read, never a transcript's content
1818
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
1819
+ // The id a `claimed-by` names (Claude Code writes `claude-opus-5-5 (session 4fd9637a)`, and `wuwei claim` from inside it): the word after the FIRST `session` — 4 to 36 hex characters and hyphens, any case, with no other
1820
+ // word character or hyphen in the word, so `4fd9637azz` and a run past a uuid's length are none. Only that first `session` is read: when its id is malformed the claim names none, and the id of a later `session` is not
1821
+ // tried (a pattern that looked for the first well-formed one would skip `4fd9637azz` and take the next). It is the repo's text (T-2), so it only ever SELECTS among the file names below: what is printed is that file's own
1822
+ // uuid, never these characters
1823
+ const claimedSession = claim => {
1824
+ const id = /\bsession\s+([\w-]*)/i.exec(claim ?? '')?.[1]
1825
+ return id && /^[0-9a-f-]{4,36}$/i.test(id) ? id.toLowerCase() : undefined
1826
+ }
1827
+ const claudeCodeSession = (worktree, claim, claimedAt) => {
1828
+ // every spelling that names a directory that exists is read (claudeProjectDirs); of the transcripts there, the newest wins — of those the claim names where it names any, else of those that span it
1829
+ const found = [] // [uuid, mtimeMs, birthtimeMs]
1830
+ for (const dir of claudeProjectDirs(worktree)) {
1831
+ if (basename(dir).length > 200) continue
1832
+ let names
1833
+ try { names = readdirSync(dir, { withFileTypes: true }).filter(e => e.isFile() && e.name.endsWith('.jsonl') && UUID.test(e.name.slice(0, -6))).map(e => e.name.slice(0, -6)) } catch { continue }
1834
+ for (const id of names) try { const st = statSync(join(dir, `${id}.jsonl`)); found.push([id, st.mtimeMs, st.birthtimeMs]) } catch {}
1835
+ }
1836
+ // the claimed session: every transcript whose uuid starts with the id — a short prefix may match several — and the newest of THOSE (a newer one the claim does not name is likelier another phase's). The claim names none
1837
+ // (no `session`, a malformed id, or one no file starts with): the transcripts whose life spans the claim, born no later than claimed-at and last written no earlier than it, the newest of those — the session that was
1838
+ // running when the phase was claimed. None: the newest of all
1839
+ const want = claimedSession(claim), named = want ? found.filter(f => f[0].toLowerCase().startsWith(want)) : []
1840
+ const at = Date.parse(claimedAt ?? ''), spans = Number.isFinite(at) ? found.filter(f => f[2] > 0 && f[2] <= at && at <= f[1]) : []
1841
+ const newest = list => [...list].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? 1 : -1))[0]?.[0]
1842
+ const pick = newest(named.length ? named : spans.length ? spans : found)
1843
+ return pick ? { resume: `claude --resume ${pick}` } : null
1844
+ }
1845
+ // Orca's own CLI — the only place its names for a worktree are. `orca worktree list --json` → { ok, result: { worktrees: [{ path, displayName, … }] } } and `orca terminal list --json` →
1846
+ // { ok, result: { terminals: [{ worktreePath, agentIdentity, … }] } } (shapes the user's Orca printed on 2026-10-20), asked once for the whole run — the first time a worktree in progress could be Orca's,
1847
+ // not once per worktree or per repo — with no shell, three seconds, NO_ENV_TOKEN's environment, from the home directory (ghCwd: brief may be started inside a checkout) and stdin and stderr thrown away. Not there, failing, slow, no JSON or `ok` not true: no names, and
1848
+ // nothing said. Only those four fields are taken, the rest dropped unread; the terminals are asked only when the worktrees answered. The three seconds are a bound, not a hope (round 1): spawnSync's timeout sends SIGTERM to orca alone and waits for
1849
+ // it to die, and a wrapper can ignore that, or leave a child holding the pipe — so orca runs in a process group of its own, is sent SIGKILL at the bound, and the group is killed whole after it (not on Windows, which has no such group)
1850
+ let orcaAsked
1851
+ const orcaNames = () => {
1852
+ if (orcaAsked) return orcaAsked
1853
+ const names = new Map(), agents = new Map()
1854
+ const ask = what => {
1855
+ const group = process.platform !== 'win32'
1856
+ const r = quiet('orca', [what, 'list', '--json'], { shell: false, detached: group, killSignal: 'SIGKILL', timeout: 3000, cwd: ghCwd(), env: NO_ENV_TOKEN, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 })
1857
+ if (group && r.error?.code === 'ETIMEDOUT') try { process.kill(-r.pid, 'SIGKILL') } catch {}
1858
+ try { const j = ok(r) ? JSON.parse(r.stdout) : null; return j?.ok === true && Array.isArray(j.result?.[`${what}s`]) ? j.result[`${what}s`] : null } catch { return null }
1859
+ }
1860
+ const trees = ask('worktree')
1861
+ for (const w of trees ?? []) if (typeof w?.path === 'string' && w.path) names.set(realOr(w.path), typeof w.displayName === 'string' && w.displayName.trim() ? w.displayName.trim() : null)
1862
+ if (trees) for (const t of ask('terminal') ?? []) {
1863
+ const k = typeof t?.worktreePath === 'string' && t.worktreePath ? realOr(t.worktreePath) : null
1864
+ if (k && !agents.has(k) && typeof t.agentIdentity === 'string' && t.agentIdentity.trim()) agents.set(k, t.agentIdentity.trim())
1865
+ }
1866
+ return (orcaAsked = { names, agents })
1867
+ }
1868
+ // A worktree is Orca's when its realpath is one Orca lists — its name that displayName (what Orca's sidebar shows), the agent in it the first terminal's agentIdentity — or, when orca is silent or does not list it, one under
1869
+ // ~/orca/workspaces, Orca's own place for them, named by its folder. Orca opens no worktree by a link (`orca://` is skill sharing only), so nothing here is one
1870
+ const orcaSession = worktree => {
1871
+ const key = realOr(worktree), { names, agents } = orcaNames()
1872
+ if (!names.has(key) && !key.startsWith(realOr(join(homedir(), 'orca', 'workspaces')) + sep)) return null
1873
+ return { name: names.get(key) ?? basename(key), ...agents.has(key) ? { agent: agents.get(key) } : {} }
1874
+ }
1875
+ // the app that holds a worktree, [app, session] — Orca where it holds it (the app the user works in; the agent in it is named), else Claude Code's resume command, else none. Orca's row has the command too (phase 24:
1876
+ // Orca beat Claude Code, and its rows had no way back into the session): the Claude Code session in the same worktree, chosen by the phase's claim (the id it names, else the session running at its claimed-at) — but only
1877
+ // where the agent in Orca's terminal is Claude Code's, or none is named: a worktree where Orca says codex runs is not a Claude Code one, and the transcripts under its path are not the session in use (round 1)
1878
+ const localApp = (worktree, claim, claimedAt) => {
1879
+ const orca = orcaSession(worktree), code = claudeCodeSession(worktree, claim, claimedAt)
1880
+ return orca ? ['orca', { ...orca, ...!orca.agent || orca.agent === 'claude' ? code : null }] : code ? ['claude-code', code] : null
1881
+ }
1882
+
1883
+ // `fetched` is fetchAll's answer for this group; opts.staleMs is `--stale`, opts.now the
1884
+ // clock the ages are read against, opts.window this repo's { kind, start }. Returns the per-repo object of SPEC's `## Output
1885
+ // contract`, less what phase 03 adds; `notes` is an addition — what a reader should
1886
+ // know about HOW this repo was read (a failed fetch, no default branch, no origin)
1887
+ function collectRepo(group, fetched, { staleMs = 72 * 3600e3, now = Date.now(), window } = {}) {
1888
+ const dest = group.checkouts[0]
1889
+ const git = args => untrustedGit(dest, args)
1890
+ const hasRef = ref => ok(git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]))
1891
+ const notes = []
1892
+ const start = Date.parse(window.start)
1893
+ if (group.origin == null) notes.push(group.why ?? 'no origin remote, so read from the local checkout only: not fetched, and no PRs read')
1894
+ else if (!fetched.ok) notes.push(`fetch failed${fetched.needsCredentials ? ' (needs credentials)' : ''}: ${fetched.error}`)
1895
+ // gitFetch adds no wuwei/* entry to a shallow clone, which a fetch of every head would widen: what its own entries
1896
+ // bring is its refspec's business (`--no-single-branch` tracks every head, `--single-branch` one), so a stream branch MAY be missing
1897
+ if (group.origin != null && isShallow(dest)) notes.push('shallow clone — only the branches its own refspec tracks are fetched, so a stream branch (wuwei/*) may be missing')
1898
+
1899
+ // the default branch: origin/HEAD, else origin/main, else origin/master
1900
+ const head = git(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
1901
+ const defRef = [ok(head) ? `refs/remotes/${head.stdout.trim()}` : null, 'refs/remotes/origin/main', 'refs/remotes/origin/master']
1902
+ .filter(Boolean).find(hasRef)
1903
+ if (!defRef && group.origin != null)
1904
+ notes.push('no origin/HEAD, origin/main or origin/master — the streams on the default branch were not read')
1905
+
1906
+ const streamsOf = src => src.list('.wuwei/streams').flatMap(name => {
1907
+ const spec = src.read(`.wuwei/streams/${name}/SPEC.md`)
1908
+ if (spec == null) return []
1909
+ return [{ name, spec, src, signed: isSignedOff(spec), phases: src.list(`.wuwei/streams/${name}/phases`).filter(f => f.endsWith('.md')) }]
1910
+ })
1911
+ // merged (R2): signed off, with a phase file whose FRONTMATTER has a claim, read at the branch
1912
+ // the stream integrates into — the one its own SPEC records, else the default branch.
1913
+ // cmdClaim writes `claimed-by` only on a phase branch, so it reaches that branch only through
1914
+ // the stream's merge — never through a plan PR. The word in prose is no claim, and a base that
1915
+ // is an ancestor of the integration branch is no proof: a freshly cut one is its tip.
1916
+ // A branch the clone does not have (a --single-branch clone never fetches it; origin may have deleted it)
1917
+ // reads as the default branch, which says "not merged" for a stream that is: `notes` says so
1918
+ const noInteg = new Set()
1919
+ const integRef = spec => {
1920
+ const b = signOffField(spec ?? '', 'integration branch')
1921
+ if (!b) return defRef
1922
+ if (hasRef(`refs/remotes/origin/${b}`)) return `refs/remotes/origin/${b}`
1923
+ if (defRef) noInteg.add(b)
1924
+ return defRef
1925
+ }
1926
+ const at = new Map() // ref → the streams on it, each flagged merged; read once
1927
+ const streamsAt = ref => {
1928
+ if (!at.has(ref)) at.set(ref, streamsOf(refSource(git, ref)).map(d =>
1929
+ ({ ...d, ref, merged: d.signed && d.phases.some(f => parseFrontmatter(d.src.read(`.wuwei/streams/${d.name}/phases/${f}`) ?? '').data['claimed-by']) })))
1930
+ return at.get(ref)
1931
+ }
1932
+ const mergedStream = (name, spec) => { const ref = integRef(spec); return ref ? streamsAt(ref).find(d => d.name === name && d.merged) : undefined }
1933
+
1934
+ // WHEN a merged stream landed (R5, R2), a time in epoch seconds from the commit that did it; all of it on the
1935
+ // first-parent line of the branch it was merged INTO, which is where a merge commit, a squash and a fast-forward
1936
+ // each leave a commit of theirs (or, for the fast-forward, only the tip)
1937
+ // `tip`, an ancestor of `ref`, reached it: when `tip` is itself on ref's first-parent line it was fast-forwarded in,
1938
+ // there is no commit of the landing's own, and its time is the floor. Otherwise the OLDEST first-parent commit with
1939
+ // the tip behind it: the merge commit. The query alone dates a fast-forward to whatever came next on `ref`
1940
+ const landedAt = (tip, ref) => {
1941
+ const [sha, ct] = (git(['log', '-1', '--format=%H %ct', tip]).stdout ?? '').trim().split(' ')
1942
+ const [when, parent] = (git(['log', '--first-parent', '--ancestry-path', '--format=%ct %P', `${tip}..${ref}`]).stdout ?? '').trim().split('\n').at(-1).split(' ')
1943
+ return Number(!when || parent === sha ? ct : when) || null
1944
+ }
1945
+ // R2's walk, for a stream with no base to date by (a squash, a rebase, a base deleted): the newest first-parent
1946
+ // commit on `ref` after which more of its phase files carry the frontmatter key than before. -G with a `^`, not -S:
1947
+ // prose that names the key is no claim; -m, as claimPhases has it (kept by the user, phase 01 round 3), though git 2.29
1948
+ // and later imply it with --first-parent (RelNotes 2.29.0; brief's floor) — what it still decides is which diff of a
1949
+ // merge, log.diffMerges, which UNTRUSTED_GIT pins. A take-over replaces a value and a deletion lowers the count, so
1950
+ // neither rises; a root commit has no `c^` to compare with. `-m` lists a merge once for each parent its diff matches
1951
+ // (one, first-parent, with the pin). `--no-textconv --text`, here and in phaseDate, for the reasons `reworked` in
1952
+ // claimPhases gives: the checkout's own textconv driver, and its attributes marking the file `-diff` or binary
1953
+ const claimedIn = (c, dir) => (git(['ls-tree', '-z', '--name-only', c, `${dir}/`]).stdout ?? '').split('\0')
1954
+ .filter(f => f.endsWith('.md') && parseFrontmatter(git(['show', `${c}:${f}`]).stdout ?? '').data['claimed-by']).length
1955
+ const walkedAt = (ref, name) => {
1956
+ const dir = `.wuwei/streams/${name}/phases`, seen = new Set()
1957
+ for (const line of (git(['log', '--first-parent', '-m', '--no-textconv', '--text', '-G^claimed-by:', '--format=%H %ct %P', ref, '--', dir]).stdout ?? '').split('\n')) {
1958
+ const [c, ct, parent] = line.split(' ')
1959
+ if (!parent || seen.has(c)) continue
1960
+ seen.add(c)
1961
+ if (claimedIn(c, dir) > claimedIn(parent, dir)) return Number(ct)
1962
+ }
1963
+ return null
1964
+ }
1965
+ // when a phase that claimPhases read merged reached the stream branch, by the trace that read it (`how`): the phase
1966
+ // branch's own tip, fast-forwarded in or brought in by a merge commit; a squash that kept the branch has only the
1967
+ // claim to go by, and the NEWEST first-parent commit that changed a `claimed-by:` line (as `reworked` in claimPhases
1968
+ // has it), so a phase squashed, reverted and squashed again dates from the second; a merge commit that names a
1969
+ // deleted branch is the commit itself — `--grep` is a substring match, as in claimPhases (`01-api` finds a merge of
1970
+ // `01-api-spike`), so the newest of the two is the date, the same limit the verdict has. Fully qualified refs: a
1971
+ // checkout's own `origin/x` branch must not stand in
1972
+ const phaseDate = (p, remote) => {
1973
+ const newest = args => Number(git(['log', '-1', '--format=%ct', ...args]).stdout?.trim())
1974
+ const t = {
1975
+ 'branch inside the stream branch': () => landedAt(`refs/remotes/origin/${p.branch}`, remote),
1976
+ 'its claim reached the stream branch': () => newest(['--first-parent', '-m', '--no-textconv', '--text', '-G^claimed-by:', remote, '--', p.file]),
1977
+ 'a merge commit names it': () => newest(['--merges', '--fixed-strings', `--grep=${p.branch}`, remote]),
1978
+ }[p.how]?.()
1979
+ return t ? isoOf(t) : undefined
1980
+ }
1981
+ // `base` merged into `ref` by a merge commit or a fast-forward: that commit. A base that is an ancestor of `ref`
1982
+ // says HOW the stream landed, not whether (the claims on `ref` say that: a freshly cut base is main's tip) — and only
1983
+ // when the base held the claims. One cut from the plan and never pushed to is an ancestor of everything after it,
1984
+ // with the phases merged straight into `ref`: landedAt would date that at the next commit on `ref`, or at the plan.
1985
+ // Then R2's walk, which looks at the claims
1986
+ const streamDate = (ref, name, base) => {
1987
+ const t = base && git(['merge-base', '--is-ancestor', base, ref]).status === 0 && claimedIn(base, `.wuwei/streams/${name}/phases`) > 0 ? landedAt(base, ref) : walkedAt(ref, name)
1988
+ return t ? { mergedAt: isoOf(t) } : {}
1989
+ }
1990
+ const onDefault = defRef ? streamsAt(defRef) : []
1991
+ const trees = group.checkouts.flatMap(c => streamsOf(treeSource(c)))
1992
+
1993
+ const entry = (name, signedOff, total, more) => ({ name, signedOff, merged: false, total, today: [], inProgress: [], inProgressAt: [], left: [], done: [], waiting: [], ...more })
1994
+ // where a phase in progress is checked out (phase 15, the user: "要顯示是哪裡的 worktree,Ex: Orca"): `git worktree list` in each checkout — a clone lists
1995
+ // every worktree of its own wherever it lives (Orca's, one under a dot-dir the walk skips), each with the branch it has checked out. Read once, the first
1996
+ // time a phase in progress asks; the first checkout's answer wins a branch two clones both have out; a worktree whose directory is gone (git: prunable) is
1997
+ // none. A read, through untrustedGit (T-3). `-z` (git ≥ 2.36; an older git refuses it, and no worktree is named): a field ends at a NUL and an entry at
1998
+ // two, so a path that holds a line feed is read whole — without it, the path's first line is read, which may be a directory of its own (round 1)
1999
+ let worktrees
2000
+ const worktreeOf = branch => (worktrees ??= new Map(group.checkouts.flatMap(c => (untrustedGit(c, ['worktree', 'list', '--porcelain', '-z']).stdout ?? '').split('\0\0').flatMap(e => {
2001
+ const f = e.split('\0'), path = f.find(x => x.startsWith('worktree '))?.slice(9), b = f.find(x => x.startsWith('branch refs/heads/'))?.slice(18)
2002
+ return path && b && existsSync(path) ? [[b, path]] : []
2003
+ })).reverse())).get(branch)
2004
+ const out = new Map()
2005
+
2006
+ // open: a base on origin. The stream's own files are read there, not on the default branch
2007
+ // (a plan merged before sign-off leaves main's SPEC unsigned) — and `claimPhases` is the one
2008
+ // place `claim` and `brief` decide what is ready
2009
+ const bases = (git(['for-each-ref', '--format=%(refname)', 'refs/remotes/origin/wuwei/']).stdout ?? '').split('\n')
2010
+ .flatMap(ref => { const m = ref.match(/^refs\/remotes\/origin\/wuwei\/(.+)\/base$/); return m ? [[ref, m[1]]] : [] })
2011
+ .map(([ref, name]) => { const src = refSource(git, ref); return { name, ref, src, spec: src.read(`.wuwei/streams/${name}/SPEC.md`) } })
2012
+ for (const { name, ref, src, spec } of bases) {
2013
+ const branch = `wuwei/${name}/base`
2014
+ const merged = mergedStream(name, spec ?? onDefault.find(x => x.name === name)?.spec)
2015
+ if (merged) { out.set(name, entry(name, true, merged.phases.length, { branch, merged: true, ...streamDate(merged.ref, name, ref) })); continue } // a squash, the base kept
2016
+ if (spec == null) { out.set(name, entry(name, false, 0, { branch, error: `${src.where} has no .wuwei/streams/${name}/SPEC.md` })); continue }
2017
+ const r = claimPhases(dest, name, { fetch: false, spec })
2018
+ if (r.error) { out.set(name, entry(name, isSignedOff(spec), 0, { branch, error: r.error })); continue }
2019
+ // done: the merged phases that landed inside the window, in phase order. Every merged phase is dated, since the date
2020
+ // is what says it is outside; one that cannot be dated is in or out of nothing, so it is left out and a note names it.
2021
+ // A stream merged as a whole lists none — it is itself the one thing that got done (mergedAt)
2022
+ const done = r.phases.flatMap(p => {
2023
+ const at = p.merged && phaseDate(p, `refs/remotes/${r.remote}`)
2024
+ if (p.merged && !at) notes.push(`stream ${name}: phase ${p.name} is merged, but when could not be read — left out of done`)
2025
+ return at && Date.parse(at) >= start ? [{ phase: p.name, at }] : []
2026
+ })
2027
+ const left = r.phases.filter(p => !p.merged)
2028
+ const waiting = []
2029
+ if (r.phases.length && !left.length)
2030
+ waiting.push(/^## Manual UAT\b/m.test(src.read(`.wuwei/streams/${name}/ACCEPTANCE.md`) ?? '')
2031
+ ? { kind: 'uat', detail: 'every phase merged — ACCEPTANCE has a Manual UAT to run' }
2032
+ // `audit`: the stream audit's digest is on the base (phase 11, audit:contract L4) — with the stream PR open, what is left is that audit
2033
+ : { kind: 'stream-ready', detail: 'every phase merged — the stream PR and its audit are owed', audit: src.read(`.wuwei/streams/${name}/AUDIT.md`) != null })
2034
+ const row = stateRow(readStateRows({ text: src.read('.wuwei/STATE.md') ?? '' }), name)
2035
+ // a cell that says nothing blocks: empty, `-`, `—`, `none`, or a dash then a note (`—(PR #282 merged)`,
2036
+ // `- (see #12)`). A dash glued to a word (`-wip`, `—foo`) stays a block: it may be the blocker's own name
2037
+ if (row && !/^(?:-|—|none)?$|^[-—][\s((]/i.test(row.blocked)) waiting.push({ kind: 'blocked', detail: row.blocked })
2038
+ // in progress (phase 08, U1: a phase claimed since the last run was nowhere): claimed, or with a live phase branch, and not
2039
+ // merged — whatever its dependencies, which claimPhases' state tests first (there a claimed phase can read `waiting on …`).
2040
+ // A claim nobody has touched for --stale (R1) is one of these: not ready, and a human decides whether it is abandoned. Its
2041
+ // age is the phase branch's last commit, else `claimed-at`; an age that cannot be read is not a stale one. Merged phases
2042
+ // are not in `left`
2043
+ const inProgress = left.filter(x => x.claimedBy || x.onRemote)
2044
+ for (const p of inProgress) {
2045
+ const ct = p.onRemote ? git(['log', '-1', '--format=%ct', `refs/remotes/origin/${p.branch}`]).stdout?.trim() : ''
2046
+ const since = ct ? Number(ct) * 1000 : Date.parse(p.claimedAt ?? '')
2047
+ if (!(now - since > staleMs)) continue
2048
+ const age = fmtAge(now - since)
2049
+ waiting.push({ kind: 'stale-claim', phase: p.name, age, detail: `claimed by ${p.claimedBy ?? '?'}, ${ct ? `no commit for ${age}` : `claimed ${age} ago`}` })
2050
+ }
2051
+ for (const q of openQuestions(spec)) waiting.push({ kind: 'open-question', detail: q })
2052
+ out.set(name, entry(name, true, r.phases.length, {
2053
+ branch: r.streamBranch,
2054
+ today: r.phases.filter(p => p.state === 'ready').map(p => p.name),
2055
+ inProgress: inProgress.map(p => p.name),
2056
+ // …and the app that holds it and its session (phase 21): a local worktree first — Orca's names, then Claude Code's resume command — and only where no local app is found, a cloud session, the phase branch's own newest
2057
+ // trailer; else none (`app` and `session` are absent). One app a row: Orca, where it holds the worktree, is the app the user works in. The worktree wins (round 1): a phase started in the cloud and continued here keeps its
2058
+ // old trailer, and where it runs now is the worktree. The trailer is read only when no local app was found
2059
+ inProgressAt: inProgress.map(p => {
2060
+ const wt = worktreeOf(p.branch)
2061
+ const local = wt ? localApp(wt, p.claimedBy, p.claimedAt) : null
2062
+ const cloud = local || !p.onRemote ? null : cloudSession(git, p.branch, r.streamBranch)
2063
+ const found = local ?? (cloud ? ['claude-cloud', cloud] : null)
2064
+ return { phase: p.name, ...(wt ? { worktree: wt } : {}), ...(p.claimedBy ? { claimedBy: p.claimedBy } : {}), ...(p.claimedAt ? { claimedAt: p.claimedAt } : {}), ...found ? { app: found[0], session: found[1] } : {} }
2065
+ }),
2066
+ left: left.map(p => ({ phase: p.name, review: p.review })),
2067
+ done,
2068
+ waiting,
2069
+ }))
2070
+ }
2071
+
2072
+ // A SPEC signed before v0.1.0 names a stream branch with no /base (U1's re-run): on origin, it is said so — not read, as `wuwei claim` does not read it (the
2073
+ // user's option A). The branch is the Sign-off line LABELLED stream branch (phase 10, T4: ziweiai's pwa-redesign names `wuwei/pwa-redesign-stream`, not
2074
+ // `wuwei/<stream>`), so a dated note that only mentions the words, and `develop`, is not it. Any `wuwei/` name but a `/base`, which is a stream of today's,
2075
+ // perhaps another one's. It is the repo's text: only a plain ref name reaches git, and as a full ref (T-2) — no `..`, `~ ^ : @{`, nothing git reads as a
2076
+ // revision. Never "merged" because that branch is inside the integration branch: pwa-redesign's reached develop mid-stream, with phase 14 still running
2077
+ const legacy = s => {
2078
+ const b = signOffValue(s.spec, 'stream branch')
2079
+ return b && /^wuwei(\/[\w.-]+)+$/.test(b) && !b.includes('..') && !b.endsWith('/base') && hasRef(`refs/remotes/origin/${b}`) ? b : null
2080
+ }
2081
+ // merged, its base gone: its integration branch holds the stream's claims. Every branch some
2082
+ // SPEC seen here (an open base, the default branch, a working tree) names is listed, so a stream
2083
+ // integrated into `develop` is found by the SPEC of any stream that says `develop`. And only
2084
+ // there: claims on main for a SPEC that says `develop` are no merge into develop, where the
2085
+ // stream PR is still owed — the same "merged" an open base is read by (mergedStream), which reads
2086
+ // such a stream as stream-ready. With its base gone it falls through to a plan on the default branch.
2087
+ // Nor is a stream named before v0.1.0 merged by its claims while that branch is still on origin (`legacy`): its phases are claimed on it, so
2088
+ // claims on the integration branch are that branch reaching it mid-stream (phase 11, audit:composition M1). It is left to the 🔧 loop below,
2089
+ // which sees it too when its SPEC is only on the integration branch (`held`). With the branch gone from origin, its claims say merged, as before
2090
+ const refs = new Set([defRef, ...[...bases.map(b => b.spec), ...onDefault.map(d => d.spec), ...trees.map(t => t.spec)].map(integRef)].filter(Boolean))
2091
+ const held = []
2092
+ for (const ref of refs)
2093
+ for (const d of streamsAt(ref)) {
2094
+ if (!d.merged || integRef(d.spec) !== ref || out.has(d.name)) continue
2095
+ if (legacy(d)) held.push(d)
2096
+ else out.set(d.name, entry(d.name, true, d.phases.length, { merged: true, ...streamDate(ref, d.name) }))
2097
+ }
2098
+
2099
+ // what is left was never opened: signed off with no base on origin, or not signed off — on
2100
+ // the default branch or in any checkout's working tree (a worktree holds a plan of its own)
2101
+ // A 🔧 stream's progress, all brief can say of it: the done cell of its STATE.md row (stateRow) — the stream branch's own, where execute updated it,
2102
+ // else the one beside its SPEC
2103
+ const stateDone = (s, b) => [refSource(git, `refs/remotes/origin/${b}`), s.src]
2104
+ .map(src => stateRow(readStateRows({ text: src.read('.wuwei/STATE.md') ?? '' }), s.name)?.done).find(Boolean)
2105
+ const loose = [...onDefault, ...trees, ...held]
2106
+ for (const signed of [true, false])
2107
+ for (const s of loose) {
2108
+ if (s.signed !== signed || out.has(s.name)) continue
2109
+ const old = signed && legacy(s), state = old && stateDone(s, old)
2110
+ out.set(s.name, entry(s.name, signed, s.phases.length, {
2111
+ waiting: [
2112
+ old ? { kind: 'legacy-branch', detail: old, ...(state ? { stateDone: state } : {}) }
2113
+ : signed ? { kind: 'no-stream-branch', detail: `signed off — stream branch not on origin (${s.src.where})` }
2114
+ : { kind: 'sign-off', detail: `sign-off not given (${s.src.where})` },
2115
+ ...openQuestions(s.spec).map(q => ({ kind: 'open-question', detail: q })),
2116
+ ],
2117
+ }))
2118
+ }
2119
+
2120
+ for (const b of noInteg) notes.push(`origin/${b} not fetched in this clone (or gone from origin) — merged state read at ${defRef.replace(/^refs\/remotes\//, '')}`)
2121
+ // a merged stream is listed when it merged in the window (SPEC goal 4, E5), or when the time it merged cannot be read: then it
2122
+ // stays, undated, and a note says so. It was put in `out` whatever the window, so the buckets above claimed its name: a
2123
+ // stream merged before the window must not come back as unsigned or without a stream branch (R2). A repo left with no
2124
+ // stream by that says why, or its section would read as one with no streams
2125
+ const all = [...out.values()]
2126
+ const streams = all.filter(s => !s.merged || !s.mergedAt || Date.parse(s.mergedAt) >= start)
2127
+ for (const s of streams) if (s.merged && !s.mergedAt) notes.push(`stream ${s.name}: merged, but when could not be read — listed whatever the window`)
2128
+ const none = out.size && !streams.length ? `${out.size} stream${out.size === 1 ? '' : 's'} merged before the window — none listed` : null
2129
+ if (none) notes.push(none) // renderMarkdown reads it back
2130
+ const repo = {
2131
+ origin: group.origin, checkouts: group.checkouts,
2132
+ stale: group.origin != null && !fetched.ok,
2133
+ ...(fetched.fetchedAt ? { fetchedAt: fetched.fetchedAt } : {}),
2134
+ window,
2135
+ streams,
2136
+ ...(notes.length ? { notes } : {}),
2137
+ }
2138
+ allStreams.set(repo, { all, note: none })
2139
+ return repo
2140
+ }
2141
+
2142
+ // `--stale 72h`: <N> then s, m, h or d — the units fmtAge prints, so an age reads back as a flag
2143
+ const DURATIONS = { s: 1e3, m: 60e3, h: 3600e3, d: 86400e3 }
2144
+ const parseDuration = t => { const m = String(t ?? '').match(/^(\d+)([smhd])$/); return m ? Number(m[1]) * DURATIONS[m[2]] : null }
2145
+
2146
+ // `--since 24h | 7d | 2026-10-14`: a duration back from the run, or a date's midnight UTC. Returns the start as a
2147
+ // function of the run's clock (the duration needs it, and it is only known after the fetches), or null when the
2148
+ // value is neither — a date must be a real one: Date.UTC rolls 2026-13-45 over, so the round trip is the check; and a
2149
+ // duration must reach a date there is (`99999999999d` goes back past the first one: Invalid Date, which isoOf throws
2150
+ // on after the fetches), checked against the clock now, which the run's own, seconds later, cannot move across
2151
+ const sinceStart = v => {
2152
+ const ms = parseDuration(v)
2153
+ if (ms != null) return Number.isFinite(new Date(Date.now() - ms).getTime()) ? now => now - ms : null
2154
+ const m = String(v ?? '').match(/^(\d{4})-(\d\d)-(\d\d)$/)
2155
+ const t = m ? Date.UTC(m[1], m[2] - 1, m[3]) : NaN
2156
+ return Number.isFinite(t) && new Date(t).toISOString().startsWith(v) ? () => t : null
2157
+ }
2158
+
2159
+ // the columns a line takes in a terminal, for the box below: East Asian Wide and Fullwidth characters (Han, kana, Hangul,
2160
+ // fullwidth forms, most emoji — the ranges are close to Unicode's table, not it) take two, a combining mark none, and
2161
+ // so do the vowel and final jamo of Hangul (U+1160–11FF, U+D7B0–D7FF: the tail of a syllable in NFD, whose leading jamo
2162
+ // is a wide one). A checkout path in CJK, or an NFD one (macOS), would put the right border out of line if the padding
2163
+ // counted characters. Every emoji shown as one (`\p{Emoji_Presentation}`: the markdown's symbols, 🚀 ✅ ⛔ ⏰ ❓ outside those ranges among them) takes two; an
2164
+ // OSC 8 hyperlink's sequences (the markdown's links on a terminal) take none. Three emoji are more than one code point, and a repo's text (a stream's name) can
2165
+ // hold them: a flag is two regional indicators, one column each, two together. U+FE0F, and a skin tone (U+1F3FB–1F3FF) after an emoji it can modify, join the
2166
+ // emoji before them: one that took one column (❤, ☝, ✌) is widened to two, the joiner taking the one more; after a two-column one (👍), or after ✌️, already two,
2167
+ // the joiner takes none — and U+FE0F after what is no emoji at all (a letter) asks for nothing. `charCols` is one code point's, given the emoji it would join
2168
+ // and the columns that one has taken so far; `columns` carries both, a joiner leaving them on the emoji it joined
2169
+ const WIDE = /[\u1100-\u115f\u2e80-\u303e\u3041-\u9fff\ua000-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe6f\uff01-\uff60\uffe0-\uffe6\u{1f300}-\u{1f64f}\u{1f900}-\u{1f9ff}\u{20000}-\u{3fffd}]|\p{Emoji_Presentation}/u
2170
+ const OSC8 = /\x1b\]8;;[^\x1b]*\x1b\\/g
2171
+ const joins = (c, base) => c === '\ufe0f' || /\p{Emoji_Modifier}/u.test(c) && /\p{Emoji_Modifier_Base}/u.test(base)
2172
+ const charCols = (c, base, cols) => /\p{Regional_Indicator}/u.test(c) ? 1 : joins(c, base) ? (cols === 1 && /\p{Emoji}/u.test(base) ? 1 : 0) : /[\p{Mn}\u1160-\u11ff\ud7b0-\ud7ff]/u.test(c) ? 0 : WIDE.test(c) ? 2 : 1
2173
+ const columns = l => [...l.replace(OSC8, '')].reduce((a, c) => { const w = charCols(c, a.c, a.w), j = joins(c, a.c); return { n: a.n + w, c: j ? a.c : c, w: j ? a.w + w : w } }, { n: 0, c: '', w: 0 }).n
2174
+ // a line broken between words into lines of at most `cols` columns, and before a CJK character, as CJK text breaks between any two (phase 15 round 1: a
2175
+ // path of Han characters and no space was one word wider than the terminal); a run of other characters wider than that is a line of its own, whole —
2176
+ // --schedule's sentence on a terminal, and the markdown's lines outside its tables (phase 15)
2177
+ const BEFORE_CJK = /(?=[\u1100-\u115f\u2e80-\u303e\u3041-\u9fff\ua000-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe6f\uff01-\uff60\u{20000}-\u{3fffd}])/u
2178
+ const wrapWords = (s, cols) => s.split(' ').reduce((ls, w) => {
2179
+ w.split(BEFORE_CJK).forEach((p, k) => { const sep = k ? '' : ' '; if (ls.length && columns(ls.at(-1) + sep + p) <= cols) ls[ls.length - 1] += sep + p; else ls.push(p) })
2180
+ return ls
2181
+ }, [])
2182
+
2183
+ // T-3 (ACCEPTANCE), accepted with this warning: brief runs `git fetch` in each repo it is
2184
+ // pointed at, and a checkout's own .git/config can run commands when git reaches its remote
2185
+ // (the transport: core.sshCommand, remote.*.uploadpack, a credential helper). Hooks, fsmonitor, askpass and
2186
+ // the other commands a repo can name are switched off (UNTRUSTED_GIT; GIT_ASKPASS in the env of every git it runs), and the notice says so.
2187
+ // The threat is the checkout, not the origin: two clones of one origin hold two configs, and the first one found
2188
+ // is the one fetched. So the notice is for every checkout ~/.wuwei/brief.json has not seen — by
2189
+ // real path, each one listed — printed on stderr before any fetch; cmdBrief records them as
2190
+ // seen in the run's one write, at its end, whatever the fetch does — so a checkout is warned
2191
+ // about once, unless the run dies before that write (then the next one warns again). --schedule calls it with no list: it
2192
+ // reads no repo, and the routine it prints meets its checkouts with no one there — that run's notice goes to its stderr
2193
+ function t3Notice(unseen) {
2194
+ const body = [
2195
+ ...unseen ? [`⚠ wuwei brief has not looked at ${unseen.length === 1 ? 'this checkout' : 'these checkouts'} before:`, '',
2196
+ ...plain(unseen).map(u => ` ${u.origin} (${u.checkout})`)]
2197
+ : ['⚠ The routine below runs wuwei brief with no one watching, in every repo under the paths it names;',
2198
+ ' the notice it prints the first time it meets a checkout goes to its stderr — launchd: stderr.log, until the',
2199
+ ' next run; cron: mail, where it is set up; the Claude desktop app: the task\'s output.'], '',
2200
+ 'It runs `git fetch` in each repo, with the repo\'s hooks, fsmonitor, gpg program',
2201
+ 'and askpass program (core.askPass) switched off. Its own .git/config can still run commands',
2202
+ 'to reach its remote (core.sshCommand, remote.origin.uploadpack, a credential helper) —',
2203
+ 'point brief only at repos you trust.',
2204
+ ]
2205
+ const w = Math.max(...body.map(columns))
2206
+ return [`┌${'─'.repeat(w + 2)}┐`, ...body.map(l => `│ ${l}${' '.repeat(w - columns(l))} │`), `└${'─'.repeat(w + 2)}┘`].join('\n')
2207
+ }
2208
+
2209
+ // ── brief: the GitHub half's door — who gh is, and which repos its token can read ──
2210
+ // gh is the user's: their logins, their config. It gets none of brief's git hardening, only the environment less the env tokens (NO_ENV_TOKEN: the source is
2211
+ // always a stored login, named `keyring`), and it is run neither through a shell nor in a checkout: brief's own directory can be one (`wuwei brief .`),
2212
+ // and on Windows the cwd is searched before PATH for `gh` itself — so gh is run from the home directory. `token` is another stored account's (ghAccess): it goes
2213
+ // into that one gh's environment as GH_TOKEN — never an argument — and is cut out of whatever that gh prints, so no line of it can carry the token on (T-1).
2214
+ // never rejects: { ok, missing (gh could not be started), stdout, stderr, error }. Cut after 30s: a routine has no one to wait for it. The cwd is the home directory,
2215
+ // else the temp directory, else the root, the first that is a directory: a home not on disk (HOME=/nonexistent) fails the spawn with ENOENT, which would read as
2216
+ // "gh not found", and one that is a file (HOME=/dev/null) makes execFile THROW (ENOTDIR). isDir keeps both from happening; the catch is a backstop for a throw
2217
+ // nothing here provokes (T-1's harness reaches it by calling ghRun with a NUL in the token), so that one is a gh that failed, never brief falling over
2218
+ const isDir = p => { try { return statSync(p).isDirectory() } catch { return false } }
2219
+ const ghCwd = () => [homedir(), tmpdir(), '/'].find(isDir)
2220
+ // a gh run with another account's `token` (ghAccess) can echo it — a failure line under GH_DEBUG, a body that quotes it: cut wherever its text comes back (T-1).
2221
+ // The error Node hands back is not kept whole: its message is "Command failed: …" and gh's raw stderr, token and all — only what is read of it is, code, killed, signal.
2222
+ // Nor is one execFile throws: Node refusing an env value quotes it, escaped as util.inspect writes it (a NUL as \x00) — text no cut of the token finds — so with a
2223
+ // token only its code is said. tokenOf lets no such token through (printable ASCII only); this is the backstop
2224
+ const cutToken = (t, token) => token ? String(t ?? '').split(token).join('<token>') : t
2225
+ const ghRun = (args, token) => new Promise(done => {
2226
+ try {
2227
+ execFile('gh', args, { encoding: 'utf8', timeout: 30000, maxBuffer: 16 * 1024 * 1024, cwd: ghCwd(), env: token ? { ...NO_ENV_TOKEN, GH_TOKEN: token } : NO_ENV_TOKEN },
2228
+ (error, stdout, stderr) => done({ ok: !error, missing: typeof error?.code === 'string', stdout: cutToken(stdout, token), stderr: cutToken(stderr, token),
2229
+ error: error && { code: error.code, killed: error.killed, signal: error.signal } }))
2230
+ } catch (error) { done({ ok: false, missing: false, stdout: '', stderr: `gh could not be run: ${token ? error.code : error.message}`, error: { code: error.code, killed: false, signal: null } }) }
2231
+ })
2232
+ // gh's first line for a failure, printed as a person's detail
2233
+ const ghWhy = r => firstLine(r.stderr).replace(/^gh: /, '') || (r.error?.killed ? 'gh timed out' : `gh exited ${r.error?.code}`)
2234
+ // what a failed probe says about access. Only GitHub's own refusal is a verdict on it — HTTP 401, 403 or 404 in gh's first line (`gh: <message> (HTTP n)`), what a token that
2235
+ // cannot read the repo gets (R12) — and a 403 that is the rate limit, by its words, is not one. A timeout, no network (gh's line has no status then), a 5xx say nothing
2236
+ // about the token: `failed`, with gh's first line, and the repo keeps its mark as it does for a failed query
2237
+ const ghDenied = res => ({ state: /\(HTTP (401|403|404)\)\s*$/.test(firstLine(res.stderr)) && !/rate limit/i.test(firstLine(res.stderr)) ? 'no-access' : 'failed', detail: ghWhy(res) })
2238
+ // what to run for a repo no account reads: the command gh's own hint names when the token lacks a scope (`gh auth refresh -h github.com -s repo`, verbatim), else a
2239
+ // login of an account that can read it
2240
+ const ghFix = res => res.stderr.match(/\bgh auth refresh\b[^\n]*/)?.[0].trim() ?? 'gh auth login'
2241
+ // owner/repo of a github.com origin, else null. The origin is a checkout's own text and goes into an endpoint: names of GitHub's alphabet, and never . or ..
2242
+ const ghSlug = origin => {
2243
+ const m = origin?.match(/^github\.com\/([\w.-]+)\/([\w.-]+)$/)
2244
+ return m && !/^\.+$/.test(m[1]) && !/^\.+$/.test(m[2]) ? `${m[1]}/${m[2]}` : null
2245
+ }
2246
+
2247
+ // Does an ssh host stand for github.com? `ssh -G <host>` prints the configuration ssh would use for it, `hostname` the host it would reach — github.com behind an
2248
+ // alias such as `Host github.com-ewe` (U1) — or ssh.github.com, GitHub's ssh over port 443 (`Hostname ssh.github.com`, `Port 443`), the same GitHub. Run as gh is: no shell, the environment less the env tokens, from ghCwd, and cut after 5s (it reads config, nothing
2249
+ // more). No ssh, a failure or any other host: false. The host is a checkout's text and goes into an argument: ghAccess lets only a host name's alphabet through
2250
+ const sshIsGitHub = host => new Promise(done => {
2251
+ try {
2252
+ execFile('ssh', ['-G', host], { encoding: 'utf8', timeout: 5000, cwd: ghCwd(), env: NO_ENV_TOKEN },
2253
+ (error, stdout) => done(!error && ['github.com', 'ssh.github.com'].includes(/^hostname (\S+)$/m.exec(stdout)?.[1].toLowerCase())))
2254
+ } catch { done(false) }
2255
+ })
2256
+
2257
+ // gh's stored github.com accounts that are logged in, [{ login, active }]: `gh auth status --json hosts`, or — on a gh older than that flag, which refuses it — the
2258
+ // text form, which says the same an account at a time (`✓ Logged in to github.com account <login> (<source>)`, then `- Active account: true|false`; a stale one is
2259
+ // `X Failed to log in …` and is left out, as `state` other than success is from the JSON). gh exits 1 when ANY stored account fails, so what it printed is read
2260
+ // whatever the exit, from stdout and stderr both. A login goes into an argument (`--user`): only GitHub's alphabet for one gets through
2261
+ async function ghAccounts() {
2262
+ const name = /^[A-Za-z0-9][A-Za-z0-9-]*$/
2263
+ const j = await ghRun(['auth', 'status', '--hostname', 'github.com', '--json', 'hosts'])
2264
+ let list
2265
+ try { list = JSON.parse(j.stdout).hosts['github.com'].filter(a => a?.state === 'success').map(a => ({ login: String(a.login), active: a.active === true })) } catch {
2266
+ const t = await ghRun(['auth', 'status', '--hostname', 'github.com'])
2267
+ list = []
2268
+ let at = null
2269
+ for (const l of `${t.stdout}\n${t.stderr}`.split('\n')) {
2270
+ const acct = l.match(/ github\.com account (\S+) \(/), active = l.match(/Active account: (true|false)/)
2271
+ if (acct) { at = /Logged in to github\.com account/.test(l) ? { login: acct[1], active: false } : null; if (at) list.push(at) }
2272
+ else if (active && at) at.active = active[1] === 'true'
2273
+ }
2274
+ }
2275
+ return list.filter(a => name.test(a.login))
2276
+ }
2277
+
2278
+ // Is gh there and logged in at github.com — the top-level `github`: not-found, not-authenticated, failed (with `detail`), or ok with the token's source — and then
2279
+ // which repos does that token read. A token that cannot read a repo gets zero results and no error from a search (the spike), so each
2280
+ // github.com repo is asked for first, and only what the search will need lets it be searched. Two calls: `repos/<owner>/<repo>` with `--jq .full_name`, the
2281
+ // name GitHub gives it NOW — a repo renamed or moved answers under its old name too (a redirect, which gh follows: a 2xx), and a search for the old name finds
2282
+ // nothing, as would the filter on `nameWithOwner` — and then `repos/<full_name>/pulls?per_page=1&state=all`, because a fine-grained token with the repo's
2283
+ // metadata and no "Pull requests: read" passes the first and gets a silent zero from the search. REST and not a GraphQL `pullRequests { totalCount }`: it is the
2284
+ // same permission, one small answer, and gh prints a REST failure with its HTTP status. A 401, 403 (SAML, R12) or 404 is `no-access` with gh's first line; any other
2285
+ // failure is `failed` (ghDenied). A gh that cannot say who it is, for a reason that is no missing login, is the top-level `failed` with its first line.
2286
+ // An origin on another host is github.com all the same when ssh says its host stands for it (`sshHosts[i]`, discoverRepos' `sshHost`: an alias, sshIsGitHub — asked
2287
+ // once a host): the slug is then the origin's path, and the origin itself stays as it is, the repo's name and the record's key.
2288
+ // The active account asks, as gh would — after the stored account the repo's owner names, when that is another (a repo of `Jameshsiao`'s is read as Jameshsiao
2289
+ // first, even where the active account reads it too, or the PRs that wait on him would be judged as someone else). Then, for a repo it cannot read, gh's other
2290
+ // github.com accounts in turn (ghAccounts, listed once: up front when some repo's owner is not the active login, else at the first no-access), each with its own
2291
+ // token (`gh auth token --user`, taken once, in that gh's env alone: ghRun's `token`); the first account that reads a repo is that repo's, for the
2292
+ // search too. A repo none reads keeps the active account's first line as `detail`, with `tried` (every login asked, in turn, when there was another to ask — an
2293
+ // account whose token cannot be taken is skipped and named all the same) and `fix` (ghFix, from the active account's answer).
2294
+ // That and a non-github.com origin set `repo.github` ({ state, detail?, tried?, fix? }) in place; a repo that reads says nothing here and comes back in `reachable`
2295
+ // ([{ repo, slug, account?, token? }], the repos' order, `slug` the canonical name, `account` and `token` when another account than the active one reads it) for the
2296
+ // search. `slugs[i]` is repo i's owner/repo for the markdown's links — the name GitHub gave it when one read it, else the one its origin (or ssh alias) names;
2297
+ // none off github.com, and only ghSlug's alphabet. A checkout with no origin has no GitHub to speak of. Never throws; `limit` calls at a time
2298
+ async function ghAccess(repos, { limit = 8, sshHosts = [] } = {}) {
2299
+ // who gh is, asked of GitHub with the login gh would use: `gh auth status` checks EVERY account gh has stored and fails for a stale one beside a working active
2300
+ // login — and says "Failed to log in" for no network too — so it cannot tell a login to make from one that works. gh exits 4 when it has no login at all. A 401
2301
+ // is one of two: `Bad credentials`, a login GitHub refused (`refused`), or `Requires authentication`, no token sent at all — gh knows a host and cannot read
2302
+ // its github.com login, as a scheduler that cannot reach the keyring finds (`noToken`; logging in again at a terminal will not fix the routine, so the line
2303
+ // says what it is). All three are `gh auth login` to fix. Anything else (no network, a timeout, a 5xx) is not a login to make: `failed`, with gh's first line
2304
+ const auth = await ghRun(['api', '--hostname', 'github.com', 'user', '--jq', '.login'])
2305
+ if (!auth.ok) {
2306
+ const github = auth.missing ? { state: 'not-found' } : auth.error?.code === 4 ? { state: 'not-authenticated' }
2307
+ : /\(HTTP 401\)\s*$/.test(firstLine(auth.stderr)) ? { state: 'not-authenticated', ...(/bad credentials/i.test(firstLine(auth.stderr)) ? { refused: true } : { noToken: true }) }
2308
+ : { state: 'failed', detail: ghWhy(auth) }
2309
+ return { github, reachable: [], slugs: repos.map(r => ghSlug(r.origin)) }
2310
+ }
2311
+ // the two calls for one repo, as the active account or (`token`) another: { full } — the name GitHub gives it — or ghDenied's verdict, with gh's answer for ghFix
2312
+ const probe = async (slug, token) => {
2313
+ const named = await ghRun(['api', '--jq', '.full_name', '--hostname', 'github.com', `repos/${slug}`], token)
2314
+ if (!named.ok) return { ...ghDenied(named), res: named }
2315
+ const full = ghSlug(`github.com/${named.stdout.trim()}`) // it goes into an endpoint and a search term: ghSlug's alphabet
2316
+ if (!full) return { state: 'failed', detail: 'GitHub answered with no repository name' }
2317
+ const read = await ghRun(['api', '--silent', '--hostname', 'github.com', `repos/${full}/pulls?per_page=1&state=all`], token)
2318
+ return read.ok ? { full } : { ...ghDenied(read), res: read }
2319
+ }
2320
+ const reads = [], viaSsh = new Map(), slugs = [] // by index: the answers come back in any order
2321
+ await pool(repos.length, limit, async i => {
2322
+ const repo = repos[i], host = sshHosts[i]
2323
+ if (repo.origin == null) return
2324
+ let slug = ghSlug(repo.origin)
2325
+ if (!slug && !repo.origin.startsWith('github.com/') && /^[A-Za-z0-9][\w.-]*$/.test(host ?? '')) { // a host name, so never a flag
2326
+ if (!viaSsh.has(host)) viaSsh.set(host, sshIsGitHub(host))
2327
+ if (await viaSsh.get(host)) slug = ghSlug(`github.com/${repo.origin.slice(repo.origin.indexOf('/') + 1)}`)
2328
+ }
2329
+ if (!slug) { repo.github = { state: 'not-github' }; return }
2330
+ slugs[i] = slug
2331
+ })
2332
+ // gh's accounts, listed once (ghAccounts) and only when another may be wanted: at once when a repo's owner is not the active login — a stored account of that
2333
+ // name is asked first — else when a repo comes back no-access. Each account's token is taken once, when first wanted
2334
+ const login = auth.stdout.trim(), ownerOf = slug => slug.split('/')[0].toLowerCase()
2335
+ let listed = null
2336
+ const accounts = () => listed ??= ghAccounts().then(all => {
2337
+ const me = all.find(a => a.active)?.login ?? login
2338
+ return { me, others: all.filter(a => !a.active && a.login.toLowerCase() !== me.toLowerCase()).map(a => a.login) }
2339
+ })
2340
+ if (slugs.some(s => s && ownerOf(s) !== login.toLowerCase())) await accounts()
2341
+ // a token is printable ASCII: anything else — a NUL, which Node will not put in an env, a control character gh would send on — is none, and the account skipped
2342
+ const tokens = new Map()
2343
+ const tokenOf = user => {
2344
+ if (!tokens.has(user)) tokens.set(user, ghRun(['auth', 'token', '--hostname', 'github.com', '--user', user]).then(t => t.ok && /^[!-~]+$/.test(t.stdout.trim()) ? t.stdout.trim() : null))
2345
+ return tokens.get(user)
2346
+ }
2347
+ // each repo asked in turn of: the stored account its owner names, when that is not the active one — its own repo, read as itself even where the active account
2348
+ // reads it too (public), or what waits on it would be judged as someone else — then the active account (`null`), then the others, in gh's order
2349
+ await pool(repos.length, limit, async i => {
2350
+ const repo = repos[i], slug = slugs[i]
2351
+ if (!slug) return
2352
+ const owner = listed && (await listed).others.find(l => l.toLowerCase() === ownerOf(slug))
2353
+ const order = [...owner ? [owner] : [], null], tried = []
2354
+ let active = null, failed = null // the active account's answer: a repo no account reads keeps its first line and its fix; the first answer that was none
2355
+ for (let k = 0; k < order.length; k++) {
2356
+ const user = order[k]
2357
+ tried.push(user)
2358
+ if (user === null) {
2359
+ const p = await probe(slug)
2360
+ if (p.full) { reads[i] = { repo, slug: p.full }; slugs[i] = p.full; return }
2361
+ if (p.state !== 'no-access') { repo.github = { state: p.state, detail: (failed ?? p).detail }; return }
2362
+ active = p
2363
+ order.push(...(await accounts()).others.filter(l => l !== owner))
2364
+ continue
2365
+ }
2366
+ const token = await tokenOf(user)
2367
+ if (!token) continue // skipped, and named in `tried` all the same
2368
+ const p = await probe(slug, token)
2369
+ if (p.full) { reads[i] = { repo, slug: p.full, account: user, token }; slugs[i] = p.full; return }
2370
+ // no answer (a timeout, no network, a 5xx) says nothing of this account's access: the next account is asked all the same — the owner's failing must not
2371
+ // keep the active one from a repo it reads — and the repo is `failed` with the first such line only when none reads it; never no-access, whose fix (a login)
2372
+ // would be wrong
2373
+ if (p.state !== 'no-access') failed ??= p
2374
+ }
2375
+ if (failed) { repo.github = { state: failed.state, detail: failed.detail }; return }
2376
+ const { me, others } = await accounts()
2377
+ repo.github = { state: 'no-access', detail: active.detail, ...(others.length ? { tried: tried.map(u => u ?? me) } : {}), fix: ghFix(active.res) }
2378
+ })
2379
+ return { github: { state: 'ok', source: 'keyring' }, reachable: reads.filter(Boolean), slugs }
2380
+ }
2381
+
2382
+ // ── brief: the GitHub half's search — the PRs of R9, each by the condition it waits on ──
2383
+ // Which PRs matter (R9): a `wuwei/…` head in the repo itself, whoever wrote it (a fork's is not the team's), and the viewer's own. Per chunk of repos two searches — what is open, and what merged
2384
+ // since the chunk's earliest GitHub window (`merged:>=` the whole timestamp, `YYYY-MM-DDTHH:MM:SSZ`, which GitHub documents; a chunk's window is its repos' earliest,
2385
+ // so the exact `mergedAt` is still cut per repo below) — and a chunk is as many `repo:` terms as the query's 256 characters take. The search string is a GraphQL
2386
+ // variable, built from slugs ghSlug let through and an ISO time, never spliced into the query.
2387
+ // T-2: the query asks for numbers, branch names, states, logins and a draft flag (a boolean) — no title, no body, no comment's text — so there is none to print
2388
+ const GH_QUERY = `query($search: String!, $first: Int!, $after: String) {
2389
+ viewer { login }
2390
+ search(query: $search, type: ISSUE, first: $first, after: $after) {
2391
+ issueCount
2392
+ pageInfo { hasNextPage endCursor }
2393
+ nodes { ... on PullRequest {
2394
+ number headRefName isCrossRepository isDraft state mergedAt reviewDecision
2395
+ author { login }
2396
+ repository { nameWithOwner }
2397
+ latestReviews(first: 100) { nodes { state author { login } } }
2398
+ reviewRequests(first: 100) { nodes { requestedReviewer { ... on User { login } } } }
2399
+ commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
2400
+ reviewThreads(first: 100) { nodes { isResolved comments(last: 1) { nodes { author { login } } } } }
2401
+ } }
2402
+ }
2403
+ }`
2404
+ // every page of one search, by the cursor (not `gh --paginate`, whose output across pages is only as parseable as the gh's version): { viewer, nodes },
2405
+ // or { error } — gh's first line — and then no half an answer. A page is 50: each PR brings its threads and reviews.
2406
+ // GitHub pages a search through to its first 1000 results and then just stops, with no error: `issueCount` is what matched, so fewer nodes than that
2407
+ // (or the 40 pages, which is 2000, not ending) is a search cut short — an answer that leaves PRs out and does not say so, which is the silent zero
2408
+ async function ghPages(search, token) {
2409
+ const nodes = []
2410
+ let after = null, total = 0
2411
+ const capped = () => ({ error: `search capped at ${nodes.length} of ${total} results`, capped: true })
2412
+ for (let page = 0; page < 40; page++) {
2413
+ const res = await ghRun(['api', 'graphql', '--hostname', 'github.com', '-f', `query=${GH_QUERY}`, '-f', `search=${search}`, '-F', 'first=50', ...(after ? ['-f', `after=${after}`] : [])], token)
2414
+ let j = null
2415
+ try { j = JSON.parse(res.stdout) } catch {}
2416
+ const said = j?.errors?.[0]?.message // an error in the body is an error whatever the exit code, and the body is where an older gh puts it
2417
+ // …cut again once parsed: JSON may spell the token in \u escapes, which ghRun's cut on the raw text does not find and JSON.parse decodes (T-1)
2418
+ if (!res.ok || said != null) return { error: ghWhy({ ...res, stderr: res.stderr.trim() ? res.stderr : cutToken(String(said ?? ''), token) }) }
2419
+ const s = j?.data?.search, viewer = j?.data?.viewer?.login
2420
+ if (!Array.isArray(s?.nodes) || !Number.isInteger(s.issueCount) || typeof viewer !== 'string' || !viewer) return { error: 'GitHub answered with no search result' }
2421
+ nodes.push(...s.nodes)
2422
+ total = s.issueCount
2423
+ if (!s.pageInfo?.hasNextPage || !s.pageInfo.endCursor) return nodes.length >= total ? { viewer, nodes } : capped()
2424
+ after = s.pageInfo.endCursor
2425
+ }
2426
+ return capped() // 40 pages and still more to come
2427
+ }
2428
+ // one chunk's two queries: [{ slug, repos }] → { open, merged }. Both carry the same repos, so a chunk is cut by the longer (the merged) one
2429
+ const ghSearches = chunk => {
2430
+ const terms = chunk.map(c => ` repo:${c.slug}`).join('')
2431
+ const from = chunk.flatMap(c => c.repos.map(r => r.githubWindow.start)).sort()[0] // ISO strings: they sort as times, and `start` is already `…T…Z` to the second
2432
+ return { open: `is:pr is:open${terms}`, merged: `is:pr is:merged merged:>=${from}${terms}` }
2433
+ }
2434
+
2435
+ // GitHub's search index lags: a PR merged just before or during a run may not be found by it yet, and a GitHub mark at the run's start would cut that PR out of every
2436
+ // later window for good. So the mark is this far before the start: a PR can be listed twice, the side the git mark already accepts (a PR is never lost to it)
2437
+ const GH_INDEX_LAG = 10 * 60e3
2438
+
2439
+ // what a PR can wait on, in the order a reader takes them (the wireframe's), and the words the markdown and a routine's agent get for it
2440
+ const GH_KINDS = ['pr-ci', 'pr-approved', 'pr-review-human', 'pr-review-requested', 'pr-reply']
2441
+ const prDetail = (kind, threads) => kind === 'pr-reply' ? `${threads} thread${threads === 1 ? ' owes' : 's owe'} you a reply`
2442
+ : { 'pr-ci': 'CI failure', 'pr-approved': 'approved — not merged', 'pr-review-human': 'review: human — PR open', 'pr-review-requested': 'your review requested' }[kind]
2443
+ const ghLogin = a => String(a?.login ?? '').toLowerCase() // '' for no account (a deleted one) — and never the viewer's, which is not empty
2444
+
2445
+ // one PR node of a search → what it is, or [] when it is not one brief lists: a wuwei/ head or the viewer's own, open (the conditions that wait on someone)
2446
+ // or merged since `start` (its exact mergedAt). Only what a search can say: `review: human` is a fact about the phase, ghPlace has the stream.
2447
+ // A head in another repository is a fork's, and its branch name is the forker's text, not the team's stream (T-2): `wuwei/…` there counts for nothing, and the PR
2448
+ // is listed only as the viewer's own — `fork`, which ghPlace reads to keep it out of the streams. A node that does not say it is not a fork is taken for one.
2449
+ // A draft is `draft` (U1: #1284 was approved and a draft — GitHub will not merge it until it is marked ready): only a node that says so is one
2450
+ function ghClassify(n, me, start) {
2451
+ const branch = String(n.headRefName ?? ''), mine = ghLogin(n.author) === me, fork = n.isCrossRepository !== false
2452
+ if (!Number.isInteger(n.number) || !((branch.startsWith('wuwei/') && !fork) || mine)) return []
2453
+ const p = { pr: n.number, branch, kinds: [], ...(fork ? { fork: true } : {}), ...(n.isDraft === true ? { draft: true } : {}) }
2454
+ if (n.state === 'MERGED') {
2455
+ const at = Date.parse(n.mergedAt)
2456
+ return at >= start ? [{ ...p, at: isoOf(at / 1000) }] : [] // a merged PR waits on nothing, whatever its last CI or review said
2457
+ }
2458
+ if (n.state !== 'OPEN') return []
2459
+ const rolled = n.commits?.nodes?.at(-1)?.commit?.statusCheckRollup?.state
2460
+ if (rolled === 'FAILURE' || rolled === 'ERROR') p.kinds.push('pr-ci')
2461
+ // `reviewDecision` is null where no review is required (R6): then the latest review of each author decides — an APPROVED and no CHANGES_REQUESTED
2462
+ const reviews = (n.latestReviews?.nodes ?? []).map(r => r?.state) // connection nodes are nullable in GitHub's schema: a null one is skipped, here and below
2463
+ if (n.reviewDecision === 'APPROVED' || (n.reviewDecision == null && reviews.includes('APPROVED') && !reviews.includes('CHANGES_REQUESTED'))) p.kinds.push('pr-approved')
2464
+ if ((n.reviewRequests?.nodes ?? []).some(r => ghLogin(r?.requestedReviewer) === me)) p.kinds.push('pr-review-requested') // a team is no login
2465
+ // owed a reply: the viewer's own PR, a thread still open whose last comment is not theirs (a deleted account's is someone else's; a comment that is no node at all says nothing)
2466
+ const owed = mine ? (n.reviewThreads?.nodes ?? []).filter(t => { const last = t?.comments?.nodes?.at(-1); return !t?.isResolved && t && last && ghLogin(last.author) !== me }).length : 0
2467
+ if (owed) { p.kinds.push('pr-reply'); p.threads = owed }
2468
+ return [p]
2469
+ }
2470
+
2471
+ // the PRs of one repo (ghClassify's) → the repo. A `wuwei/<stream>/…` PR goes under that stream when the repo lists it: `waiting`, one entry per condition,
2472
+ // or `done` when it merged in the window — in place of the phase `done` entry of the same phase, which says the same thing. A PR that is `review: human`
2473
+ // (the stream's `left` says so, for the phase its branch names) waits on that as well. Any other — the viewer's own, a fork's whatever its head says, or a wuwei/
2474
+ // one of a stream brief does not know — goes to `otherPrs`; one with nothing to say is in neither. A stream the git window left out (merged, before it) is brief's own and comes
2475
+ // back, merged, for a PR that has a line to put under it — the one place the list of streams changes, so the repo's note and its `streams` agree after it
2476
+ function ghPlace(repo, prs) {
2477
+ repo.otherPrs = []
2478
+ const was = allStreams.get(repo), back = new Set()
2479
+ const add = new Map() // stream → { waiting, done }: the entries this adds to it, put in order below
2480
+ // a stream named before v0.1.0 (🔧, phase 10; U1: #1295 under no stream) has no `wuwei/<stream>/…` heads: its PRs come from its stream branch, `wuwei/<stream>`
2481
+ // or `wuwei/<stream>-…` — the `-` a boundary, so wuwei/payx-1 is not pay's, and no `/` after it, so wuwei/pay-x/base is a stream pay-x's — and the longest
2482
+ // name wins (wuwei/pay-out-02-x is pay-out's). Only such a stream: a name of today's that a head merely starts with is no claim on it
2483
+ const old = repo.streams.flatMap(s => s.waiting.filter(w => w.kind === 'legacy-branch').map(w => ({ s, branch: w.detail })))
2484
+ const oldOf = b => old.filter(({ s, branch }) => { const w = `wuwei/${s.name}`; return b === branch || b === w || (b.startsWith(`${w}-`) && !b.slice(w.length).includes('/')) })
2485
+ .sort((x, y) => (b === y.branch) - (b === x.branch) || y.s.name.length - x.s.name.length)[0]?.s
2486
+ for (const p of prs) {
2487
+ const m = p.branch.match(/^wuwei\/(.+)\/([^/]+)$/), s = !p.fork && ((m && (repo.streams.find(x => x.name === m[1]) ?? was?.all.find(x => x.name === m[1]))) || oldOf(p.branch))
2488
+ // the stream PR, open (a draft too): a stream-ready stream owes it no more (U1: rxd-3344 was told to open #1284, which it had) — the PR's own rows say what
2489
+ // is left, its entry carries the PR. Before the `continue` below, for a stream PR with nothing to say is still one. The lowest number, should there be two
2490
+ const ready = s && !p.at && s.name === m?.[1] && m[2] === 'base' && s.waiting.find(w => w.kind === 'stream-ready')
2491
+ if (ready && !(ready.pr < p.pr)) Object.assign(ready, { pr: p.pr, detail: 'every phase merged — the stream PR is open' })
2492
+ if (s && !p.at && s.left.some(l => l.phase === m?.[2] && l.review === 'human')) p.kinds.push('pr-review-human')
2493
+ if (!p.at && !p.kinds.length) continue
2494
+ if (s && !repo.streams.includes(s)) back.add(s)
2495
+ const draft = p.draft && !p.at ? { draft: true } : {} // a merged PR is no draft, whatever the node said
2496
+ if (!s) { repo.otherPrs.push({ pr: p.pr, branch: p.branch, waiting: p.kinds, ...(p.threads ? { threads: p.threads } : {}), ...draft, ...(p.at ? { mergedAt: p.at } : {}) }); continue }
2497
+ if (!add.has(s)) add.set(s, { waiting: [], done: [] })
2498
+ if (p.at) { s.done = s.done.filter(d => d.phase !== m?.[2]); add.get(s).done.push({ pr: p.pr, branch: p.branch, at: p.at }) }
2499
+ add.get(s).waiting.push(...p.kinds.map(kind => ({ kind, pr: p.pr, branch: p.branch, detail: prDetail(kind, p.threads), ...(kind === 'pr-reply' ? { threads: p.threads } : {}), ...draft })))
2500
+ }
2501
+ for (const [s, a] of add) {
2502
+ s.waiting.push(...a.waiting.sort((x, y) => GH_KINDS.indexOf(x.kind) - GH_KINDS.indexOf(y.kind) || x.pr - y.pr))
2503
+ s.done.push(...a.done.sort((x, y) => Date.parse(x.at) - Date.parse(y.at) || x.pr - y.pr))
2504
+ }
2505
+ repo.otherPrs.sort((a, b) => a.pr - b.pr)
2506
+ if (!back.size) return
2507
+ repo.streams = was.all.filter(x => repo.streams.includes(x) || back.has(x)) // in collectRepo's order
2508
+ if (was.note) { repo.notes = repo.notes.filter(n => n !== was.note); if (!repo.notes.length) delete repo.notes } // it lists one now
2509
+ }
2510
+
2511
+ // One chunk — one account's (ghSearch), so its searches run with that account's token and `viewer` is that account: who a PR waits on is the account that reads
2512
+ // its repo. Both searches, then every repo of it is `ok` (with `account` when that is not the active one) and has its PRs placed — or, if either search fails, `failed` with gh's first line, all of them
2513
+ // together (a query names the chunk's repos, so GitHub's error cannot say which); a search GitHub cut short is the exception, its repos searched again alone. What gh said is read for ALL the repos before any is touched, so an
2514
+ // answer that is not the shape the query asked for fails the chunk whole, not half placed. Never throws
2515
+ async function ghChunk(chunk) {
2516
+ const fail = detail => { for (const c of chunk) for (const r of c.repos) r.github = { state: 'failed', detail } }
2517
+ try {
2518
+ const q = ghSearches(chunk), { account, token } = chunk[0]
2519
+ const open = await ghPages(q.open, token), merged = open.error ? open : await ghPages(q.merged, token)
2520
+ // GitHub cut a search short: with more than one repo in it, each is searched alone — or one busy repo would fail its neighbours on every run, its window and
2521
+ // theirs held where they were, so the same chunk again. Alone, only a repo that has more than GitHub will page through fails
2522
+ if ((open.capped || merged.capped) && chunk.length > 1) { for (const t of chunk) await ghChunk([t]); return }
2523
+ if (open.error ?? merged.error) return fail(open.error ?? merged.error)
2524
+ const me = ghLogin({ login: open.viewer })
2525
+ // a PR is one node: it can come twice — in both searches, when it merged between them (the open one saw it open, the merged one merged), or on two pages as the
2526
+ // index moves — and then the LATER wins, the merged search's: a PR is never a waiting row and a done row at once. A null node (a hit GitHub could not show) is skipped
2527
+ const byPr = new Map()
2528
+ for (const n of [...open.nodes, ...merged.nodes]) if (n?.repository?.nameWithOwner && Number.isInteger(n.number)) byPr.set(`${String(n.repository.nameWithOwner).toLowerCase()}#${n.number}`, n)
2529
+ const found = chunk.flatMap(c => {
2530
+ const nodes = [...byPr.values()].filter(n => String(n.repository.nameWithOwner).toLowerCase() === c.slug.toLowerCase())
2531
+ return c.repos.map(r => [r, nodes.flatMap(n => ghClassify(n, me, Date.parse(r.githubWindow.start)))])
2532
+ })
2533
+ for (const [r, prs] of found) { r.github = { state: 'ok', ...(account ? { account } : {}) }; ghPlace(r, prs) }
2534
+ } catch (e) { fail(`GitHub's answer could not be read: ${firstLine(e.message)}`) }
2535
+ }
2536
+
2537
+ // The GitHub half: the PRs of the repos ghAccess found readable (`reachable`), each placed on its repo, each searched as the account that read it (a chunk is one
2538
+ // account's). `window(repo)` is the repo's GitHub window ({ kind, start }):
2539
+ // it goes on the repo as `githubWindow` — for every repo whose query ran, failed or not — and cuts what merged before it. Chunks, one window start each, are searched four at a time;
2540
+ // a repo comes out `github: { state: 'ok', account? }` with `otherPrs` set and its streams' PR entries added, or `failed`. Two checkouts whose origins differ only in case
2541
+ // are one search term and two repos. Never throws
2542
+ async function ghSearch(reachable, window) {
2543
+ const terms = new Map()
2544
+ for (const { repo, slug, account, token } of reachable) {
2545
+ repo.githubWindow = window(repo)
2546
+ const key = `${account ?? ''}\0${slug.toLowerCase()}`
2547
+ if (!terms.has(key)) terms.set(key, { slug, account, token, repos: [] })
2548
+ terms.get(key).repos.push(repo)
2549
+ }
2550
+ // a chunk's merged search goes back to its EARLIEST window start, so a repo with an old window would widen its neighbours' — more results each, and nearer
2551
+ // GitHub's cap of 1000, which fails the whole chunk. So terms are grouped by account and window start (a term's is the earliest of its repos') and chunks cut inside a group
2552
+ const groups = new Map()
2553
+ for (const t of terms.values()) {
2554
+ const key = `${t.account ?? ''}\0${t.repos.map(r => r.githubWindow.start).sort()[0]}`
2555
+ groups.set(key, [...(groups.get(key) ?? []), t])
2556
+ }
2557
+ const chunks = []
2558
+ for (const group of groups.values()) {
2559
+ const first = chunks.length
2560
+ for (const t of group) {
2561
+ if (chunks.length > first && ghSearches([...chunks.at(-1), t]).merged.length <= 256) chunks.at(-1).push(t)
2562
+ else chunks.push([t])
2563
+ }
2564
+ }
2565
+ await pool(chunks.length, 4, i => ghChunk(chunks[i]))
2566
+ }
2567
+
2568
+ // Needs you's kinds (briefModel; the markdown's and the page's alike). One symbol per kind, a single code point with emoji presentation: no ZWJ, no U+FE0F —
2569
+ // a terminal draws such a code point two columns wide, as `columns` counts it, while terminals disagree on a sequence (U+FE0F after a text-style base is one
2570
+ // column in some, two in others; a ZWJ sequence one glyph or several), which would put the table out of line where brief cannot see. Their order is the order Needs you is in (T4: red CI … open questions);
2571
+ // beside each symbol, its words (U1's re-run: "emoji 的意思要說明"): what it is, then what to do — in the table below, `need.<kind>.what` and `.to`. no-stream-branch is no action: its words are the stream's progress
2572
+ const NEEDS = {
2573
+ 'pr-ci': '🔴',
2574
+ 'pr-review-requested': '👀',
2575
+ 'pr-review-human': '👤',
2576
+ 'pr-reply': '💬',
2577
+ 'pr-approved': '✅',
2578
+ uat: '🧪',
2579
+ 'stream-ready': '🚀',
2580
+ blocked: '⛔',
2581
+ 'sign-off': '📝',
2582
+ 'stale-claim': '⏰',
2583
+ 'legacy-branch': '🔧',
2584
+ 'open-question': '❓',
2585
+ }
2586
+
2587
+ // ── brief's own words (phase 30, the user: "另外也給一個 template 讓使用者決定 output 是要用什麼語言") ──
2588
+ // Every string briefModel, renderMarkdown and renderHtml print of brief's own is a key of this table, `{name}` where the code fills a value in; a language is the table of the same keys. English and 繁體中文 (zh-TW) are
2589
+ // built in; any other is a JSON file the user keeps in ~/.wuwei/brief/lang/<code>.json (`--lang-template` prints one to start from), and a file for en or zh-TW lies over those keys. A key a language lacks is read in
2590
+ // English, key by key. What is not here is not brief's: a repo's text, GitHub's, `--json` — and what `--schedule` prints, and the lines on stderr, which are for the person who runs brief, not for the report.
2591
+ // The table is escaped like any text (T-2): the page puts every string through html(), the markdown through esc, and a slot is filled with a value its own renderer has already made safe — a node (`{ t }` for a repo's
2592
+ // text) rather than a string spliced in — so a translation that carries markup prints as text, and so does a repo text that a translation moves. No built-in value holds a character esc changes (`\ [ ] < > |`), so the markdown
2593
+ // prints English as it always did, and none a ZWJ or U+FE0F (E22 reads this table with the code that prints it); html() changes an `&` and an apostrophe in a few, as it did when they were written in the code
2594
+ const STRINGS = {
2595
+ en: {
2596
+ // the header, and the window it names
2597
+ 'header': '{window} · {repos} · fetched in {secs}s · {stale} stale',
2598
+ 'repos.one': '{n} repo',
2599
+ 'repos.other': '{n} repos',
2600
+ 'window': 'since {at} ({how})',
2601
+ 'how.since': '--since {since}',
2602
+ 'how.mark': 'last mark',
2603
+ 'how.default': 'no mark yet — last 24h',
2604
+ 'how.unreadable': 'mark unreadable — last 24h',
2605
+ // the ⚠ lines: gh, and each repo gh or git could not read for
2606
+ 'warn.ghNotFound': 'GitHub: gh not found — wuwei half only',
2607
+ 'warn.ghFailed': 'GitHub: gh failed ({detail}) — wuwei half only',
2608
+ 'warn.ghNotAuth': 'GitHub: gh not authenticated — run `gh auth login` — wuwei half only',
2609
+ 'warn.ghNotAuthRefused': 'GitHub: gh not authenticated (its login was refused) — run `gh auth login` — wuwei half only',
2610
+ 'warn.ghNotAuthNoToken': 'GitHub: gh not authenticated (gh sent no token: it has no github.com login it can read — a scheduled run may not reach the keyring) — run `gh auth login` — wuwei half only',
2611
+ 'warn.stale': '{repo} · STALE, {age} — {note}',
2612
+ 'age.last': 'last fetched {age} ago',
2613
+ 'age.never': 'never fetched by brief',
2614
+ 'warn.noAccess': 'GitHub: no access ({who})',
2615
+ 'warn.noAccessDetail': 'GitHub: no access ({who}): {detail}',
2616
+ 'warn.runFix': ' — run {fix}',
2617
+ 'warn.runLogin': ' — run {fix} with an account that can read it',
2618
+ 'warn.queryFailed': 'GitHub: query failed ({detail}) — its GitHub mark not moved',
2619
+ // Needs you, in the markdown: a kind's line is `<symbol> <what>` — `<to do> · <n>`
2620
+ 'need.pr-ci.what': 'CI failing',
2621
+ 'need.pr-ci.to': 'fix it',
2622
+ 'need.pr-review-requested.what': 'your review requested',
2623
+ 'need.pr-review-requested.to': 'review it',
2624
+ 'need.pr-review-human.what': 'review: human, PR open',
2625
+ 'need.pr-review-human.to': 'review it yourself',
2626
+ 'need.pr-reply.what': 'threads waiting on your reply',
2627
+ 'need.pr-reply.to': 'reply',
2628
+ 'need.pr-approved.what': 'approved, not merged',
2629
+ 'need.pr-approved.to': 'merge it',
2630
+ 'need.uat.what': 'every phase merged',
2631
+ 'need.uat.to': 'run the Manual UAT',
2632
+ 'need.stream-ready.what': 'every phase merged',
2633
+ 'need.stream-ready.to': 'open the stream PR and its audit',
2634
+ 'need.blocked.what': 'blocked (STATE.md)',
2635
+ 'need.blocked.to': 'clear the blocker',
2636
+ 'need.sign-off.what': 'SPEC not signed off',
2637
+ 'need.sign-off.to': 'review and sign it',
2638
+ 'need.stale-claim.what': 'claim gone quiet',
2639
+ 'need.stale-claim.to': 'check on it',
2640
+ 'need.legacy-branch.what': 'stream branch named before v0.1.0 — its phases are not read',
2641
+ 'need.legacy-branch.to': 'leave it until the stream closes',
2642
+ 'need.open-question.what': 'open questions in SPEC',
2643
+ 'need.open-question.to': 'answer them',
2644
+ // …and on the page: a kind's name, the hint under it, the verb a row starts with, and its words in the state line for one and for several
2645
+ 'kind.pr-ci.name': 'CI failing',
2646
+ 'kind.pr-ci.hint': 'Fix the failing checks',
2647
+ 'kind.pr-ci.verb': 'Fix CI',
2648
+ 'kind.pr-ci.one': 'CI failing',
2649
+ 'kind.pr-ci.many': 'CI failing',
2650
+ 'kind.pr-review-requested.name': 'Review requested',
2651
+ 'kind.pr-review-requested.hint': 'Review the PR',
2652
+ 'kind.pr-review-requested.verb': 'Review',
2653
+ 'kind.pr-review-requested.one': 'review requested',
2654
+ 'kind.pr-review-requested.many': 'reviews requested',
2655
+ 'kind.pr-review-human.name': 'Human review',
2656
+ 'kind.pr-review-human.hint': 'Review the PR yourself',
2657
+ 'kind.pr-review-human.verb': 'Review it yourself',
2658
+ 'kind.pr-review-human.one': 'human review',
2659
+ 'kind.pr-review-human.many': 'human reviews',
2660
+ 'kind.pr-reply.name': 'Replies owed',
2661
+ 'kind.pr-reply.hint': 'Reply to the threads',
2662
+ 'kind.pr-reply.verb': 'Reply',
2663
+ 'kind.pr-reply.one': 'reply owed',
2664
+ 'kind.pr-reply.many': 'replies owed',
2665
+ 'kind.pr-approved.name': 'Ready to merge',
2666
+ 'kind.pr-approved.hint': 'Merge the approved PR',
2667
+ 'kind.pr-approved.verb': 'Merge',
2668
+ 'kind.pr-approved.one': 'ready to merge',
2669
+ 'kind.pr-approved.many': 'ready to merge',
2670
+ 'kind.uat.name': 'Ready for UAT',
2671
+ 'kind.uat.hint': 'Run the Manual UAT',
2672
+ 'kind.uat.verb': 'Run UAT',
2673
+ 'kind.uat.one': 'ready for UAT',
2674
+ 'kind.uat.many': 'ready for UAT',
2675
+ 'kind.stream-ready.name': 'Stream ready',
2676
+ 'kind.stream-ready.hint': 'Open the stream PR and its audit',
2677
+ 'kind.stream-ready.verb': 'Open stream PR',
2678
+ 'kind.stream-ready.one': 'stream ready',
2679
+ 'kind.stream-ready.many': 'streams ready',
2680
+ 'kind.blocked.name': 'Blocked',
2681
+ 'kind.blocked.hint': 'Clear the blocker',
2682
+ 'kind.blocked.verb': 'Unblock',
2683
+ 'kind.blocked.one': 'blocked',
2684
+ 'kind.blocked.many': 'blocked',
2685
+ 'kind.sign-off.name': 'Sign-off needed',
2686
+ 'kind.sign-off.hint': 'Review and sign the SPEC',
2687
+ 'kind.sign-off.verb': 'Sign SPEC',
2688
+ 'kind.sign-off.one': 'SPEC to sign',
2689
+ 'kind.sign-off.many': 'SPECs to sign',
2690
+ 'kind.stale-claim.name': 'Stale claims',
2691
+ 'kind.stale-claim.hint': 'Check on the claim',
2692
+ 'kind.stale-claim.verb': 'Check',
2693
+ 'kind.stale-claim.one': 'stale claim',
2694
+ 'kind.stale-claim.many': 'stale claims',
2695
+ 'kind.legacy-branch.name': 'Legacy branches',
2696
+ 'kind.legacy-branch.hint': 'Leave until the stream closes',
2697
+ 'kind.legacy-branch.verb': '',
2698
+ 'kind.legacy-branch.one': 'legacy branch',
2699
+ 'kind.legacy-branch.many': 'legacy branches',
2700
+ 'kind.open-question.name': 'Open questions',
2701
+ 'kind.open-question.hint': 'Answer questions in the SPEC',
2702
+ 'kind.open-question.verb': 'Answer',
2703
+ 'kind.open-question.one': 'SPEC with open questions',
2704
+ 'kind.open-question.many': 'SPECs with open questions',
2705
+ // a row's own to-do where it is not its kind's: an approved draft, and a stream PR whose audit is owed
2706
+ 'todo.draft.say': 'mark it ready, then merge',
2707
+ 'todo.draft.verb': 'Mark ready',
2708
+ 'todo.draft.then': 'then merge',
2709
+ 'todo.audit.say': 'run its audit',
2710
+ 'todo.audit.verb': 'Run its audit',
2711
+ // what goes in a row's detail
2712
+ 'more.draft': 'draft',
2713
+ 'more.threads.one': '{n} thread',
2714
+ 'more.threads.other': '{n} threads',
2715
+ 'more.streamPr': 'stream PR open',
2716
+ 'claim.quiet': 'claimed by {who}, no commit for {age}',
2717
+ 'claim.age': 'claimed by {who}, claimed {age} ago',
2718
+ 'oq.first': '{n}, first: ',
2719
+ // the sections of the markdown, and the lines under their tables
2720
+ 'title.needs': '🔥 Needs you — {n}',
2721
+ 'title.needsWarn': '🔥 Needs you — {n} · may be incomplete (⚠ above)',
2722
+ 'title.fyi': 'FYI — {n}',
2723
+ 'title.streams': '🧭 Streams',
2724
+ 'title.going': '⏳ In progress',
2725
+ 'title.done': '✓ Done in window',
2726
+ 'title.other': '🔀 Other PRs',
2727
+ 'title.notes': '📎 How each repo was read',
2728
+ 'notes.intro': 'Nothing here needs doing unless it surprises you.',
2729
+ 'legend.human': '👤 review: human',
2730
+ // the tables' heads
2731
+ 'head.repo': 'repo',
2732
+ 'head.stream': 'stream',
2733
+ 'head.detail': 'detail',
2734
+ 'head.progress': 'progress',
2735
+ 'head.today': 'today',
2736
+ 'head.phase': 'phase',
2737
+ 'head.where': 'where',
2738
+ 'head.who': 'who',
2739
+ 'head.session': 'session',
2740
+ 'head.done': 'done',
2741
+ 'head.at': 'at',
2742
+ 'head.pr': 'PR',
2743
+ 'head.merged': 'merged',
2744
+ 'head.how': 'how it was read',
2745
+ // Streams: where a stream stands
2746
+ 'stream.merged': '✓ merged {at}',
2747
+ 'stream.undated': '(undated)',
2748
+ 'stream.error': 'error: {error}',
2749
+ 'stream.old': 'old branch name — {rest}',
2750
+ 'stream.oldState': 'STATE.md: {done}',
2751
+ 'stream.oldNone': 'phases not read',
2752
+ 'stream.noBranch': 'signed off · no stream branch',
2753
+ 'stream.notSigned': 'not signed off',
2754
+ 'stream.noPhases': 'no phases on the stream branch',
2755
+ 'stream.prOpen': 'stream PR {pr} open',
2756
+ 'session.open': 'open session',
2757
+ // How each repo was read: a row a note
2758
+ 'note.ownWindow': 'its own window, since {at} — {why}',
2759
+ 'note.ghWindow': 'its PRs read since {at} — {why}',
2760
+ 'note.noStreams': '.wuwei/ has no streams',
2761
+ 'note.account': "GitHub read as gh account {account}, not gh's active account",
2762
+ 'note.notGithub': 'origin not on github.com, so no PRs read',
2763
+ 'why.own.mark': 'marked at another time than the other repos',
2764
+ 'why.own.markOthersDefault': 'its last mark, where the other repos have no mark yet',
2765
+ 'why.own.markOthersUnreadable': "its last mark, where the other repos' marks could not be read",
2766
+ 'why.own.default': 'no mark yet, so the last 24h',
2767
+ 'why.own.unreadable': 'its mark could not be read, so the last 24h',
2768
+ 'why.gh.mark': "GitHub's own mark, apart from git's since a --mark run read only one of the two",
2769
+ 'why.gh.default': 'no GitHub mark yet, so the last 24h',
2770
+ 'why.gh.unreadable': 'its GitHub mark could not be read, so the last 24h',
2771
+ // …and the notes git's half gives a repo (they are in --json as written; the report says them in these words)
2772
+ 'note.noOrigin': 'no origin remote, so read from the local checkout only: not fetched, and no PRs read',
2773
+ 'note.badRefspec': 'git rejects remote.origin.fetch ({error}) — origin unknown, so read from the local checkout only: not fetched, and no PRs read',
2774
+ 'note.fetchFailed': 'fetch failed: {error}',
2775
+ 'note.fetchFailedCreds': 'fetch failed (needs credentials): {error}',
2776
+ 'note.fetchFailedBare': 'fetch failed',
2777
+ 'note.shallow': 'shallow clone — only the branches its own refspec tracks are fetched, so a stream branch (wuwei/*) may be missing',
2778
+ 'note.noDefault': 'no origin/HEAD, origin/main or origin/master — the streams on the default branch were not read',
2779
+ 'note.phaseUndated': 'stream {stream}: phase {phase} is merged, but when could not be read — left out of done',
2780
+ 'note.notFetched': 'origin/{branch} not fetched in this clone (or gone from origin) — merged state read at {ref}',
2781
+ 'note.mergedUndated': 'stream {stream}: merged, but when could not be read — listed whatever the window',
2782
+ 'note.mergedBefore.one': '{n} stream merged before the window — none listed',
2783
+ 'note.mergedBefore.other': '{n} streams merged before the window — none listed',
2784
+ // the line --mark adds after the report
2785
+ 'mark.marked': 'marked {repos}',
2786
+ 'mark.left': 'left {list}',
2787
+ 'mark.ghLeft': 'GitHub: left {list}',
2788
+ 'mark.item': '{origins} ({why})',
2789
+ 'mark.why.stale': 'stale',
2790
+ 'mark.why.unreached': 'no wuwei/* fetched',
2791
+ 'mark.why.ghNotFound': 'gh not found',
2792
+ 'mark.why.ghNotAuth': 'gh not authenticated',
2793
+ 'mark.why.ghFailed': 'gh failed',
2794
+ 'mark.why.noAccess': 'no access',
2795
+ 'mark.why.failed': 'failed',
2796
+ // the page
2797
+ 'page.skip': 'Skip to content',
2798
+ 'page.nav': 'Page sections',
2799
+ 'page.theme': 'Theme',
2800
+ 'page.theme.system': 'System',
2801
+ 'page.theme.light': 'Light',
2802
+ 'page.theme.dark': 'Dark',
2803
+ 'page.description': 'Your cross-repository work brief. Decisions, stream progress, and work in motion in one calm workspace.',
2804
+ 'page.warnings': 'Warnings',
2805
+ 'page.none': 'Nothing needs you right now',
2806
+ 'page.nothingFound': 'Nothing found — may be incomplete (⚠ below)',
2807
+ 'page.date': '{mon} {d}, {y}',
2808
+ 'strip.need.one': '{n} needs you',
2809
+ 'strip.need.other': '{n} need you',
2810
+ 'strip.streams.one': '{n} stream',
2811
+ 'strip.streams.other': '{n} streams',
2812
+ 'strip.going': '{n} in progress',
2813
+ 'strip.stale': '{n} stale',
2814
+ 'strip.incomplete': 'may be incomplete (⚠ below)',
2815
+ 'nav.needs': 'Needs you',
2816
+ 'nav.streams': 'Streams',
2817
+ 'nav.going': 'In progress',
2818
+ 'nav.sources': 'Sources',
2819
+ 'page.h.needs': 'Needs you',
2820
+ 'page.needs.note': 'Decisions and follow-ups, in one place.',
2821
+ 'page.needs.kicker': 'Your next moves',
2822
+ 'page.needs.kickerWarn': 'May be incomplete (⚠ above)',
2823
+ 'page.needs.empty': 'Nothing needs you right now.',
2824
+ 'page.needs.emptyWarn': 'Nothing found — may be incomplete (⚠ above).',
2825
+ 'page.fyi': 'FYI · {kinds}',
2826
+ 'said': '{n} {kind}',
2827
+ 'said.drafts': '{n} {kind} ({drafts})',
2828
+ 'drafts.one': '{n} draft',
2829
+ 'drafts.other': '{n} drafts',
2830
+ 'page.h.going': 'Work in motion',
2831
+ 'page.going.note': 'Phases currently being worked on.',
2832
+ 'page.going.empty': 'Nothing in progress.',
2833
+ 'page.repos.one': '{n} repository',
2834
+ 'page.repos.other': '{n} repositories',
2835
+ 'page.worker': 'Worker & workspace',
2836
+ 'page.dt.worker': 'Worker',
2837
+ 'page.dt.workspace': 'Workspace',
2838
+ 'page.dt.session': 'Session',
2839
+ 'page.dt.none': 'Not provided',
2840
+ 'page.h.streams': 'Streams',
2841
+ 'page.streams.note': 'A pulse on every project.',
2842
+ 'page.streams.empty': 'No streams found.',
2843
+ 'page.merged': '✓ {merged}/{total} merged',
2844
+ 'page.phasesMerged': 'Phases merged',
2845
+ 'page.progressAria': '{name} phases merged',
2846
+ 'page.canStart': 'Can start: {list}',
2847
+ 'page.h.done': 'Done in window',
2848
+ 'page.done.note': 'Phases and PRs merged in the window.',
2849
+ 'page.h.other': 'Other PRs',
2850
+ 'page.other.note': 'Your own PRs that merged in the window.',
2851
+ 'page.mergedAt': 'merged {at}',
2852
+ 'page.sources.title': 'How each repo was read',
2853
+ 'page.sources.span': 'Source details & account notes',
2854
+ 'page.footer': 'Static snapshot — a quieter view of your work.',
2855
+ 'month.1': 'Jan',
2856
+ 'month.2': 'Feb',
2857
+ 'month.3': 'Mar',
2858
+ 'month.4': 'Apr',
2859
+ 'month.5': 'May',
2860
+ 'month.6': 'Jun',
2861
+ 'month.7': 'Jul',
2862
+ 'month.8': 'Aug',
2863
+ 'month.9': 'Sep',
2864
+ 'month.10': 'Oct',
2865
+ 'month.11': 'Nov',
2866
+ 'month.12': 'Dec',
2867
+ },
2868
+ // 繁體中文(台灣): how a Taiwanese engineer would say it to a teammate — concise, spaces between Han and Latin or digits, and the terms that are kept in English where they are kept in English at work (PR, CI, SPEC,
2869
+ // repo, stream, phase, branch, merge, review, claim, fetch, commit, checkout, draft, session, stale)
2870
+ 'zh-TW': {
2871
+ 'header': '{window} · {repos} · fetch 耗時 {secs}s · {stale} 個 stale',
2872
+ 'repos.one': '{n} 個 repo',
2873
+ 'repos.other': '{n} 個 repo',
2874
+ 'window': '自 {at} 起({how})',
2875
+ 'how.since': '--since {since}',
2876
+ 'how.mark': '上次標記',
2877
+ 'how.default': '尚無標記,取最近 24h',
2878
+ 'how.unreadable': '標記讀不到,取最近 24h',
2879
+ 'warn.ghNotFound': 'GitHub:找不到 gh — 只有 wuwei 這一半',
2880
+ 'warn.ghFailed': 'GitHub:gh 執行失敗({detail})— 只有 wuwei 這一半',
2881
+ 'warn.ghNotAuth': 'GitHub:gh 未登入 — 請執行 `gh auth login` — 只有 wuwei 這一半',
2882
+ 'warn.ghNotAuthRefused': 'GitHub:gh 未登入(登入被拒絕)— 請執行 `gh auth login` — 只有 wuwei 這一半',
2883
+ 'warn.ghNotAuthNoToken': 'GitHub:gh 未登入(gh 沒有送出 token:它沒有可讀的 github.com 登入,排程執行可能讀不到 keyring)— 請執行 `gh auth login` — 只有 wuwei 這一半',
2884
+ 'warn.stale': '{repo} · STALE,{age} — {note}',
2885
+ 'age.last': '上次 fetch 在 {age} 前',
2886
+ 'age.never': 'brief 從沒 fetch 過',
2887
+ 'warn.noAccess': 'GitHub:沒有存取權({who})',
2888
+ 'warn.noAccessDetail': 'GitHub:沒有存取權({who}):{detail}',
2889
+ 'warn.runFix': ' — 請執行 {fix}',
2890
+ 'warn.runLogin': ' — 請用讀得到它的帳號執行 {fix}',
2891
+ 'warn.queryFailed': 'GitHub:查詢失敗({detail})— 它的 GitHub 標記沒有移動',
2892
+ 'need.pr-ci.what': 'CI 失敗',
2893
+ 'need.pr-ci.to': '去修',
2894
+ 'need.pr-review-requested.what': '被要求 review',
2895
+ 'need.pr-review-requested.to': '去 review',
2896
+ 'need.pr-review-human.what': 'review: human,PR 已開',
2897
+ 'need.pr-review-human.to': '自己 review',
2898
+ 'need.pr-reply.what': '有討論串等你回覆',
2899
+ 'need.pr-reply.to': '去回覆',
2900
+ 'need.pr-approved.what': '已核准,還沒 merge',
2901
+ 'need.pr-approved.to': '去 merge',
2902
+ 'need.uat.what': '所有 phase 都 merge 了',
2903
+ 'need.uat.to': '跑 Manual UAT',
2904
+ 'need.stream-ready.what': '所有 phase 都 merge 了',
2905
+ 'need.stream-ready.to': '開 stream PR 並跑 audit',
2906
+ 'need.blocked.what': '卡住了(STATE.md)',
2907
+ 'need.blocked.to': '排除阻礙',
2908
+ 'need.sign-off.what': 'SPEC 還沒簽核',
2909
+ 'need.sign-off.to': '看過後簽核',
2910
+ 'need.stale-claim.what': 'claim 很久沒動靜',
2911
+ 'need.stale-claim.to': '去確認',
2912
+ 'need.legacy-branch.what': 'v0.1.0 之前命名的 stream branch — 不讀它的 phase',
2913
+ 'need.legacy-branch.to': '等 stream 結束再處理',
2914
+ 'need.open-question.what': 'SPEC 有待回答的問題',
2915
+ 'need.open-question.to': '回答它們',
2916
+ 'kind.pr-ci.name': 'CI 失敗',
2917
+ 'kind.pr-ci.hint': '修好沒過的 checks',
2918
+ 'kind.pr-ci.verb': '修 CI',
2919
+ 'kind.pr-ci.one': 'CI 失敗',
2920
+ 'kind.pr-ci.many': 'CI 失敗',
2921
+ 'kind.pr-review-requested.name': '被要求 review',
2922
+ 'kind.pr-review-requested.hint': 'Review 這個 PR',
2923
+ 'kind.pr-review-requested.verb': 'Review',
2924
+ 'kind.pr-review-requested.one': '被要求 review',
2925
+ 'kind.pr-review-requested.many': '被要求 review',
2926
+ 'kind.pr-review-human.name': '需人工 review',
2927
+ 'kind.pr-review-human.hint': '自己 review 這個 PR',
2928
+ 'kind.pr-review-human.verb': '自己 review',
2929
+ 'kind.pr-review-human.one': '需人工 review',
2930
+ 'kind.pr-review-human.many': '需人工 review',
2931
+ 'kind.pr-reply.name': '待回覆',
2932
+ 'kind.pr-reply.hint': '回覆這些討論串',
2933
+ 'kind.pr-reply.verb': '回覆',
2934
+ 'kind.pr-reply.one': '待回覆',
2935
+ 'kind.pr-reply.many': '待回覆',
2936
+ 'kind.pr-approved.name': '可以 merge',
2937
+ 'kind.pr-approved.hint': 'Merge 已核准的 PR',
2938
+ 'kind.pr-approved.verb': 'Merge',
2939
+ 'kind.pr-approved.one': '可 merge',
2940
+ 'kind.pr-approved.many': '可 merge',
2941
+ 'kind.uat.name': '可以跑 UAT',
2942
+ 'kind.uat.hint': '跑 Manual UAT',
2943
+ 'kind.uat.verb': '跑 UAT',
2944
+ 'kind.uat.one': '可跑 UAT',
2945
+ 'kind.uat.many': '可跑 UAT',
2946
+ 'kind.stream-ready.name': 'Stream 完成',
2947
+ 'kind.stream-ready.hint': '開 stream PR 並跑 audit',
2948
+ 'kind.stream-ready.verb': '開 stream PR',
2949
+ 'kind.stream-ready.one': 'stream 完成',
2950
+ 'kind.stream-ready.many': 'stream 完成',
2951
+ 'kind.blocked.name': '卡住',
2952
+ 'kind.blocked.hint': '排除阻礙',
2953
+ 'kind.blocked.verb': '排除',
2954
+ 'kind.blocked.one': '卡住',
2955
+ 'kind.blocked.many': '卡住',
2956
+ 'kind.sign-off.name': '待簽核',
2957
+ 'kind.sign-off.hint': '看過 SPEC 並簽核',
2958
+ 'kind.sign-off.verb': '簽核 SPEC',
2959
+ 'kind.sign-off.one': 'SPEC 待簽核',
2960
+ 'kind.sign-off.many': 'SPEC 待簽核',
2961
+ 'kind.stale-claim.name': 'Claim 久沒動靜',
2962
+ 'kind.stale-claim.hint': '確認這個 claim 還有沒有人在做',
2963
+ 'kind.stale-claim.verb': '確認',
2964
+ 'kind.stale-claim.one': 'claim 久沒動靜',
2965
+ 'kind.stale-claim.many': 'claim 久沒動靜',
2966
+ 'kind.legacy-branch.name': '舊命名的 branch',
2967
+ 'kind.legacy-branch.hint': '等 stream 結束再處理',
2968
+ 'kind.legacy-branch.verb': '',
2969
+ 'kind.legacy-branch.one': '舊命名的 branch',
2970
+ 'kind.legacy-branch.many': '舊命名的 branch',
2971
+ 'kind.open-question.name': '待回答的問題',
2972
+ 'kind.open-question.hint': '回答 SPEC 裡的問題',
2973
+ 'kind.open-question.verb': '回答',
2974
+ 'kind.open-question.one': 'SPEC 有待回答的問題',
2975
+ 'kind.open-question.many': 'SPEC 有待回答的問題',
2976
+ 'todo.draft.say': '先標成 ready,再 merge',
2977
+ 'todo.draft.verb': '標成 ready',
2978
+ 'todo.draft.then': '再 merge',
2979
+ 'todo.audit.say': '跑 audit',
2980
+ 'todo.audit.verb': '跑 audit',
2981
+ 'more.draft': 'draft',
2982
+ 'more.threads.one': '{n} 個討論串',
2983
+ 'more.threads.other': '{n} 個討論串',
2984
+ 'more.streamPr': 'stream PR 已開',
2985
+ 'claim.quiet': '由 {who} claim,已 {age} 沒有 commit',
2986
+ 'claim.age': '由 {who} claim,{age} 前',
2987
+ 'oq.first': '{n} 個,第一個:',
2988
+ 'title.needs': '🔥 等你處理 — {n}',
2989
+ 'title.needsWarn': '🔥 等你處理 — {n} · 可能不完整(見上方 ⚠)',
2990
+ 'title.fyi': 'FYI — {n}',
2991
+ 'title.streams': '🧭 Streams',
2992
+ 'title.going': '⏳ 進行中',
2993
+ 'title.done': '✓ 期間內完成',
2994
+ 'title.other': '🔀 其他 PR',
2995
+ 'title.notes': '📎 各 repo 的讀取方式',
2996
+ 'notes.intro': '這裡的內容不需要處理,除非有哪裡讓你意外。',
2997
+ 'legend.human': '👤 review: human,要自己 review',
2998
+ 'head.repo': 'repo',
2999
+ 'head.stream': 'stream',
3000
+ 'head.detail': '細節',
3001
+ 'head.progress': '進度',
3002
+ 'head.today': '可開始',
3003
+ 'head.phase': 'phase',
3004
+ 'head.where': '位置',
3005
+ 'head.who': '誰',
3006
+ 'head.session': 'session',
3007
+ 'head.done': '完成',
3008
+ 'head.at': '時間',
3009
+ 'head.pr': 'PR',
3010
+ 'head.merged': 'merge 時間',
3011
+ 'head.how': '讀取方式',
3012
+ 'stream.merged': '✓ 已 merge {at}',
3013
+ 'stream.undated': '(時間不詳)',
3014
+ 'stream.error': '錯誤:{error}',
3015
+ 'stream.old': '舊命名的 branch — {rest}',
3016
+ 'stream.oldState': 'STATE.md:{done}',
3017
+ 'stream.oldNone': '不讀 phase',
3018
+ 'stream.noBranch': '已簽核 · 還沒有 stream branch',
3019
+ 'stream.notSigned': '未簽核',
3020
+ 'stream.noPhases': 'stream branch 上沒有 phase',
3021
+ 'stream.prOpen': 'stream PR {pr} 已開',
3022
+ 'session.open': '開啟 session',
3023
+ 'note.ownWindow': '自己的區間,自 {at} 起 — {why}',
3024
+ 'note.ghWindow': 'PR 是自 {at} 起讀的 — {why}',
3025
+ 'note.noStreams': '.wuwei/ 裡沒有 stream',
3026
+ 'note.account': 'GitHub 用 gh 帳號 {account} 讀取,不是 gh 目前使用的帳號',
3027
+ 'note.notGithub': 'origin 不在 github.com,所以沒讀 PR',
3028
+ 'why.own.mark': '標記的時間和其他 repo 不同',
3029
+ 'why.own.markOthersDefault': '用它自己上次的標記,其他 repo 還沒有標記',
3030
+ 'why.own.markOthersUnreadable': '用它自己上次的標記,其他 repo 的標記讀不到',
3031
+ 'why.own.default': '還沒有標記,所以取最近 24h',
3032
+ 'why.own.unreadable': '標記讀不到,所以取最近 24h',
3033
+ 'why.gh.mark': 'GitHub 自己的標記,因為有一次 --mark 只讀到 git 和 GitHub 其中一邊,兩邊的標記就分開了',
3034
+ 'why.gh.default': 'GitHub 還沒有標記,所以取最近 24h',
3035
+ 'why.gh.unreadable': 'GitHub 的標記讀不到,所以取最近 24h',
3036
+ 'note.noOrigin': '沒有 origin remote,只讀本機的 checkout:沒有 fetch,也沒讀 PR',
3037
+ 'note.badRefspec': 'git 不接受 remote.origin.fetch({error})— 不知道 origin 是哪個,所以只讀本機的 checkout:沒有 fetch,也沒讀 PR',
3038
+ 'note.fetchFailed': 'fetch 失敗:{error}',
3039
+ 'note.fetchFailedCreds': 'fetch 失敗(需要認證):{error}',
3040
+ 'note.fetchFailedBare': 'fetch 失敗',
3041
+ 'note.shallow': 'shallow clone — 只會 fetch 它自己的 refspec 追蹤的 branch,所以 stream branch(wuwei/*)可能不在',
3042
+ 'note.noDefault': '找不到 origin/HEAD、origin/main 或 origin/master — 沒讀 default branch 上的 stream',
3043
+ 'note.phaseUndated': 'stream {stream}:phase {phase} 已 merge,但讀不到時間 — 不列入完成',
3044
+ 'note.notFetched': 'origin/{branch} 沒有 fetch 到這個 clone(或 origin 已經沒有它)— merge 狀態改讀 {ref}',
3045
+ 'note.mergedUndated': 'stream {stream}:已 merge,但讀不到時間 — 不論區間一律列出',
3046
+ 'note.mergedBefore.one': '區間之前已 merge 了 {n} 個 stream — 沒有列出',
3047
+ 'note.mergedBefore.other': '區間之前已 merge 了 {n} 個 stream — 沒有列出',
3048
+ 'mark.marked': '已標記 {repos}',
3049
+ 'mark.left': '略過 {list}',
3050
+ 'mark.ghLeft': 'GitHub:略過 {list}',
3051
+ 'mark.item': '{origins}({why})',
3052
+ 'mark.why.stale': 'stale',
3053
+ 'mark.why.unreached': '沒 fetch 到 wuwei/*',
3054
+ 'mark.why.ghNotFound': '找不到 gh',
3055
+ 'mark.why.ghNotAuth': 'gh 未登入',
3056
+ 'mark.why.ghFailed': 'gh 失敗',
3057
+ 'mark.why.noAccess': '沒有存取權',
3058
+ 'mark.why.failed': '失敗',
3059
+ 'page.skip': '跳到內容',
3060
+ 'page.nav': '頁面章節',
3061
+ 'page.theme': '主題',
3062
+ 'page.theme.system': '跟隨系統',
3063
+ 'page.theme.light': '淺色',
3064
+ 'page.theme.dark': '深色',
3065
+ 'page.description': '你跨 repo 的工作簡報:要決定的事、stream 進度、進行中的工作,都在這一頁。',
3066
+ 'page.warnings': '警告',
3067
+ 'page.none': '目前沒有事情等你',
3068
+ 'page.nothingFound': '什麼都沒找到 — 可能不完整(見下方 ⚠)',
3069
+ 'page.date': '{y} 年 {mon} {d} 日',
3070
+ 'strip.need.one': '{n} 件等你處理',
3071
+ 'strip.need.other': '{n} 件等你處理',
3072
+ 'strip.streams.one': '{n} 個 stream',
3073
+ 'strip.streams.other': '{n} 個 stream',
3074
+ 'strip.going': '{n} 個進行中',
3075
+ 'strip.stale': '{n} 個 stale',
3076
+ 'strip.incomplete': '可能不完整(見下方 ⚠)',
3077
+ 'nav.needs': '等你處理',
3078
+ 'nav.streams': 'Streams',
3079
+ 'nav.going': '進行中',
3080
+ 'nav.sources': '資料來源',
3081
+ 'page.h.needs': '等你處理',
3082
+ 'page.needs.note': '需要你決定、跟進的事,都在這裡。',
3083
+ 'page.needs.kicker': '下一步',
3084
+ 'page.needs.kickerWarn': '可能不完整(見上方 ⚠)',
3085
+ 'page.needs.empty': '目前沒有事情等你。',
3086
+ 'page.needs.emptyWarn': '什麼都沒找到 — 可能不完整(見上方 ⚠)。',
3087
+ 'page.fyi': 'FYI · {kinds}',
3088
+ 'said': '{kind} {n}',
3089
+ 'said.drafts': '{kind} {n}({drafts})',
3090
+ 'drafts.one': '{n} 個 draft',
3091
+ 'drafts.other': '{n} 個 draft',
3092
+ 'page.h.going': '進行中的工作',
3093
+ 'page.going.note': '目前正在做的 phase。',
3094
+ 'page.going.empty': '目前沒有進行中的工作。',
3095
+ 'page.repos.one': '{n} 個 repo',
3096
+ 'page.repos.other': '{n} 個 repo',
3097
+ 'page.worker': '誰在做、在哪做',
3098
+ 'page.dt.worker': '誰',
3099
+ 'page.dt.workspace': '位置',
3100
+ 'page.dt.session': 'Session',
3101
+ 'page.dt.none': '未提供',
3102
+ 'page.h.streams': 'Streams',
3103
+ 'page.streams.note': '每個專案的進度,一眼看完。',
3104
+ 'page.streams.empty': '沒有找到 stream。',
3105
+ 'page.merged': '✓ {merged}/{total} 已 merge',
3106
+ 'page.phasesMerged': '已 merge 的 phase',
3107
+ 'page.progressAria': '{name} 已 merge 的 phase',
3108
+ 'page.canStart': '可以開始:{list}',
3109
+ 'page.h.done': '期間內完成',
3110
+ 'page.done.note': '這段期間 merge 的 phase 與 PR。',
3111
+ 'page.h.other': '其他 PR',
3112
+ 'page.other.note': '這段期間 merge 的、你自己的 PR。',
3113
+ 'page.mergedAt': 'merge 於 {at}',
3114
+ 'page.sources.title': '各 repo 的讀取方式',
3115
+ 'page.sources.span': '來源細節與帳號說明',
3116
+ 'page.footer': '靜態快照 — 安靜地看一眼你的工作。',
3117
+ 'month.1': '1 月',
3118
+ 'month.2': '2 月',
3119
+ 'month.3': '3 月',
3120
+ 'month.4': '4 月',
3121
+ 'month.5': '5 月',
3122
+ 'month.6': '6 月',
3123
+ 'month.7': '7 月',
3124
+ 'month.8': '8 月',
3125
+ 'month.9': '9 月',
3126
+ 'month.10': '10 月',
3127
+ 'month.11': '11 月',
3128
+ 'month.12': '12 月',
3129
+ },
3130
+ }
3131
+ // T(key, slots) is a key's words with the slots filled: a string when every value is a string or a number, else a list of nodes (briefModel's: the template's words as strings, a slot's value as the node it is — `{ t }` for a
3132
+ // repo's text), which the renderers escape as they do every node. `fmt` is for the one caller that wants a string and escapes the template's words in it, never the values (cmdBrief's mark line, printed raw by the markdown
3133
+ // as it always was). A key a language lacks is read in English; a key no table has is brief's own mistake and throws
3134
+ const mkT = (words = STRINGS.en, fmt = x => x) => (key, slots = {}) => {
3135
+ const text = words[key] ?? STRINGS.en[key]
3136
+ if (typeof text !== 'string') throw new Error(`brief has no string '${key}'`)
3137
+ const pieces = text.split(/\{(\w+)\}/).map((p, i) => i % 2 ? { v: slots[p] ?? '' } : { s: p })
3138
+ if (pieces.every(x => x.s !== undefined || typeof x.v === 'string' || typeof x.v === 'number')) return pieces.map(x => x.s !== undefined ? fmt(x.s) : String(x.v)).join('')
3139
+ return pieces.map(x => x.s ?? x.v).filter(x => x !== '')
3140
+ }
3141
+ // …a number and its noun, in the language's plural forms: `<key>.one` for 1, `<key>.other` for the rest
3142
+ const plural = (T, key, n) => T(`${key}.${n === 1 ? 'one' : 'other'}`, { n })
3143
+ // the words of a kind on the page — its name, hint, verb, and its words in the state line for one (`one`) and for several (`many`); a kind the table lacks says its own name, no hint
3144
+ const kindWord = (T, k, part) => Object.hasOwn(HTML_KIND, k) ? T(`kind.${k}.${part}`) : part === 'hint' ? '' : k
3145
+ // collectRepo's notes are English, and --json keeps them as written (they quote git); the report says them in the language's words. Each shape is the note's exact form, the key of the table it is said by and the slots it
3146
+ // fills from the note's groups — a repo's or git's text, so each a `{ t }` node (the count is brief's)
3147
+ const NOTE_SHAPES = [
3148
+ [/^no origin remote, so read from the local checkout only: not fetched, and no PRs read$/, 'note.noOrigin'],
3149
+ [/^git rejects remote\.origin\.fetch \(([\s\S]*)\) — origin unknown, so read from the local checkout only: not fetched, and no PRs read$/, 'note.badRefspec', 'error'],
3150
+ [/^fetch failed \(needs credentials\): ([\s\S]*)$/, 'note.fetchFailedCreds', 'error'],
3151
+ [/^fetch failed: ([\s\S]*)$/, 'note.fetchFailed', 'error'],
3152
+ [/^shallow clone — only the branches its own refspec tracks are fetched, so a stream branch \(wuwei\/\*\) may be missing$/, 'note.shallow'],
3153
+ [/^no origin\/HEAD, origin\/main or origin\/master — the streams on the default branch were not read$/, 'note.noDefault'],
3154
+ [/^stream ([\s\S]*?): phase ([\s\S]*) is merged, but when could not be read — left out of done$/, 'note.phaseUndated', 'stream', 'phase'],
3155
+ [/^origin\/([\s\S]*?) not fetched in this clone \(or gone from origin\) — merged state read at ([\s\S]*)$/, 'note.notFetched', 'branch', 'ref'],
3156
+ [/^stream ([\s\S]*): merged, but when could not be read — listed whatever the window$/, 'note.mergedUndated', 'stream'],
3157
+ [/^(\d+) stream merged before the window — none listed$/, 'note.mergedBefore.one', 'n'],
3158
+ [/^(\d+) streams merged before the window — none listed$/, 'note.mergedBefore.other', 'n'],
3159
+ ]
3160
+ const noteShape = n => NOTE_SHAPES.find(([re]) => re.test(n))
3161
+
3162
+ // ── choosing the language (phase 30) ──
3163
+ // `--lang <code>`, else `lang` in ~/.wuwei/brief/config.json, else en. A code is two lowercase letters and, after a -, a region or a script of 2–4 letters (zh-TW, pt-BR, sr-Latn), and it names a file: nothing else does,
3164
+ // so nothing of the command line or of a config reaches a path but those characters. Built in: en and zh-TW. Any code may have ~/.wuwei/brief/lang/<code>.json — JSON, an object, 256 KB at most, whose string values
3165
+ // replace the table's for the keys it holds (`_note`, which `--lang-template` writes, is not one); for a code brief holds, it lies over the built-in table. What it cannot use is left out and said once on stderr: a key brief
3166
+ // does not hold, a value that is no string or is over 2000 characters, a slot the key has no use for — and that key is read in English. A control or format character in a value is a space (plainLang(): plain() but for ZWNJ and ZWJ): the file is the user's own
3167
+ // text and it is printed to their terminal. A file that cannot be had at all is an error when `--lang` asked for it; when config.json named it, a warning and the English report — a routine is not stopped by a file
3168
+ const LANG_CODE = /^[a-z]{2}(?:-[A-Za-z]{2,4})?$/
3169
+ const LANG_MAX = 256 * 1024
3170
+ const langFile = code => join(wuweiHome(), 'brief', 'lang', `${code}.json`)
3171
+ const configFile = () => join(wuweiHome(), 'brief', 'config.json')
3172
+ const slotsOf = t => [...String(t).matchAll(/\{(\w+)\}/g)].map(m => m[1])
3173
+ // a JSON object of the user's, 256 KB at most: throws what is wrong with it (an fs error as it is, brief's own saying `is …`). JSON.parse's message quotes the start of the file, which is the user's bytes, an escape sequence
3174
+ // among them: whoever prints the message passes it through plain()
3175
+ function readUserJson(file) {
3176
+ const st = statSync(file)
3177
+ if (!st.isFile()) throw new Error(`${file} is not a file`)
3178
+ if (st.size > LANG_MAX) throw new Error(`${file} is ${st.size} bytes — brief reads ${LANG_MAX} at most`)
3179
+ let j
3180
+ try { j = JSON.parse(readFileSync(file, 'utf8')) } catch (e) { throw new Error(`${file} is not JSON (${e.message})`) }
3181
+ if (!isPlain(j)) throw new Error(`${file} is not a JSON object`)
3182
+ return j
3183
+ }
3184
+ // a language's value is plain() but for the two joiners, U+200C (ZWNJ) and U+200D (ZWJ): Persian and Urdu spell with the first, and the Indic scripts, Sinhala and Khmer with both, and plain() — made for a repo's text, where a joiner
3185
+ // is a way to hide one — would make each a space. Every other control, format and separator character (a bidi override, a zero-width space, an escape) is still a space: a joiner moves nothing on a terminal and is not markup
3186
+ const plainLang = v => v.replace(/(?![\u200c\u200d])[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ' ')
3187
+ const absent = e => e.code === 'ENOENT' || e.code === 'ENOTDIR'
3188
+ // a file of 0 bytes: what `wuwei brief --lang-template ja > ~/.wuwei/brief/lang/ja.json` finds, since the shell makes the file before the command runs
3189
+ const emptyFile = file => { try { const st = statSync(file); return st.isFile() && st.size === 0 } catch { return false } }
3190
+ // → { code, words, left, started }: the language `code` names — its built-in table (English where there is none) under the user's file — and what the file could not give; throws why a language cannot be had.
3191
+ // `template` is `--lang-template`'s, which has a language to offer where a report has none (phase 31): a code with no built-in table and no file — or a file of 0 bytes — is English, `started`: the words to begin
3192
+ // a translation from. A report still throws (`--lang ja` with no ja.json is no language), and so does a file that is there and cannot be read, for the template too
3193
+ function loadLang(code, { template = false } = {}) {
3194
+ if (!LANG_CODE.test(code)) throw new Error(`'${plain(code)}' is no language code`) // every caller has checked; a code that names a file is checked once more where the file is named
3195
+ const name = Object.keys(STRINGS).find(c => c.toLowerCase() === code.toLowerCase()) ?? code, file = langFile(name)
3196
+ let user = {}, noFile = false
3197
+ if (template && emptyFile(file)) noFile = true
3198
+ else try { user = readUserJson(file) } catch (e) { if (!absent(e)) throw e; noFile = true }
3199
+ const started = noFile && !STRINGS[name]
3200
+ if (started && !template) throw new Error(`no language '${name}': it is not built in (${Object.keys(STRINGS).join(', ')}) and ${file} is not there — \`wuwei brief --lang-template\` prints a table to translate into it`)
3201
+ const words = { ...STRINGS.en, ...STRINGS[name] }, left = []
3202
+ // a key that is not brief's is the file's own text, and a file may be 256 KB of one: every key is named cut to 80 characters (the keys brief holds are shorter, and whole)
3203
+ const named = k => { const c = [...k]; return c.length > 80 ? `${c.slice(0, 79).join('')}…` : k }
3204
+ for (const [k, v] of Object.entries(user)) {
3205
+ const own = Object.hasOwn(STRINGS.en, k) ? slotsOf(STRINGS.en[k]) : []
3206
+ if (k === '_note') continue
3207
+ if (!Object.hasOwn(STRINGS.en, k)) left.push(`${named(k)} (not a key brief holds)`)
3208
+ else if (typeof v !== 'string') left.push(`${named(k)} (not a string)`)
3209
+ else if (v.length > 2000) left.push(`${named(k)} (over 2000 characters)`)
3210
+ else if (slotsOf(v).some(x => !own.includes(x))) left.push(`${named(k)} (${slotsOf(v).filter(x => !own.includes(x)).map(x => `{${x}}`).join(' ')} is no slot of that key)`)
3211
+ else words[k] = plainLang(v)
3212
+ }
3213
+ return { code: name, words, started, left: left.slice(0, 12).map(plain).concat(left.length > 12 ? [`and ${left.length - 12} more`] : []) }
3214
+ }
3215
+ // config.json: → { lang } (a code), {} (none asked for), or { warn } (the file is there and cannot be used)
3216
+ function readConfig() {
3217
+ const file = configFile()
3218
+ let j
3219
+ try { j = readUserJson(file) } catch (e) { return absent(e) ? {} : { warn: plain(e.message) } }
3220
+ if (j.lang === undefined) return {}
3221
+ return typeof j.lang === 'string' && LANG_CODE.test(j.lang) ? { lang: j.lang } : { warn: `${file} holds a lang that is no language code (${plain(typeof j.lang === 'string' ? JSON.stringify(j.lang.slice(0, 40)) : typeof j.lang)})` }
3222
+ }
3223
+ // what the routines call the language in a prompt (T-2: the prompt is instructions to an agent, so not a word of it is the user's): a built-in language by brief's own name for it, any other by the English name ICU gives
3224
+ // its code (Intl.DisplayNames: `fr` is French) — the code is checked by LANG_CODE, letters and a hyphen — and by the code itself where ICU does not know it, or there is no Intl.DisplayNames. A language's own name for itself is not
3225
+ // in the table, and what a file says it is — it may be a sentence — is not read; nor is a file that holds no word of its own called English
3226
+ const LANG_NAME = { en: 'English', 'zh-TW': 'Traditional Chinese (繁體中文)' }
3227
+ const langName = l => {
3228
+ if (Object.hasOwn(LANG_NAME, l.code)) return LANG_NAME[l.code]
3229
+ try { const n = new Intl.DisplayNames(['en'], { type: 'language' }).of(l.code); return typeof n === 'string' && n && n.toLowerCase() !== l.code.toLowerCase() ? n : l.code } catch { return l.code }
3230
+ }
3231
+ // `--lang-template`: the table of a language as JSON, every key with the words in force for it, and a `_note` that says what the file is and names each slot
3232
+ const SLOT_NOTE = {
3233
+ account: "a gh account's login — not translated",
3234
+ age: 'an age such as 5s, 12m, 3h or 2d',
3235
+ at: 'a date and time as MM-DD HH:MM',
3236
+ branch: "a branch's name — not translated",
3237
+ d: 'the day of the month, a number',
3238
+ detail: 'what gh or GitHub said — not translated',
3239
+ done: "a stream's done cell in STATE.md — not translated",
3240
+ drafts: 'the number of draft PRs with its noun, from drafts.one / drafts.other',
3241
+ error: 'what git or gh said — not translated',
3242
+ fix: 'a command to run — not translated',
3243
+ how: 'where a window came from, from the how.* keys',
3244
+ kind: 'a kind of item, from its kind.<kind>.one / .many',
3245
+ kinds: 'the kinds in the FYI summary, each as said / said.drafts makes it',
3246
+ list: 'a list of repos, phases or reasons',
3247
+ merged: 'the number of phases of the stream that are merged',
3248
+ mon: "the month's name, from month.1 … month.12",
3249
+ n: 'a number',
3250
+ name: "a stream's name — not translated",
3251
+ note: "a note on a repo, in the words of the note.* keys",
3252
+ origins: "the origins of some repos — not translated",
3253
+ phase: "a phase's name — not translated",
3254
+ pr: 'a PR number, a link — not translated',
3255
+ ref: 'a branch ref — not translated',
3256
+ repo: "a repo's name — not translated",
3257
+ repos: 'a number of repos with its noun, from repos.one / repos.other',
3258
+ rest: 'the end of the sentence, from the stream.old* keys',
3259
+ secs: 'seconds, with one decimal',
3260
+ since: 'the value given to --since — not translated',
3261
+ stale: 'a number of stale repos',
3262
+ stream: "a stream's name — not translated",
3263
+ total: 'a number of phases',
3264
+ who: 'a login, a name, or a list of them — not translated',
3265
+ why: 'a reason, from the why.* or mark.why.* keys',
3266
+ window: 'the window the report covers, from the window key',
3267
+ y: 'the year',
3268
+ }
3269
+ // where the file goes and how it is used: a code of its own (a language brief does not hold yet, which `--lang-template <code>` starts from English) is named, any other is the generic one
3270
+ const langSave = code => code === undefined
3271
+ ? 'Save the file as ~/.wuwei/brief/lang/<code>.json (the code is like en or zh-TW) and run `wuwei brief --lang <code>`, or put {"lang": "<code>"} in ~/.wuwei/brief/config.json to make it the default.'
3272
+ : `These are the English words to start from: brief holds no '${code}' yet. Save the file as ~/.wuwei/brief/lang/${code}.json and run \`wuwei brief --lang ${code}\`, or put {"lang": "${code}"} in ~/.wuwei/brief/config.json to make it the default.`
3273
+ const langAbout = code => `Translate the values, not the keys. ${langSave(code)} A {slot} is filled in by brief: keep it, in the place your language wants it. A key you leave out is read in English; a key brief does not hold, a value that is not a string and a slot a key does not have are left out, and said on stderr. What you write is shown as text, never as markup. Names of repos, streams, phases and branches, and what git, gh and GitHub say, are not translated.`
3274
+ const langTemplate = l => JSON.stringify({ _note: { about: langAbout(l.started ? l.code : undefined), slots: Object.fromEntries([...new Set(Object.values(STRINGS.en).flatMap(slotsOf))].sort().map(x => [x, SLOT_NOTE[x] ?? ''])) },
3275
+ ...Object.fromEntries(Object.keys(STRINGS.en).map(k => [k, l.words[k]])) }, null, 2)
3276
+ const FYI = new Set(['legacy-branch'])
3277
+ const esc = t => String(t).replace(/[\\[\]<>|]/g, '\\$&')
3278
+ // …and text that can START a line's content — a repo's name led a Needs you, a Notes and an Other PRs bullet, and leads their cells now — cannot open a block there either: a `# x` would be a heading inside
3279
+ // the item, `---` a rule, `> x` a quote, ``` a fence, `1. x` a list of its own (T-2). A table cell opens no block in GFM; it is escaped all the same, for a viewer that takes no table. So, after esc, its first non-space character is backslash-escaped when it
3280
+ // can open a block — `# > + - * = _`, a backtick, `~` — as is the `.` or `)` after leading digits: CommonMark's escape for each, which prints the character. Only
3281
+ // those: any other punctuation opens nothing, and an escape before it is a `\` a reader sees (`\/home/…`, `\.github`). `[ < |` and a leading `\` are esc's
3282
+ const lead = t => t.replace(/^(\s*)(\d{1,9}(?=[.)])|(?=[#>+*=_`~-]))(.)/, '$1$2\\$3')
3283
+ const urlPart = t => encodeURIComponent(t).replace(/[!'()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`) // a ( or ) would end a markdown link's URL
3284
+ // ten cells: full only when every phase merged, empty only when none has — 1 of 30 still shows one
3285
+ const bar = (d, t) => { const f = d === 0 ? 0 : d === t ? 10 : Math.min(9, Math.max(1, Math.round(10 * d / t))); return `${'▰'.repeat(f)}${'▱'.repeat(10 - f)} ${d}/${t}` }
3286
+ // a cell cut to `n` columns, `…` last (phase 15: on a terminal the report fits its width). The visible text is measured by `columns` as it grows, so a wide
3287
+ // character that would cross the edge is left out whole (the cell a column short); a backslash escape is never parted from what it escapes (`\|` cut in two
3288
+ // would leave a `\` before the cell's own `|`); an OSC 8 link open at the cut is closed after what is left of its words — the link kept on them — and one
3289
+ // that would open on nothing is not opened. A space the cut leaves last goes
3290
+ function cutCell(c, n) {
3291
+ if (columns(c) <= n) return c
3292
+ let out = '', seen = '', open = false, next = ''
3293
+ for (const t of c.match(/\x1b\]8;;[^\x1b]*\x1b\\|\\[\s\S]|[\s\S]/gu)) {
3294
+ if (t.startsWith('\x1b')) { if (t !== '\x1b]8;;\x1b\\') next = t; else if (next) next = ''; else { out += t; open = false } continue }
3295
+ if (columns(seen + t) > n - 1) break
3296
+ if (next) { out += next; next = ''; open = true }
3297
+ out += t; seen += t
3298
+ }
3299
+ return `${out.replace(/ +$/, '')}${open ? '\x1b]8;;\x1b\\' : ''}…`
3300
+ }
3301
+ // …and a path, cut from its start, `…` first: the end of a worktree's path is what tells it apart (Orca's workspace, the directory named for the
3302
+ // branch). An escape is kept whole here too; a path cell holds no link
3303
+ function cutPath(c, n) {
3304
+ if (columns(c) <= n) return c
3305
+ const t = c.match(/\\[\s\S]|[\s\S]/gu)
3306
+ let k = t.length
3307
+ while (k > 0 && columns(t.slice(k - 1).join('')) <= n - 1) k--
3308
+ return `…${t.slice(k).join('')}`
3309
+ }
3310
+ // below this many columns brief stops narrowing: a terminal narrower gets the report as it would be at 60, and wraps it. Each table fits in 60 with its
3311
+ // cut columns at their floor — Needs you's, the widest there, needs 56: repo and stream at 6 each, and a detail of 34, a draft's own to-do (28) kept whole and 6 of
3312
+ // the PR's words; ⏳ In progress needs 37: stream, phase, where and who at 6 each — 49 with a session column, whose floor is 9: a kept ` r1` and 6 columns of its words (its header's 7 before round 1)
3313
+ const FIT_MIN = 60
3314
+ // a GFM table, each column padded to its widest cell by `columns`: what a terminal shows, so a markdown link is aligned on its raw text and an OSC 8 one on its words.
3315
+ // `fit` (a terminal: its width, and each column's tier) cuts it to that width: while a line is wider, the widest column of tier 1 loses a column — the long
3316
+ // free text, a detail, a progress, a path — down to its floor, its header's width or 6, whichever is more (a header is never cut); then tier 2's the same way —
3317
+ // the names a row is found by, a repo, a stream, a phase, cut only when the free text is down to its floor. Tier 0 never is: a symbol, a time. Its cells are then cut to
3318
+ // their column — by cutCell, or the column's own cut (`fit.cut[i]`: a path's). The widest first, so the columns cut share what is left. A cell is a string, or
3319
+ // `[words, kept]`: the kept words are never cut, the words before them are, and the column's floor is the kept words and 6 columns of the others (phase 17, K1)
3320
+ function mdTable(head, rows, fit) {
3321
+ const whole = c => typeof c === 'string' ? c : c.join('')
3322
+ const w = head.map((_, i) => Math.max(3, ...[head, ...rows].map(r => columns(whole(r[i])))))
3323
+ if (fit) {
3324
+ const floor = head.map((h, i) => Math.max(6, columns(h), ...rows.map(r => typeof r[i] === 'string' ? 0 : columns(r[i][1]) + 6)))
3325
+ for (const tier of [1, 2])
3326
+ for (let over = w.reduce((a, b) => a + b, 3 * w.length + 1) - fit.width; over > 0; over--) {
3327
+ const i = w.reduce((m, x, k) => fit.tiers[k] === tier && x > floor[k] && (m < 0 || x > w[m]) ? k : m, -1)
3328
+ if (i < 0) break
3329
+ w[i]--
3330
+ }
3331
+ rows = rows.map(r => r.map((c, i) => typeof c === 'string' ? (fit.cut?.[i] ?? cutCell)(c, w[i]) : [cutCell(c[0], w[i] - columns(c[1])), c[1]]))
3332
+ }
3333
+ const line = cells => `| ${cells.map((c, i) => whole(c) + ' '.repeat(w[i] - columns(whole(c)))).join(' | ')} |`
3334
+ return [line(head), `|${w.map(n => '-'.repeat(n + 2)).join('|')}|`, ...rows.map(line)]
3335
+ }
3336
+ // The report as data (phase 16): what renderMarkdown and renderHtml both print — the header, the ⚠ lines and the sections, each a list of tables — with
3337
+ // every rank, grouping, count, to-do and link decided here, once, so the two cannot drift apart. A cell, a caption or a line is a node: a string is brief's
3338
+ // own words; `{ t }` text from a repo or from GitHub (cmdBrief's `plain` object already), which each renderer escapes for its own syntax — `as: 'lead'` where it
3339
+ // starts a cell or a line, `as: 'bold'` a repo line's name; `{ href, c }` a link (`href` empty: none — then `c` alone); `{ b }` bold; `{ keep }` words a terminal's
3340
+ // cut leaves whole, the ones before them in their cell cut first (a row's own to-do, last in its cell); `{ bar: d, of: t }` d phases of t (the markdown's ten
3341
+ // cells, the page's bar); `{ aside }` a note beside it (` · ` before it in the markdown); `{ code }` a command, brief's own, as code and never a link (phase 21); an array, its nodes in order. A table: `head`, `rows`, the line over it
3342
+ // (`caption`; a Needs-you kind's has `kind`, a repo's `repo`, its name as the page says it), and how a terminal may cut it (mdTable's `tiers`, `path` the column cut
3343
+ // from its start). A section: `key` (which one it is), `title`, an `intro` line, its `tables`, an `after` line.
3344
+ // The header names the window most repos share (a tie: the first repo's); a repo with another has it in its notes. `since` is the --since value as given, already
3345
+ // checked by cmdBrief; `slugs[i]` is repo i's owner/repo on github.com (ghAccess's), or none — then nothing of it is a link. A link brief makes is built from
3346
+ // the repo's GitHub slug, a PR's number, a branch and a stream's name, each path segment encoded — or, a cloud session's (phase 21), from the id of a `Claude-Session` trailer, checked against CLAUDE_SESSION and made into the URL again,
3347
+ // never the trailer's text — never from free text (T-2)
3348
+ function briefModel(repos, { fetchMs, since, github, now, slugs = [], words }) {
3349
+ const T = mkT(words)
3350
+ const how = { since: T('how.since', { since }), mark: T('how.mark'), default: T('how.default'), unreadable: T('how.unreadable') }
3351
+ const label = w => T('window', { at: fmtAt(Date.parse(w.start)), how: how[w.markUnreadable ? 'unreadable' : w.kind] })
3352
+ const shared = new Map()
3353
+ for (const r of repos) shared.set(label(r.window), (shared.get(label(r.window)) ?? 0) + 1)
3354
+ const window = [...shared].sort((a, b) => b[1] - a[1])[0][0]
3355
+ const header = `wuwei brief · ${T('header', { window, repos: plural(T, 'repos', repos.length), secs: (fetchMs / 1000).toFixed(1), stale: repos.filter(r => r.stale).length })}`
3356
+ const gh = (i, ...path) => slugs[i] && `https://github.com/${slugs[i]}${path.map(p => `/${urlPart(p)}`).join('')}`
3357
+ const link = (url, c) => ({ href: url || '', c })
3358
+ const prLink = (i, n) => link(Number.isInteger(n) && gh(i, 'pull', String(n)), `#${n}`)
3359
+ const pr = (i, n, branch) => [prLink(i, n), ' ', { t: branch }]
3360
+ const specUrl = (i, s) => s.branch && gh(i, 'blob', ...s.branch.split('/'), '.wuwei', 'streams', s.name, 'SPEC.md')
3361
+ const spec = (i, s) => link(specUrl(i, s), { t: s.name })
3362
+ const phase = branch => String(branch ?? '').split('/').pop() // a stream PR's `wuwei/<stream>/<phase>`: the stream is said already
3363
+ // a repo is its origin's last segment — with no origin, its checkout's directory — unless two repos would share it: then the whole origin or path
3364
+ const short = r => r.origin != null ? r.origin.split('/').pop() : basename(r.checkouts[0])
3365
+ const named = new Map()
3366
+ for (const r of repos) named.set(short(r), (named.get(short(r)) ?? 0) + 1)
3367
+ const nameText = r => named.get(short(r)) > 1 ? r.origin ?? r.checkouts[0] : short(r)
3368
+ const nameOf = r => ({ t: nameText(r), as: 'lead' }) // it starts a cell in Needs you, Notes and Other PRs, and a ⚠ line
3369
+ const sections = []
3370
+
3371
+ // what cannot be trusted. gh itself missing, logged out or failing is said once; a repo gh cannot read for, and what to do about it: no login, one GitHub refused,
3372
+ // or one no stored account reads, is `gh auth login` (gh is only ever its stored logins: brief hands it no env token of the user's) — a repo no account reads names
3373
+ // the logins tried (the token's source when gh holds only the one) and gh's own fix when it gave one. A stale repo: the age of its last good fetch, and why this one failed
3374
+ const absent = { 'not-found': () => T('warn.ghNotFound'), failed: () => T('warn.ghFailed', { detail: { t: github.detail } }),
3375
+ 'not-authenticated': () => T(github.refused ? 'warn.ghNotAuthRefused' : github.noToken ? 'warn.ghNotAuthNoToken' : 'warn.ghNotAuth') }[github.state]
3376
+ const runFix = g => !g.fix ? '' : T(g.fix === 'gh auth login' ? 'warn.runLogin' : 'warn.runFix', { fix: { t: g.fix } })
3377
+ const ghWarn = r => r.github?.state === 'no-access' ? [T(r.github.detail ? 'warn.noAccessDetail' : 'warn.noAccess', { who: { t: r.github.tried?.join(', ') ?? github.source }, detail: { t: r.github.detail } }), runFix(r.github)]
3378
+ : r.github?.state === 'failed' ? T('warn.queryFailed', { detail: { t: r.github.detail } }) : ''
3379
+ const fetchNote = r => r.stale ? (r.notes ?? []).find(n => /^fetch failed\b/.test(n)) : undefined
3380
+ // collectRepo's notes (below, in Notes too) are English with a repo's or git's text in them: said here in the language's words, the text a `{ t }` slot
3381
+ const noteNode = n => { const m = noteShape(n), g = m && n.match(m[0]); return m ? T(m[1], Object.fromEntries(m.slice(2).map((name, i) => [name, name === 'n' ? g[i + 1] : { t: g[i + 1] }]))) : { t: n } }
3382
+ const warn = absent ? [absent()] : []
3383
+ for (const r of repos) {
3384
+ const age = r.fetchedAt ? T('age.last', { age: fmtAge(now - Date.parse(r.fetchedAt)) }) : T('age.never')
3385
+ if (r.stale) warn.push(T('warn.stale', { repo: nameOf(r), age, note: fetchNote(r) !== undefined ? noteNode(fetchNote(r)) : T('note.fetchFailedBare') }))
3386
+ const gw = ghWarn(r)
3387
+ if (gw) warn.push([nameOf(r), ' · ', gw])
3388
+ }
3389
+
3390
+ // Needs you: a line per kind present, in rank — `<sym> <what>` bold, then `— <to do> · <n>` — and under it a table of its items, `| repo | stream | detail |`
3391
+ // (phase 15: as one table, `| | what | to do | repo | stream | detail |`, the kind's own words were what a terminal's width cut first — "stream bran…",
3392
+ // "leave it un…" — and the detail, the item itself, was left a dozen columns). A row whose to-do is not its kind's says it after its detail: an
3393
+ // approved draft's (draftTodo), whose rows come after the ready ones, so "merge it" never reads alone onto a draft; a stream PR whose audit is owed
3394
+ // (auditTodo). The detail is only what the words do not say: a PR's `#<n> <phase>` (then `draft`, a reply's thread count),
3395
+ // the blocker's words, where the unsigned SPEC was read, the quiet claim, the question count and the first question; an own PR outside the streams has no
3396
+ // stream and its whole branch. A row links what is acted on: the PR, else the stream's SPEC (the Streams table links the stream too). A stream's open
3397
+ // questions are one row, the first quoted plain — its ** __ and backticks left out, for a cut at 60 can leave one unpaired (the JSON keeps them). Sorted by
3398
+ // kind, so a kind keeps the repos' and streams' order. The words a sign-off comes with in the JSON are read back here: what the row adds is in them; a reply's
3399
+ // count is its `threads`. The section's title keeps the total — of what asks for something: a kind in FYI (phase 20) is a section of its own after it, with its own
3400
+ // total. A row is `[kind, its to-do, the markdown's cells, what the page says of it]` (phase 23, composition C1: the page took the cells apart by position and compared the to-do's words): the to-do is `todo`'s — the kind's
3401
+ // `say` and `verb`, or a row's own — and the page's part, `{ title, sub, link }`, is the stream (or, a PR outside every stream, its branch), what goes under it after the repo, and what is acted on. A table keeps them, row
3402
+ // for row, as `todos` and `views`: the page prints them and decides none of it
3403
+ const needs = []
3404
+ // a row's own to-do, where it is not its kind's — one object, decided here and printed by both renderers (phase 23, composition C1: the page compared the markdown's words to say its verb): `say` is what the markdown
3405
+ // prints after the detail, `verb` what the page leads the row with, `then` what the page adds at the end of its line, `draft` that the row is an approved draft (the page's state line counts them). An approved draft's:
3406
+ // GitHub will not merge a draft until it is marked ready (U1: #1284 was one, and was told "merge it"). A stream PR open with no AUDIT.md on its base: the PR is not owed any more, the audit still is (phase 11, audit:contract L4; SPEC goal 7)
3407
+ const draftTodo = { say: T('todo.draft.say'), verb: T('todo.draft.verb'), then: T('todo.draft.then'), draft: true }, auditTodo = { say: T('todo.audit.say'), verb: T('todo.audit.verb') }
3408
+ const todo = (k, x) => x?.draft && k === 'pr-approved' ? draftTodo : k === 'stream-ready' && x?.pr != null ? auditTodo : { say: Object.hasOwn(NEEDS, k) ? T(`need.${k}.to`) : '', verb: kindWord(T, k, 'verb') }
3409
+ const prMore = (x, k) => [...x.draft ? [T('more.draft')] : [], ...k === 'pr-reply' ? [plural(T, 'more.threads', x.threads)] : []]
3410
+ // a stale claim's detail is collectRepo's words (--json keeps them): the claimer and the age read back out of it and said in the language's
3411
+ const claimNode = d => {
3412
+ const quiet = d.match(/^claimed by ([\s\S]*), no commit for (\d+[smhd])$/), m = quiet ?? d.match(/^claimed by ([\s\S]*), claimed (\d+[smhd]) ago$/)
3413
+ return m ? T(quiet ? 'claim.quiet' : 'claim.age', { who: { t: m[1] }, age: m[2] }) : { t: d }
3414
+ }
3415
+ const prCell = (i, x, branch, more) => [pr(i, x.pr, branch), ...more.map(m => ` · ${m}`)]
3416
+ repos.forEach((r, i) => {
3417
+ for (const s of r.streams) {
3418
+ const stream = { t: s.name }
3419
+ const at = (detail, kind, w) => needs.push([kind, todo(kind, w), [nameOf(r), spec(i, s), detail], { title: stream, sub: [nameOf(r), detail], link: link(specUrl(i, s), 'SPEC') }])
3420
+ const pull = (w, base) => { // a PR under this stream; the stream PR itself is on `base`
3421
+ const where = base ? 'base' : phase(w.branch), more = base ? [T('more.streamPr')] : prMore(w, w.kind)
3422
+ needs.push([w.kind, todo(w.kind, w), [nameOf(r), stream, prCell(i, w, where, more)], { title: stream, sub: [nameOf(r), { t: where }, ...more], link: prLink(i, w.pr) }])
3423
+ }
3424
+ for (const w of s.waiting) {
3425
+ if (w.kind === 'no-stream-branch' || w.kind === 'open-question' || (w.kind === 'stream-ready' && w.pr != null && w.audit)) continue // its PR's rows say what is left
3426
+ if (w.pr != null) pull(w, w.kind === 'stream-ready')
3427
+ else at(w.kind === 'blocked' || w.kind === 'legacy-branch' ? { t: w.detail }
3428
+ : w.kind === 'stale-claim' ? [{ t: w.phase }, ' ', claimNode(w.detail)]
3429
+ : w.kind === 'sign-off' ? { t: w.detail.match(/^sign-off not given \((.*)\)$/s)?.[1] ?? w.detail }
3430
+ : '', w.kind, w)
3431
+ }
3432
+ const oq = s.waiting.filter(w => w.kind === 'open-question')
3433
+ // a question of nothing but markup would quote as nothing: then it is quoted as it is
3434
+ if (oq.length) at([oq.length > 1 ? T('oq.first', { n: oq.length }) : '', { t: oq[0].detail.replace(/\*\*|__|`/g, '').trim() || oq[0].detail }], 'open-question')
3435
+ }
3436
+ for (const p of r.otherPrs ?? []) for (const k of p.waiting) {
3437
+ const more = prMore(p, k)
3438
+ needs.push([k, todo(k, p), [nameOf(r), '', prCell(i, p, p.branch, more)], { title: { t: p.branch }, sub: [nameOf(r), ...more], link: prLink(i, p.pr) }])
3439
+ }
3440
+ })
3441
+ const rank = k => { const n = Object.keys(NEEDS).indexOf(k); return n < 0 ? Infinity : n }
3442
+ needs.sort((a, b) => rank(a[0]) - rank(b[0]) || !!a[1].draft - !!b[1].draft)
3443
+ const kindTables = list => {
3444
+ const kinds = []
3445
+ for (let i = 0; i < list.length;) { // sorted by kind: a kind's items are a run
3446
+ const k = list[i][0], items = []
3447
+ while (list[i]?.[0] === k) items.push(list[i++])
3448
+ const sym = NEEDS[k] ?? '•', what = Object.hasOwn(NEEDS, k) ? T(`need.${k}.what`) : k, to = Object.hasOwn(NEEDS, k) ? T(`need.${k}.to`) : ''
3449
+ kinds.push({ kind: k, caption: [{ b: `${sym} ${what}` }, ` — ${to} · ${items.length}`], head: [T('head.repo'), T('head.stream'), T('head.detail')], tiers: [2, 2, 1], todos: items.map(([, own]) => own), views: items.map(([, , , view]) => view),
3450
+ rows: items.map(([, own, [repo, stream, detail]]) => [repo, stream, own.say && own.say !== to ? [detail, { keep: ` — ${own.say}` }] : detail]) })
3451
+ }
3452
+ return kinds
3453
+ }
3454
+ const ask = needs.filter(n => !FYI.has(n[0])), fyi = needs.filter(n => FYI.has(n[0]))
3455
+ if (ask.length) sections.push({ key: 'needs', title: T(warn.length ? 'title.needsWarn' : 'title.needs', { n: ask.length }), tables: kindTables(ask) })
3456
+ if (fyi.length) sections.push({ key: 'fyi', title: T('title.fyi', { n: fyi.length }), tables: kindTables(fyi) })
3457
+
3458
+ // Streams: where each stands. `left` is not printed (the JSON keeps it): today is what can start, a phase in it that a human reviews marked. What is in
3459
+ // progress — not done and not startable, and once nowhere (U1 at the stream head) — was here too, each ⏳, until phase 15 gave it a table of its own, below,
3460
+ // which says where and who as well. One legend line for the symbol used.
3461
+ // A stream-ready stream whose stream PR is open says so beside its bar, the PR a link (phase 10: it has no 🚀 row — phase 11: unless its audit is still owed, no AUDIT.md on its base)
3462
+ const streamPr = (i, s) => { const n = s.waiting.find(w => w.kind === 'stream-ready')?.pr; return n == null ? '' : { aside: T('stream.prOpen', { pr: link(gh(i, 'pull', String(n)), `#${n}`) }) } }
3463
+ const old = s => s.waiting.find(w => w.kind === 'legacy-branch') // 🔧: its STATE.md done cell, when it has a row (phase 10, T4)
3464
+ const progress = (i, s) => s.merged ? T('stream.merged', { at: s.mergedAt ? fmtAt(Date.parse(s.mergedAt)) : T('stream.undated') })
3465
+ : s.error ? T('stream.error', { error: { t: firstLine(s.error) } })
3466
+ : !s.branch ? (old(s) ? T('stream.old', { rest: old(s).stateDone ? T('stream.oldState', { done: { t: old(s).stateDone } }) : T('stream.oldNone') }) : T(s.signedOff ? 'stream.noBranch' : 'stream.notSigned'))
3467
+ : s.total ? [{ bar: s.total - s.left.length, of: s.total }, streamPr(i, s)] : T('stream.noPhases')
3468
+ const human = (s, p) => s.left.some(l => l.phase === p && l.review === 'human')
3469
+ const today = s => s.today.flatMap((p, k) => [...k ? [' · '] : [], { t: p }, ...human(s, p) ? [' 👤'] : []])
3470
+ // Streams, ⏳ In progress and ✓ Done in window go by repo (phase 15: with the repo a column, the names took the width the progress, the path and the done
3471
+ // needed): a bold line of the repo's name, linked as the repo cell was, then a table of that repo's rows; a repo with none in a section has no line there
3472
+ const byRepo = (has, rowsOf, head, tiers, path) => repos.flatMap((r, i) => has(r) ? [{ caption: [{ b: link(gh(i), { t: nameText(r), as: 'bold' }) }], repo: nameText(r), head, rows: rowsOf(r, i), tiers, path }] : [])
3473
+ const streams = byRepo(r => r.streams.length, (r, i) => r.streams.map(s => [spec(i, s), progress(i, s), today(s)]), [T('head.stream'), T('head.progress'), T('head.today')], [2, 1, 1])
3474
+ const legend = repos.some(r => r.streams.some(s => s.today.some(p => human(s, p)))) ? T('legend.human') : '' // once, under the last table
3475
+ if (streams.length) sections.push({ key: 'streams', title: T('title.streams'), tables: streams, after: legend })
3476
+
3477
+ // ⏳ In progress (phase 15, the user: "要顯示是哪裡的 worktree"): a row per phase claimed or with a live branch, not merged — where it is checked out (the
3478
+ // worktree, from ~ under the home directory: realpaths compared, as git may print either) and who claimed it, each empty where none is known. A path is the
3479
+ // user's own text (T-2), led like a name when it starts the cell — `~/` is brief's, and opens no block. The realpath is read after cmdBrief's plain(), so what is
3480
+ // printed of it is plained here: a symlink's target, or a name on disk, may hold a control or format character the listed path did not (phase 17, security S1)
3481
+ const home = realOr(homedir())
3482
+ const where = p => { const r = realOr(p); return r === home ? { t: '~' } : r.startsWith(home + sep) ? [{ t: '~/' }, { t: plain(r.slice(home.length + 1)) }] : { t: p, as: 'lead' } } // `~` and `~/` are brief's, yet `{ t }`: a string starting a cell is led (esc and lead are for words that are a language's), and `~` is a character lead escapes
3483
+ // …and its session (phase 21, the user: a link that opens the app's session where one is documented, else the app and its session's name or the command that resumes it): `<app> · <how>` — a cloud session
3484
+ // a link on `open session`, Claude Code's the command that resumes it, as code (never a link), Orca's its names (displayName, then the agent in it). A column of its own, only when some phase in progress
3485
+ // has one — a report that names no session is what it was — free text for a terminal's cut (tier 1, below the names), cut before the path's end is: `where`'s own cut is the path's. A command is never cut — a cell
3486
+ // that held it was cut like free text and the half of a uuid cannot be pasted (round 1) — so it is a kept word: the markdown says `r<n>` there and prints the whole command under the section's tables, the page says it in full
3487
+ const withSession = repos.some(r => r.streams.some(s => s.inProgressAt.some(x => x.app)))
3488
+ const sessionOf = x => x.app === 'claude-cloud' ? ['Claude cloud · ', link(CLAUDE_SESSION.test(x.session.url) ? x.session.url : '', T('session.open'))]
3489
+ : x.app === 'claude-code' ? ['Claude Code ·', { keep: [' ', { code: x.session.resume }] }]
3490
+ : x.app === 'orca' ? ['Orca · ', { t: x.session.name }, ...x.session.agent ? [' · ', { t: x.session.agent }] : [], ...x.session.resume ? [' ·', { keep: [' ', { code: x.session.resume }] }] : []] : ''
3491
+ const going = byRepo(r => r.streams.some(s => s.inProgressAt.length), r => r.streams.flatMap(s => s.inProgressAt.map(x => [{ t: s.name }, { t: x.phase }, x.worktree ? where(x.worktree) : '', { t: x.claimedBy ?? '' }, ...withSession ? [sessionOf(x)] : []])),
3492
+ [T('head.stream'), T('head.phase'), T('head.where'), T('head.who'), ...withSession ? [T('head.session')] : []], [2, 2, 1, 1, ...withSession ? [1] : []], 2)
3493
+ if (going.length) sections.push({ key: 'going', title: T('title.going'), tables: going })
3494
+
3495
+ // ✓ Done in window (phase 15: out of the Streams table, whose widest column it was — U1's ran past the terminal): a row per phase merged in the window, or
3496
+ // per PR of a stream's merged in it, with when — on and off a terminal, a layout and no cut
3497
+ const finished = byRepo(r => r.streams.some(s => s.done.length), (r, i) => r.streams.flatMap(s => s.done.map(d => [{ t: s.name }, d.pr != null ? pr(i, d.pr, phase(d.branch)) : { t: d.phase }, fmtAt(Date.parse(d.at))])),
3498
+ [T('head.stream'), T('head.done'), T('head.at')], [2, 1, 0])
3499
+ if (finished.length) sections.push({ key: 'done', title: T('title.done'), tables: finished })
3500
+
3501
+ const merged = repos.flatMap((r, i) => (r.otherPrs ?? []).filter(p => p.mergedAt).map(p => [nameOf(r), pr(i, p.pr, p.branch), fmtAt(Date.parse(p.mergedAt))]))
3502
+ if (merged.length) sections.push({ key: 'other', title: T('title.other'), tables: [{ head: [T('head.repo'), T('head.pr'), T('head.merged')], rows: merged, tiers: [2, 1, 0] }] })
3503
+
3504
+ // Notes — "How each repo was read" (U1's re-run: "Notes 那邊也不知道是什麼意思"): nothing to do, each note a sentence. A window that is not the header's, and
3505
+ // why; how the repo was read; who GitHub read it as. A repo with no stream says so, unless collectRepo's note says why: every stream merged before the
3506
+ // window — anchored at the note's start, for another note can QUOTE those words (a fetch error, a refspec git rejects). The GitHub window is a mark of its
3507
+ // own and drifts from the git one when a half fails: a repo whose differs says so. A --mark run marks both halves at once, the GitHub one GH_INDEX_LAG
3508
+ // earlier (cmdBrief): that is how a repo in step stands, and not a drift. A repo with no origin gives its path. A row per note, the repo named on its first
3509
+ const kindOf = w => w.markUnreadable ? 'unreadable' : w.kind
3510
+ // a repo's own mark is said against the header's window: in a tie that may be "no mark yet", and then no other repo was "marked at another time"
3511
+ // (--since is every repo's window: no own mark sits under a --since header)
3512
+ const othersMark = { default: T('why.own.markOthersDefault'), unreadable: T('why.own.markOthersUnreadable') }
3513
+ const headKind = kindOf(repos.find(r => label(r.window) === window).window)
3514
+ const whyOwn = { since: how.since, mark: othersMark[headKind] ?? T('why.own.mark'), default: T('why.own.default'), unreadable: T('why.own.unreadable') }
3515
+ const whyGh = { since: how.since, mark: T('why.gh.mark'), default: T('why.gh.default'), unreadable: T('why.gh.unreadable') }
3516
+ const notes = repos.flatMap(r => {
3517
+ const marks = r.githubWindow && r.githubWindow.kind === 'mark' && r.window.kind === 'mark'
3518
+ const apart = r.githubWindow && label(r.githubWindow) !== label(r.window) && !(marks && Date.parse(r.window.start) - Date.parse(r.githubWindow.start) === GH_INDEX_LAG)
3519
+ const own = [
3520
+ ...label(r.window) === window ? [] : [T('note.ownWindow', { at: fmtAt(Date.parse(r.window.start)), why: whyOwn[kindOf(r.window)] })],
3521
+ ...apart ? [T('note.ghWindow', { at: fmtAt(Date.parse(r.githubWindow.start)), why: whyGh[kindOf(r.githubWindow)] })] : [],
3522
+ ...(r.notes ?? []).filter(n => n !== fetchNote(r)).map(noteNode),
3523
+ ...r.streams.length || (r.notes ?? []).some(n => /^\d+ streams? merged before the window/.test(n)) ? [] : [T('note.noStreams')],
3524
+ ...r.github?.account ? [T('note.account', { account: { t: r.github.account } })] : r.github?.state === 'not-github' ? [T('note.notGithub')] : [],
3525
+ ]
3526
+ const where = r.origin == null && nameText(r) !== r.checkouts[0] ? [' (', { t: r.checkouts[0] }, ')'] : '' // named by its path already: not twice
3527
+ return own.map((n, k) => [k ? '' : [nameOf(r), where], n])
3528
+ })
3529
+ if (notes.length) sections.push({ key: 'notes', title: T('title.notes'), intro: T('notes.intro'), tables: [{ head: [T('head.repo'), T('head.how')], rows: notes, tiers: [2, 1] }] })
3530
+ return { header, warn, sections }
3531
+ }
3532
+
3533
+ // the markdown (phase 05, T4; the user: "wuwei 的精神是方便人閱讀重點" — the form follows what is easiest to read). What needs the person first, across repos, and the
3534
+ // rest as context: the header; a `- ⚠` line for each thing that cannot be trusted — a stale repo, one GitHub could not read for, gh itself absent — which leaves
3535
+ // "Needs you" short of what it cannot see; Needs you, what waits on the person, most actionable kind first — its symbol, what it is and what to do (U1's
3536
+ // re-run: "emoji 的意思要說明"); Streams; Other PRs, the viewer's own that merged in the window (one still waiting is a Needs-you row); Notes, how each repo was
3537
+ // read, in words. Each section a `##` line, a caption a line of its own (a kind's words bold, a repo's name bold), tables, not bullets, since phase 08 (U1:
3538
+ // "條列式一多就有點亂,還是用表格好了"); the ⚠ lines stay lines, for each says the rest may be incomplete. An empty section is left out (briefModel).
3539
+ // Repo text (`{ t }`) gets `esc`, which escapes the markdown that could make structure of it — a `|` that would cut a table cell, a `[ ] < >` that would make
3540
+ // a link of its own beside brief's (T-2), the backslash that would undo an escape; `lead` too where it starts a cell or a line; a repo line's name its `*`, `_`
3541
+ // and backtick as well, which would end the bold or make markup of the name's (phase 15, round 1) — no `lead`: the line starts with brief's `**`. Links (a
3542
+ // bare URL in a repo's words, an open question's, may still be autolinked by a GFM viewer: words, accepted as T-2 accepts them): OSC 8 on a terminal (`osc8`);
3543
+ // else reference-style, `[text][n]` numbered in order of first use (one URL, one number) with the `[n]: url` definitions a block at the end — a scheduled
3544
+ // run's report is read raw, by an agent or in an editor, and a URL inline made the table ~330 columns wide
3545
+ function renderMarkdown(repos, { osc8 = false, width = 0, ...data }) {
3546
+ const { header, warn, sections } = briefModel(repos, data)
3547
+ // on a terminal (`width`, cmdBrief's: none off one — a file, a pipe and the routine's report keep every word) each table is fitted to it, its columns in
3548
+ // tiers (mdTable): 0 never cut, 1 the free text, cut first, 2 the names, cut after
3549
+ const table = (head, rows, tiers, cut) => mdTable(head, rows, width ? { width, tiers, cut } : null)
3550
+ // a reference is numbered when it is made, so every node is made into text in the order it is printed
3551
+ const refs = new Map(), cmds = new Map() // …a command (a `{ code }`) the same way, by its own number — `r<n>` in the cell, `[r<n>]: <command>` under the section's tables
3552
+ const link = (text, url) => {
3553
+ if (!url) return text
3554
+ if (osc8) return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`
3555
+ if (!refs.has(url)) refs.set(url, refs.size + 1)
3556
+ return `[${text}][${refs.get(url)}]`
3557
+ }
3558
+ const cmd = c => cmds.get(c) ?? (cmds.set(c, cmds.size + 1), cmds.size)
3559
+ // brief's own words are a string and go through esc like a repo's text (phase 30: a language the user wrote is text — a `<`, a `|` or a `[` in it is printed, not obeyed), and through lead where they start a cell or a line (`first`: nothing
3560
+ // has been printed before them in it yet — a list's first non-empty part)
3561
+ const mdAll = (list, first) => { let f = first; return list.map(x => { const t = md(x, f); if (t !== '') f = false; return t }).join('') }
3562
+ const md = (n, first = false) => typeof n === 'string' ? (first ? lead(esc(n)) : esc(n)) : Array.isArray(n) ? mdAll(n, first) : 'href' in n ? link(md(n.c), n.href) : 'b' in n ? `**${md(n.b)}**` : 'keep' in n ? md(n.keep) : 'bar' in n ? bar(n.bar, n.of) : 'aside' in n ? ` · ${md(n.aside)}` : 'code' in n ? `r${cmd(n.code)}`
3563
+ : n.as === 'lead' ? lead(esc(n.t)) : n.as === 'bold' ? esc(n.t).replace(/[*_`]/g, '\\$&') : esc(n.t)
3564
+ // a cell that ends in kept words goes to mdTable as [the words before them, the kept ones]
3565
+ const cell = c => { const k = Array.isArray(c) ? c.at(-1)?.keep : undefined; return k === undefined ? md(c, true) : [md(c.slice(0, -1), true), md(k)] }
3566
+ const out = [md(header)]
3567
+ if (warn.length) out.push('', ...warn.map(w => `- ⚠ ${md(w)}`))
3568
+ for (const s of sections) {
3569
+ const before = cmds.size
3570
+ out.push('', `## ${md(s.title)}`, ...s.intro ? ['', md(s.intro, true)] : [])
3571
+ for (const t of s.tables) out.push(...t.caption ? ['', md(t.caption, true)] : [], '', ...table(t.head.map(h => md(h, true)), t.rows.map(r => r.map(cell)), t.tiers, t.path != null ? { [t.path]: cutPath } : undefined))
3572
+ // each command a cell of this section named, whole on a line of its own and never cut (≤ 60 columns up to r99, the floor of a terminal): a line wider than the terminal would be broken between words, a command pasted in halves
3573
+ if (cmds.size > before) out.push('', ...[...cmds].slice(before).map(([c, n]) => `[r${n}]: ${c}`))
3574
+ if (s.after) out.push('', md(s.after, true))
3575
+ }
3576
+ if (refs.size) out.push('', ...[...refs].map(([url, n]) => `[${n}]: ${url}`)) // no heading: GFM renders none of it
3577
+ // on a terminal a line outside the tables — the header, a ⚠ line, the legend — wider than it is broken between words; a ⚠ line's next lines indented under its
3578
+ // text, as a list item's are (a path wider than the terminal stays whole: no word is cut)
3579
+ return out.flatMap(l => !width || l.startsWith('|') || columns(l) <= width ? [l]
3580
+ : l.startsWith('- ') ? wrapWords(l.slice(2), width - 2).map((w, k) => `${k ? ' ' : '- '}${w}`) : wrapWords(l, width)).join('\n')
3581
+ }
3582
+
3583
+ // `--html` (phase 16, the user: the report as a page; phase 18, U5: in the design the user's design agent drew; phase 20: in layout A, the user's pick after the design research): briefModel's report as one
3584
+ // self-contained HTML page whose first screen is today's decisions — a state line (the three kinds with the most rows) and the strip under it, a sticky strip of anchors from 851px up, then Needs you as a
3585
+ // group per kind, a row a decision with its verb first, the FYI folded at its foot, Work in motion and Streams beside it — two columns, each as tall as its own sections (phase 27) — Done in window and Other PRs under Needs you in the same style, how each repo was read folded
3586
+ // away, a footer. The markdown's sections, row for row: the page says a bar where the markdown says ten cells, a kind by its short name and hint where the markdown says its symbol and words. No repo text
3587
+ // may become markup or script (T-2): every string — a repo's, GitHub's and brief's own — goes through `html`, the one escaper, after plain(); the only tags are brief's, and the only attributes brief's own
3588
+ // constants, numbers or `html`-escaped text — `class`, `id`, `role`, `lang`, `datetime`, `aria-*` (an aria-label holds a stream's name, escaped), the theme switch's `type`, `name`, `checked` and `for` (phase 24: brief's constants), the svg icons' presentation attributes, a progress bar's
3589
+ // `style` as `width:<0–100>%` and nothing else, `rel` on a github link (`noopener noreferrer`), the head's metas' `charset`, `name`, `content` and `http-equiv` (brief's own constants), and `href`: a link
3590
+ // is one of briefModel's — a github.com one, built from a slug, or a cloud session's `https://claude.ai/code/session_<id>`, from a trailer's id checked against CLAUDE_SESSION — checked to be one of the two before it is written
3591
+ // (`real`), else its text is printed with no link (no input reaches that else, every href being built from a slug or a checked id; test/brief/html.mjs' href rule is what holds a page to github.com and to exact sessions) — or `#` and an id of brief's own, for the strip of anchors. Nothing loads from elsewhere: no script, no src, no external
3592
+ // style, font or image, the style inline (light and dark by prefers-color-scheme, system fonts, or by the theme switch); no referrer sent when a link is followed; a Content-Security-Policy meta that allows that style and
3593
+ // nothing else, and no <base>, no form — the only inputs are the theme switch's three radios, which the browser reads and nothing submits. Phase 20 took the sidebar and its profile away: the page asks git for no name
3594
+ const HTML_ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
3595
+ const html = t => String(t).replace(/[&<>"']/g, c => HTML_ESC[c])
3596
+ const ON_GITHUB = 'https://github.com/'
3597
+ // …and the one other place a link may go (phase 21): a cloud session, exactly `https://claude.ai/code/session_<letters and digits>` — briefModel makes the link only when the URL is that, and the page checks it
3598
+ // again as it writes it; test/brief/html.mjs' href rule is the same shape
3599
+ const CLAUDE_SESSION = /^https:\/\/claude\.ai\/code\/session_[A-Za-z0-9]+$/
3600
+ // defence in depth under html(): were markup ever to get through, the browser still loads nothing and runs nothing — only the page's own inline style is allowed,
3601
+ // and nothing can be sent anywhere by a form or re-based by a <base>
3602
+ const HTML_CSP = "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"
3603
+ // the page's CSS (phase 20: layout A, the user's pick — a.html of the design research, kept in the session's scratchpad; phase 18's, rule for rule, was the design agent's): light and dark by
3604
+ // prefers-color-scheme, the 14px body and nothing under 12px, four colours that each mean one thing — red broken or blocked, amber for your judgement, accent for what is ready to act on, grey
3605
+ // for what only informs (a text colour is never the only word: a row starts with its verb, a group has its icon) — the strip of anchors at 36px from 851px up, a row a grid of `8rem 1fr auto`.
3606
+ // The tokens are read back by test/brief/e38.mjs, which holds every text pair to 4.5:1 in both schemes
3607
+ // …and its colours (phase 24, the theme switch): written once each, as the tokens of the system's light, of its dark, and of Light and Dark chosen by hand — `:root:has(#theme-light:checked)` and `…-dark:checked`
3608
+ // are the same tokens under a selector that outweighs the media query, so the choice wins whatever prefers-color-scheme says, and System (checked on load) leaves it in charge. test/brief/e38.mjs reads all four
3609
+ const HTML_LIGHT = '--bg:#f7f8fa;--surface:#fff;--text:#20242c;--muted:#5d6576;--subtle:#6b7385;--border:#e6e9ef;--hover:#f9fafc;--accent:#5b5bd6;--accent-soft:#ededfc;--red:#b23b3b;--red-soft:#fceeee;--amber:#946516;--amber-soft:#fcf4e3;--grey:var(--muted);--grey-soft:#f0f2f5;--track:#c9ced8;--shadow:0 2px 5px #18233803'
3610
+ const HTML_DARK = '--bg:#14161b;--surface:#1b1e25;--text:#e8eaf0;--muted:#a0a8b8;--subtle:#8791a4;--border:#303541;--hover:#20242c;--accent:#aaa5ff;--accent-soft:#302c4a;--red:#f28c8c;--red-soft:#452a2e;--amber:#e5bc72;--amber-soft:#403724;--grey-soft:#303541;--track:#3a404d;--shadow:none'
3611
+ const HTML_STYLE = `
3612
+ :root{color-scheme:light dark;${HTML_LIGHT}}
3613
+ @media(prefers-color-scheme:dark){:root{${HTML_DARK}}}
3614
+ :root:has(#theme-light:checked){color-scheme:light;${HTML_LIGHT}}
3615
+ :root:has(#theme-dark:checked){color-scheme:dark;${HTML_DARK}}
3616
+ *{box-sizing:border-box}
3617
+ html{scroll-behavior:smooth;scroll-padding-top:48px}
3618
+ body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}
3619
+ a{color:inherit;text-decoration:none}
3620
+ a:hover{color:var(--accent);text-decoration:underline;text-underline-offset:3px}
3621
+ a:focus-visible,summary:focus-visible{outline:3px solid var(--accent);outline-offset:3px;border-radius:4px}
3622
+ svg{flex-shrink:0;vertical-align:middle}
3623
+ h1,h2,h3,p{margin:0}
3624
+ h2{font-size:18px;font-weight:650;letter-spacing:-.3px}
3625
+ h3{font-size:13px;font-weight:600}
3626
+ ul{margin:0;padding:0;list-style:none}
3627
+ .skip{position:fixed;top:-70px;left:20px;padding:10px 18px;background:var(--surface);z-index:10}
3628
+ .skip:focus{top:10px}
3629
+ .wrap{max-width:1200px;margin:0 auto;padding:0 32px}
3630
+ .topstrip{position:sticky;top:0;z-index:5;height:36px;background:var(--surface);border-bottom:1px solid var(--border)}
3631
+ .topstrip .wrap{height:100%;display:flex;align-items:center;gap:22px;font-size:13px}
3632
+ .topstrip nav{display:flex;align-items:center;gap:22px}
3633
+ .topstrip a{color:var(--muted);font-weight:500}
3634
+ .topstrip a:hover{color:var(--accent)}
3635
+ .topstrip .n{font-variant-numeric:tabular-nums;color:var(--subtle);margin-left:3px}
3636
+ .brand{display:inline-flex;align-items:center;gap:7px;font-weight:700;font-size:14px;margin-right:6px}
3637
+ .brand-logo{height:20px;width:20px;background:var(--text);color:var(--surface);display:grid;place-items:center;border-radius:6px;font-size:14px;font-family:Georgia,serif;font-style:italic}
3638
+ .theme{display:none;position:relative;margin-left:auto;border:1px solid var(--border);border-radius:7px;overflow:hidden}
3639
+ .theme input{position:absolute;opacity:0;width:1px;height:1px;margin:0;pointer-events:none}
3640
+ .theme label{display:block;padding:3px 10px;font-size:12px;line-height:18px;font-weight:500;color:var(--muted);cursor:pointer}
3641
+ .theme label:hover{color:var(--text)}
3642
+ .theme input:checked+label{background:var(--accent-soft);color:var(--accent);box-shadow:inset 0 -2px 0 var(--accent)}
3643
+ .theme input:focus-visible+label{outline:3px solid var(--accent);outline-offset:-3px}
3644
+ @supports selector(:has(*)){.theme{display:inline-flex}}
3645
+ .state{padding:22px 0 12px}
3646
+ .state-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 14px}
3647
+ h1{font-size:22px;line-height:1.3;font-weight:600;letter-spacing:-.4px}
3648
+ .state time{font-size:12px;color:var(--muted);white-space:nowrap}
3649
+ .strip{font-size:13px;color:var(--muted);margin-top:4px}
3650
+ .strip .stale{color:var(--red);font-weight:600}
3651
+ .banner{margin:0 0 20px;padding:10px 14px;background:var(--amber-soft);color:var(--amber);border:1px solid var(--border);border-radius:10px;font-size:12px;overflow-wrap:anywhere}
3652
+ .banner li+li{margin-top:5px}
3653
+ .layout{display:grid;grid-template-columns:minmax(0,1.75fr) minmax(300px,1fr);gap:22px 24px;align-items:start}
3654
+ .col-main,.col-side{display:flex;flex-direction:column;gap:22px;min-width:0}
3655
+ .section-heading{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;margin-bottom:10px}
3656
+ .section-heading p{font-size:12px;color:var(--muted);margin-top:2px}
3657
+ .section-number{display:inline-grid;place-items:center;min-width:23px;height:22px;padding:0 6px;border:1px solid var(--border);border-radius:6px;font-size:12px;font-weight:500;color:var(--muted);margin-left:6px;vertical-align:middle}
3658
+ .section-kicker{font-size:12px;color:var(--muted);white-space:nowrap}
3659
+ .panel{border:1px solid var(--border);border-radius:11px;background:var(--surface);overflow:hidden;box-shadow:var(--shadow)}
3660
+ .red{--status:var(--red);--soft:var(--red-soft)}
3661
+ .amber{--status:var(--amber);--soft:var(--amber-soft)}
3662
+ .accent{--status:var(--accent);--soft:var(--accent-soft)}
3663
+ .grey{--status:var(--grey);--soft:var(--grey-soft)}
3664
+ .group-head,.fyi>summary{display:flex;align-items:center;gap:9px;padding:8px 18px;background:var(--hover);border-bottom:1px solid var(--border)}
3665
+ .group+.group .group-head,.fyi>summary{border-top:1px solid var(--border)}
3666
+ .status-icon{width:22px;height:22px;border-radius:6px;background:var(--soft);color:var(--status);display:grid;place-items:center}
3667
+ .status-icon svg{width:14px;height:14px}
3668
+ .group-head h3{display:flex;align-items:center;gap:8px}
3669
+ .count{background:var(--soft);color:var(--status);font-size:12px;padding:0 6px;border-radius:4px;font-weight:600}
3670
+ .group-hint{font-size:12px;color:var(--muted);margin-left:auto}
3671
+ .row{display:grid;grid-template-columns:8rem 1fr auto;gap:0 14px;align-items:baseline;padding:8px 18px;border-bottom:1px solid var(--border)}
3672
+ .rows .row:last-child{border-bottom:0}
3673
+ .row:hover{background:var(--hover)}
3674
+ .verb{font-size:13px;font-weight:600;color:var(--status)}
3675
+ .what{min-width:0}
3676
+ .what strong{display:block;font-size:14px;font-weight:600;line-height:1.4;overflow-wrap:anywhere}
3677
+ .what small{display:block;font-size:12px;color:var(--muted);line-height:1.5;overflow-wrap:anywhere}
3678
+ .pr{font-size:13px;font-weight:600;color:var(--accent);font-variant-numeric:tabular-nums;white-space:nowrap}
3679
+ .pr::after{content:' ↗';font-weight:400;color:var(--subtle)}
3680
+ .fyi>summary{list-style:none;cursor:pointer;font-size:13px;font-weight:600;color:var(--muted);border-bottom:0}
3681
+ .fyi>summary::-webkit-details-marker{display:none}
3682
+ .fyi[open]>summary{border-bottom:1px solid var(--border)}
3683
+ .fyi .group-hint{font-weight:400}
3684
+ .mrow{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 10px;align-items:baseline;padding:10px 16px;border-bottom:1px solid var(--border)}
3685
+ .mrow:last-child{border-bottom:0}
3686
+ .phase{font-weight:600;min-width:0;overflow-wrap:break-word}
3687
+ .mwhat{grid-column:1/-1;min-width:0;font-size:13px;color:var(--muted);overflow-wrap:break-word}
3688
+ .mwhat strong{font-weight:inherit}
3689
+ .worker summary{list-style:none;cursor:pointer;color:var(--muted);font-size:12px;display:flex;align-items:center;gap:4px;white-space:nowrap}
3690
+ .worker summary::-webkit-details-marker{display:none}
3691
+ .worker[open] summary svg{transform:rotate(90deg)}
3692
+ .worker{grid-column:2;grid-row:1}
3693
+ .worker[open]{grid-column:1/-1;grid-row:auto}
3694
+ dl{font-size:12px;margin:6px 0 0;display:grid;grid-template-columns:auto 1fr;gap:2px 12px}
3695
+ dt{color:var(--muted)}
3696
+ dd{margin:0;overflow-wrap:anywhere}
3697
+ .repo-group{padding:14px 16px 4px;border-bottom:1px solid var(--border)}
3698
+ .repo-group:last-of-type{border-bottom:0}
3699
+ .repo-group>h3{display:flex;align-items:center;gap:8px;margin-bottom:2px}
3700
+ .repo-group>h3>strong{font-weight:600;overflow-wrap:anywhere}
3701
+ .repo-avatar{display:grid;place-items:center;width:22px;height:22px;border-radius:6px;background:var(--grey-soft);color:var(--text);font-size:12px;font-weight:650}
3702
+ .repo-total{margin-left:auto;color:var(--subtle);font-size:12px;font-weight:400}
3703
+ .stream-item{padding:10px 0 12px;border-bottom:1px dashed var(--border)}
3704
+ .stream-item:last-child{border:0}
3705
+ .stream-name{font-size:13px;line-height:1.5;font-weight:500;overflow-wrap:anywhere}
3706
+ .progress-meta{display:flex;justify-content:space-between;align-items:center;font-size:12px;color:var(--muted);margin-top:6px}
3707
+ .progress-meta.done{justify-content:flex-start;margin-top:2px}
3708
+ .fraction{font-size:12px;font-weight:600;color:var(--text);font-variant-numeric:tabular-nums}
3709
+ .fraction>span{color:var(--muted);font-weight:400}
3710
+ .progress-track{height:4px;background:var(--track);border-radius:3px;overflow:hidden;margin-top:5px}
3711
+ .progress-fill{display:block;background:var(--accent);height:100%;border-radius:3px}
3712
+ .stream-note{font-size:12px;color:var(--muted);line-height:1.6;margin-top:5px;overflow-wrap:anywhere}
3713
+ .stream-note a{color:var(--accent)}
3714
+ .legend{padding:0 16px 12px;color:var(--muted);font-size:12px}
3715
+ .empty{padding:16px 18px;color:var(--muted);font-size:13px}
3716
+ .sources{margin-top:24px;background:var(--surface);border:1px solid var(--border);border-radius:9px}
3717
+ .sources>summary{padding:13px 18px;cursor:pointer;font-size:12px;color:var(--muted)}
3718
+ .sources>summary span{float:right}
3719
+ .source-content{padding:0 18px 18px}
3720
+ .source-content p{font-size:12px;color:var(--muted);margin-bottom:10px}
3721
+ .source-content table{width:100%;border-collapse:collapse;font-size:12px;table-layout:fixed}
3722
+ .source-content th,.source-content td{text-align:left;border-bottom:1px solid var(--border);padding:8px 10px;overflow-wrap:anywhere;vertical-align:top}
3723
+ .source-content th{color:var(--muted);font-weight:500;width:33%}
3724
+ .footer{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px 15px;margin:22px 0 26px;padding:0 2px;color:var(--muted);font-size:12px}
3725
+ .footer strong{font-weight:550;color:var(--text)}
3726
+ .footer .mark{flex-basis:100%}
3727
+ @media(max-width:850px){.topstrip{display:none;position:static;height:auto;background:none;border:0}.topstrip .wrap{height:auto;padding-top:8px;justify-content:flex-end}.brand,.topstrip nav{display:none}.wrap{padding:0 22px}.layout{grid-template-columns:minmax(0,1fr)}}
3728
+ @media(max-width:650px){.wrap{padding:0 16px}.state{padding-top:18px}.group-head,.fyi>summary{padding:8px 14px}.group-hint{display:none}.row{padding:8px 14px;gap:0 10px}.mrow{padding:10px 14px}.section-kicker{white-space:normal;text-align:right}.sources>summary span{display:none}}
3729
+ @supports selector(:has(*)){@media(max-width:850px){.topstrip{display:block}}}
3730
+ @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
3731
+ @media print{.topstrip,.skip{display:none}.layout{display:block}.col-side{margin-top:22px}.panel{box-shadow:none;break-inside:avoid}body{background:white;color:black}}
3732
+ `
3733
+ // Needs you's kinds on the page, each one [colour, icon] (phase 20); its words — the short name, the hint, the verb a row starts with, and the state line's words for one and for several — are `kind.<kind>.name`, `.hint`,
3734
+ // `.verb`, `.one` and `.many` of the table (phase 30). The colour is one of four, each
3735
+ // meaning one thing — red: broken or blocked; amber: your judgement; accent: ready to act; grey: nothing to do but know — and the icon is the kind's own, so no kind is told by colour alone; the verb
3736
+ // is the first word a row says — briefModel reads it into each row (an approved draft's is Mark ready and a stream PR whose audit is owed Run its audit: the row's own to-do, which replaces it). The short names, hints and colours of the
3737
+ // kinds the design agent drew (Blocked, Ready to merge, Ready for UAT, Sign-off needed, Open questions, Legacy branches) are its, the rest brief's own in the same voice. Every kind in NEEDS has its
3738
+ // entry here and its words in the table, and a kind with something to do a verb — E34 holds them to each other. The legacy kind is the one with nothing to do: it is FYI, and says none. A stale claim is not
3739
+ // grey: it is counted in Needs you and asks the human something — whether the claim is abandoned is theirs to decide (SPEC goal 5) — so it is amber, and its verb is Check, not Reclaim
3740
+ const HTML_KIND = {
3741
+ 'pr-ci': ['red', 'ci'],
3742
+ 'pr-review-requested': ['amber', 'review'],
3743
+ 'pr-review-human': ['amber', 'human'],
3744
+ 'pr-reply': ['amber', 'reply'],
3745
+ 'pr-approved': ['accent', 'merge'],
3746
+ uat: ['accent', 'uat'],
3747
+ 'stream-ready': ['accent', 'stream'],
3748
+ blocked: ['red', 'blocked'],
3749
+ 'sign-off': ['amber', 'signoff'],
3750
+ 'stale-claim': ['amber', 'stale'],
3751
+ 'legacy-branch': ['grey', 'legacy'],
3752
+ 'open-question': ['amber', 'question'],
3753
+ }
3754
+ const htmlKind = k => Object.hasOwn(HTML_KIND, k) ? HTML_KIND[k] : ['grey', 'stale'] // a kind the table lacks is grey, with the stale clock (its words: its own name — kindWord)
3755
+ // the icons, as static svg children, each drawn in a 24-unit box by `svg()` — one glyph per kind in HTML_KIND (a flag, a flask, a pen, a ban, a merge …) and the chevron of a folded part; their numbers
3756
+ // and path data are the whole of what an svg carries
3757
+ const HTML_ICON = {
3758
+ ci: '<circle cx="12" cy="12" r="9"></circle><path d="m15 9-6 6M9 9l6 6"></path>',
3759
+ blocked: '<circle cx="12" cy="12" r="9"></circle><path d="m5.6 5.6 12.8 12.8"></path>',
3760
+ review: '<path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z"></path><circle cx="12" cy="12" r="3"></circle>',
3761
+ human: '<circle cx="12" cy="8" r="4"></circle><path d="M4 21a8 8 0 0 1 16 0"></path>',
3762
+ reply: '<path d="M21 12a8 8 0 0 1-11.5 7.2L4 20l1-4.6A8 8 0 1 1 21 12Z"></path>',
3763
+ signoff: '<path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path>',
3764
+ question: '<circle cx="12" cy="12" r="9"></circle><path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6V14"></path><path d="M12 17.5h.01"></path>',
3765
+ merge: '<circle cx="18" cy="18" r="3"></circle><circle cx="6" cy="6" r="3"></circle><path d="M6 21V9a9 9 0 0 0 9 9"></path>',
3766
+ uat: '<path d="M9 3h6M10 3v6l-5.6 9.6A1.6 1.6 0 0 0 5.8 21h12.4a1.6 1.6 0 0 0 1.4-2.4L14 9V3"></path><path d="M7 15h10"></path>',
3767
+ stream: '<path d="M5 21V4h12l-2.5 4.5L17 13H5"></path>',
3768
+ legacy: '<rect x="3" y="4" width="18" height="4" rx="1"></rect><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8M10 12h4"></path>',
3769
+ stale: '<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path>',
3770
+ chevron: '<path d="m9 5 7 7-7 7"></path>',
3771
+ }
3772
+ function renderHtml(repos, { markLine, ...data }) {
3773
+ const { header, warn, sections } = briefModel(repos, data)
3774
+ const T = mkT(data.words), lang = data.code ?? 'en'
3775
+ const real = n => typeof n.href === 'string' && (n.href.startsWith(ON_GITHUB) || CLAUDE_SESSION.test(n.href))
3776
+ // a node as markup: its text escaped by `esc` (html, or brk where a name may break) and every tag brief's own — h is the first, hb the second
3777
+ const hx = (n, esc) => typeof n === 'string' ? esc(n) : Array.isArray(n) ? n.map(x => hx(x, esc)).join('') : 'b' in n ? `<strong>${hx(n.b, esc)}</strong>` : 'keep' in n ? hx(n.keep, esc) : 'aside' in n ? hx(n.aside, esc) : 'bar' in n ? html(`${n.bar}/${n.of}`)
3778
+ : 'code' in n ? `<code>${html(n.code)}</code>` : 'href' in n ? a(n, '', esc) : esc(n.t)
3779
+ const a = (n, cls = '', esc = html) => real(n) ? `<a${cls ? ` class="${cls}"` : ''} href="${html(n.href)}" rel="noopener noreferrer">${hx(n.c, esc)}</a>` : hx(n.c, esc)
3780
+ // a name that may break after a `-` or a `/` (phase 29): the browser breaks after a hyphen of its own, but not before a digit (`six-1441`), so a <wbr> says where else. Each piece is escaped by html() alone and the
3781
+ // <wbr>s go between the escaped pieces — nothing of the name is ever markup
3782
+ const brk = t => String(t).split(/(?<=[-/])/).map(html).join('<wbr>')
3783
+ const h = n => hx(n, html), hb = n => hx(n, brk)
3784
+ const attr = n => h(n).replace(/<[^>]*>/g, '') // a node as text for an attribute or a title: h's tags are brief's, every other < is already &lt;
3785
+ // …and the words of the table in an attribute (phase 30): html() leaves a backtick, which the checker (html.mjs) allows in no attribute — a language is the user's text — so it is an entity there. `attrOf` is a node of the table's: its strings
3786
+ // that way, its nodes as `attr` makes them
3787
+ const att = t => html(t).replace(/`/g, '&#96;'), attrOf = n => (Array.isArray(n) ? n : [n]).map(x => typeof x === 'string' ? att(x) : attr(x)).join('')
3788
+ const svg = (k, size = 18) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${HTML_ICON[k]}</svg>`
3789
+ const int = n => Math.max(0, Math.trunc(Number(n)) || 0)
3790
+ const sec = k => sections.find(s => s.key === k)
3791
+ const tablesOf = k => sec(k)?.tables ?? []
3792
+ const count = k => tablesOf(k).reduce((n, t) => n + t.rows.length, 0)
3793
+ const [nNeeds, nStreams, nGoing, nFyi] = ['needs', 'streams', 'going', 'fyi'].map(count) // Needs you's count has no FYI row in it: briefModel keeps them apart
3794
+ const stale = repos.filter(r => r.stale).length
3795
+ const d = new Date(data.now), p = n => String(n).padStart(2, '0'), ymd = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
3796
+ const dated = T('page.date', { mon: T(`month.${d.getMonth() + 1}`), d: d.getDate(), y: d.getFullYear() })
3797
+ const heading = (id, title, n, note, kicker) => `<header class="section-heading"><div><h2 id="${id}-title">${html(title)} <span class="section-number">${n}</span></h2><p>${html(note)}</p></div>${kicker ? `<span class="section-kicker">${html(kicker)}</span>` : ''}</header>`
3798
+ const empty = text => `<p class="empty">${html(text)}</p>`
3799
+ const note = x => x ? `<div class="stream-note">${x}</div>` : ''
3800
+ // a row of Needs you or FYI is one decision (phase 23, composition C1: briefModel says all of it — this took the markdown's cells apart by position and compared the to-do's words): its verb first — the kind's, or the row's own
3801
+ // (`todos`: Mark ready on an approved draft, Run its audit on a stream PR whose audit is owed) — then its title in bold, the stream or, for a PR outside every stream, its branch, with what goes under it (`views`: the repo,
3802
+ // the PR's branch, `draft`, a reply's thread count, the row's own `then`), then what is acted on: the PR, or else the stream's SPEC, where briefModel linked it — a link the guard (`real`) lets through, else none
3803
+ const row = (t, i) => {
3804
+ const { verb, then } = t.todos[i], { title, sub, link } = t.views[i]
3805
+ return `<li class="row"><span class="verb">${html(verb)}</span><span class="what"><strong>${h(title)}</strong><small>${sub.map(h).filter(Boolean).join(' · ')}${then ? ` — ${html(then)}` : ''}</small></span>${link && real(link) ? a({ href: link.href, c: link.c }, 'pr') : ''}</li>`
3806
+ }
3807
+ const group = t => {
3808
+ const [colour, icon] = htmlKind(t.kind), name = kindWord(T, t.kind, 'name'), hint = kindWord(T, t.kind, 'hint')
3809
+ return `<section class="group ${colour}" aria-labelledby="need-${html(t.kind)}"><header class="group-head"><span class="status-icon">${svg(icon)}</span><h3 id="need-${html(t.kind)}">${html(name)}<span class="count">${t.rows.length}</span></h3><span class="group-hint">${html(hint)}</span></header><ul class="rows">${t.rows.map((_, i) => row(t, i)).join('')}</ul></section>`
3810
+ }
3811
+ // a kind and how many, in its own words — the state line's and the FYI summary's, each escaped by its caller: a kind's words are brief's constants, and html() holds for them as for a repo's text.
3812
+ // A kind with approved drafts says how many beside its count, `7 ready to merge (3 drafts)`: GitHub will not merge a draft, and the row's own verb is Mark ready (`todos` has each row's own to-do)
3813
+ const said = t => { const d = (t.todos ?? []).filter(o => o.draft).length, kind = kindWord(T, t.kind, t.rows.length === 1 ? 'one' : 'many'); return d ? T('said.drafts', { n: t.rows.length, kind, drafts: plural(T, 'drafts', d) }) : T('said', { n: t.rows.length, kind }) }
3814
+ // FYI, folded at the foot of Needs you and in none of its counts: what has nothing to do but be known (a legacy branch)
3815
+ const fyi = tablesOf('fyi')
3816
+ const fyiBlock = !nFyi ? '' : `<details class="fyi grey"><summary><span class="status-icon">${svg(htmlKind(fyi[0].kind)[1])}</span>${html(T('page.fyi', { kinds: fyi.map(said).join(', ') }))}<span class="group-hint">${html(fyi.map(t => kindWord(T, t.kind, 'hint')).join(' · '))}</span></summary><ul class="rows">${fyi.flatMap(t => t.rows.map((_, i) => row(t, i))).join('')}</ul></details>`
3817
+ // the state line: the three kinds with the most rows, the larger first, a tie in Needs you's order (the sort is stable). With a ⚠ line the list may be short — a repo stale, a GitHub half missing — so
3818
+ // an empty state line says nothing was found, not that nothing needs you, and the strip under it says may be incomplete whatever it counts
3819
+ const top = tablesOf('needs').map((t, i) => [t, i]).sort((x, y) => y[0].rows.length - x[0].rows.length || x[1] - y[1]).slice(0, 3).map(([t]) => said(t))
3820
+ // a repo's group in Streams, Done in window and Other PRs: its initial in one neutral tint (colour is kept for what it means), its name and how many rows it has
3821
+ const repoGroup = (name, nameNode, total, items) => `<section class="repo-group"><h3><span class="repo-avatar">${html([...name][0]?.toUpperCase() ?? '')}</span>${nameNode}${total == null ? '' : `<span class="repo-total">${total}</span>`}</h3>${items}</section>`
3822
+ const byRepo = (t, items) => repoGroup(t.repo, h(t.caption), t.rows.length, items)
3823
+ // a stream: its name, then — a stream whose phases are all merged reads ✓ n/n merged and has no bar — a bar of its phases merged (the bar's d of t, numbers; its width a whole percent, 0–100)
3824
+ // with the stream PR beside it, or the words that stand where there is none; what can start is a note too
3825
+ const stream = ([name, progress, today]) => {
3826
+ const bar = Array.isArray(progress) && progress[0] !== null && typeof progress[0] === 'object' && 'bar' in progress[0] ? progress : null
3827
+ const [done, total] = bar ? [int(bar[0].bar), int(bar[0].of)] : [0, 0]
3828
+ const todo = h(today)
3829
+ return `<div class="stream-item"><div class="stream-name">${h(name)}</div>${bar
3830
+ ? `${done === total ? `<div class="progress-meta done">${html(T('page.merged', { merged: done, total }))}</div>`
3831
+ : `<div class="progress-meta"><span>${html(T('page.phasesMerged'))}</span><span class="fraction">${done}<span> / ${total}</span></span></div><div class="progress-track" role="progressbar" aria-label="${attrOf(T('page.progressAria', { name }))}" aria-valuemin="0" aria-valuemax="${total}" aria-valuenow="${done}"><span class="progress-fill" style="width:${Math.min(100, total ? Math.round(100 * done / total) : 0)}%"></span></div>`}${note(bar[1] ? h(bar[1]) : '')}`
3832
+ : note(h(progress))}${todo ? note(h(T('page.canStart', { list: today }))) : ''}</div>`
3833
+ }
3834
+ // Work in motion: a row a phase, two lines (phase 29) — the phase and the toggle, then its stream and repo across the row — with who and where folded under it, and its session where it has one (phase 21: the app, then a link, a command or Orca's names)
3835
+ const mrow = (t, [stream, phase, where, who, session]) => `<li class="mrow"><span class="phase">${h(phase)}</span><span class="mwhat">${hb(stream)} · ${hb(t.caption)}</span><details class="worker"><summary>${html(T('page.worker'))} ${svg('chevron', 12)}</summary><dl><dt>${html(T('page.dt.worker'))}</dt><dd>${h(who) || html(T('page.dt.none'))}</dd><dt>${html(T('page.dt.workspace'))}</dt><dd>${h(where) || html(T('page.dt.none'))}</dd>${session ? `<dt>${html(T('page.dt.session'))}</dt><dd>${h(session)}</dd>` : ''}</dl></details></li>`
3836
+ const doneRow = ([stream, what, at]) => `<div class="stream-item"><div class="stream-name">${h(stream)}</div><div class="stream-note"><span>${h(what)}</span> · <span>${h(at)}</span></div></div>`
3837
+ const needs = tablesOf('needs'), streams = tablesOf('streams'), going = tablesOf('going'), finished = tablesOf('done'), other = tablesOf('other'), notes = sec('notes')
3838
+ // Other PRs: a group a repo (a table of three columns, in the markdown), each PR with when it merged
3839
+ const otherRepos = new Map()
3840
+ for (const [repo, pr, at] of other[0]?.rows ?? []) { const k = attr(repo); if (!otherRepos.has(k)) otherRepos.set(k, { repo, rows: [] }); otherRepos.get(k).rows.push([pr, at]) }
3841
+ const when = header.replace(/^wuwei brief · /, '')
3842
+ const anchors = [['needs-you', T('nav.needs'), nNeeds], ['streams', T('nav.streams'), nStreams], ['in-progress', T('nav.going'), nGoing], ...notes ? [['sources', T('nav.sources'), notes.tables[0].rows.length]] : []]
3843
+ const later = [...finished.length ? [`<section id="done" aria-labelledby="done-title">${heading('done', T('page.h.done'), count('done'), T('page.done.note'))}<div class="panel">${finished.map(t => byRepo(t, t.rows.map(doneRow).join(''))).join('\n')}</div></section>`] : [],
3844
+ ...other.length ? [`<section id="other-prs" aria-labelledby="other-title">${heading('other', T('page.h.other'), count('other'), T('page.other.note'))}<div class="panel">${[...otherRepos.values()].map(g => repoGroup(attr(g.repo), `<strong>${h(g.repo)}</strong>`, null,
3845
+ g.rows.map(([pr, at]) => `<div class="stream-item"><div class="stream-name">${h(pr)}</div><div class="stream-note">${h(T('page.mergedAt', { at }))}</div></div>`).join(''))).join('\n')}</div></section>`] : []]
3846
+ // the theme switch (phase 24, the user: "a switch on the page"): System, Light, Dark — three radios of one group and their labels, in the strip, with no script; the CSS above (:has on the root) does the rest. System is
3847
+ // checked on load, so the page follows the system until a choice is made, and nothing is remembered after it — no script, no storage. The only inputs the page has, and no form: a radio is read by the browser alone
3848
+ const themeSwitch = `<span class="theme" role="radiogroup" aria-label="${att(T('page.theme'))}">${['system', 'light', 'dark'].map(k => [k, T(`page.theme.${k}`)]).map(([k, label]) => `<input type="radio" name="theme" id="theme-${k}"${k === 'system' ? ' checked="checked"' : ''}><label for="theme-${k}">${html(label)}</label>`).join('')}</span>`
3849
+ const out = ['<!doctype html>', `<html lang="${html(lang)}">`, '<head>', '<meta charset="utf-8">', '<meta name="referrer" content="no-referrer">', `<meta http-equiv="Content-Security-Policy" content="${html(HTML_CSP)}">`, '<meta name="viewport" content="width=device-width, initial-scale=1">',
3850
+ `<meta name="description" content="${att(T('page.description'))}">`, `<title>${html(`wuwei brief · ${ymd}`)}</title>`, `<style>${HTML_STYLE}</style>`, '</head>', '<body>',
3851
+ `<a class="skip" href="#main">${html(T('page.skip'))}</a>`,
3852
+ `<div class="topstrip"><div class="wrap"><span class="brand"><span class="brand-logo">w</span>wuwei</span><nav aria-label="${att(T('page.nav'))}">${anchors.map(([to, label, n]) => `<a href="#${to}">${html(label)} <span class="n">${n}</span></a>`).join('')}</nav>${themeSwitch}</div></div>`,
3853
+ '<main class="wrap" id="main">',
3854
+ `<header class="state"><div class="state-line"><h1>${html(top.length ? top.join(' · ') : T(warn.length ? 'page.nothingFound' : 'page.none'))}</h1><time datetime="${ymd}">${html(dated)}</time></div>`,
3855
+ `<p class="strip">${html(plural(T, 'strip.need', nNeeds))} · ${html(plural(T, 'strip.streams', nStreams))} · ${html(T('strip.going', { n: nGoing }))}${stale ? ` · <span class="stale">${html(T('strip.stale', { n: stale }))}</span>` : ''}${warn.length ? ` · ${html(T('strip.incomplete'))}` : ''}</p></header>`,
3856
+ ...warn.length ? [`<section class="banner" aria-label="${att(T('page.warnings'))}"><ul>`, ...warn.map(w => `<li>⚠ ${h(w)}</li>`), '</ul></section>'] : [],
3857
+ '<div class="layout">', '<div class="col-main">',
3858
+ `<section id="needs-you" class="needs" aria-labelledby="needs-title">${heading('needs', T('page.h.needs'), nNeeds, T('page.needs.note'), T(warn.length ? 'page.needs.kickerWarn' : 'page.needs.kicker'))}<div class="panel">${nNeeds ? needs.map(group).join('\n') : empty(T(warn.length ? 'page.needs.emptyWarn' : 'page.needs.empty'))}${fyiBlock}</div></section>`,
3859
+ ...later, // Done in window and Other PRs: under Needs you, in its column — each column is as tall as its own sections (phase 27)
3860
+ '</div>', '<div class="col-side">',
3861
+ `<section id="in-progress" class="motion" aria-labelledby="running-title">${heading('running', T('page.h.going'), nGoing, T('page.going.note'), nGoing ? plural(T, 'page.repos', going.length) : '')}<div class="panel">${nGoing ? `<ul class="mrows">${going.flatMap(t => t.rows.map(r => mrow(t, r))).join('\n')}</ul>` : empty(T('page.going.empty'))}</div></section>`,
3862
+ `<section id="streams" class="streams" aria-labelledby="streams-title">${heading('streams', T('page.h.streams'), nStreams, T('page.streams.note'))}<div class="panel">${nStreams ? streams.map(t => byRepo(t, t.rows.map(stream).join(''))).join('\n') : empty(T('page.streams.empty'))}${sec('streams')?.after ? `<p class="legend">${h(sec('streams').after)}</p>` : ''}</div></section>`,
3863
+ '</div>', '</div>',
3864
+ ...notes ? [`<details class="sources" id="sources"><summary>${html(T('page.sources.title'))} <span>${html(T('page.sources.span'))}</span></summary><div class="source-content"><p>${html(notes.intro)}</p><table><thead><tr>${notes.tables[0].head.map(c => `<th>${html(c)}</th>`).join('')}</tr></thead><tbody>`,
3865
+ ...notes.tables[0].rows.map(r => `<tr>${r.map(c => `<td>${h(c)}</td>`).join('')}</tr>`), '</tbody></table></div></details>'] : [],
3866
+ '<footer class="footer">', `<span><strong>wuwei brief</strong> · ${html(T('page.footer'))}</span>`, `<span>${html(when.replace(/^./, c => c.toUpperCase()))}</span>`,
3867
+ ...markLine ? [`<p class="mark">${html(markLine)}</p>`] : [], // what --mark did: in the footer, as the markdown has it after the report
3868
+ '</footer>', '</main>', '</body>', '</html>']
3869
+ return out.join('\n')
3870
+ }
3871
+
3872
+ // a POSIX shell word: single-quoted, an embedded ' as '\''
3873
+ const shq = t => `'${String(t).replace(/'/g, `'\\''`)}'`
3874
+ // --schedule (SPEC goal 10): the schedulers it prints a config for, each with the line that tells them apart
3875
+ const SCHEDULERS = [
3876
+ ['launchd', 'macOS · runs while you are logged in'],
3877
+ ['cron', 'any Unix'],
3878
+ ['claude-desktop', 'Claude summarises it · set up in the app'],
3879
+ ]
3880
+ const SCHEDULE_USAGE = 'usage: wuwei brief <path>… --schedule <launchd|cron|claude-desktop> [--at HH:MM] [--stale 72h]'
3881
+ const BRIEF_USAGE = 'usage: wuwei brief <path>… [--json | --html | --md] [--write] [--open] [--stale 72h] [--since 24h|7d|<date>] [--mark] [--if-due HH:MM]'
3882
+ // …and what the language flags (phase 30) say when they are misused: BRIEF_USAGE is what every error of the flags before them printed, and stays as it was
3883
+ const LANG_USAGE = 'usage: wuwei brief <path>… --lang <code> (zh-TW built in; ~/.wuwei/brief/lang/<code>.json for any other)\n wuwei brief --lang-template [<code>] (prints the words to translate)'
3884
+ // a time of day, as --at and --if-due take it: 8:45 and 08:45, up to 23:59
3885
+ const TIME_OF_DAY = /^([01]?\d|2[0-3]):[0-5]\d$/
3886
+ // --if-due (phase 25): the latest HH:MM, local time, that is not after `now` — today's, or yesterday's while today's has not come round. By the calendar, setDate and not 24 hours back,
3887
+ // so a day with a clock change in it (23 or 25 hours long) is still yesterday's HH:MM. Yesterday's is built from the date, not from today's time moved back a day (round 1): when today's HH:MM falls in
3888
+ // a gap (02:30 as the clocks go forward) setHours has already carried it on to 03:30, and a day back from that is 03:30 again, an hour after yesterday's 02:30
3889
+ function lastDue(now, hhmm) {
3890
+ const [h, m] = hhmm.split(':').map(Number), at = back => { const d = new Date(now); d.setDate(d.getDate() - back); d.setHours(h, m, 0, 0); return d.getTime() }
3891
+ return at(0) > now ? at(1) : at(0)
3892
+ }
3893
+ // when the record says a --mark run over these folders last ran (`ranAt`) or last tried and read nothing (`triedAt`), as ms; null when it says none: no such field, none for them, one that is no string or no time, or one after
3894
+ // `now` (a hand-edited or skewed clock: it must not silence the routine). A string where the object is, is an older record's ranAt, before there was one for each folders: it is theirs, and every other folders' too
3895
+ const timeAt = (v, now, folders) => {
3896
+ const raw = typeof v === 'string' ? v : isPlain(v) ? v[folders] : undefined, t = typeof raw === 'string' ? Date.parse(raw) : NaN
3897
+ return Number.isFinite(t) && t <= now ? t : null
3898
+ }
3899
+ // how long after a run that read nothing the next --if-due run waits before trying again (triedAt)
3900
+ const RETRY_MS = 3600e3
3901
+ const isFile = f => { try { return statSync(f).isFile() } catch { return false } }
3902
+
3903
+ // ~/.wuwei/brief/brief.html (phase 19, the user: "default 就是 html"): the page, the one file brief writes beside its record. It holds the user's repos and PRs, so it is private like the
3904
+ // crontab's backup: made under umask 077 (the directory too, when it is new) and mode 600 — through writeAtomic: the temp file is renamed over the last page, so a reader never sees half of one and an older,
3905
+ // world-readable page is replaced, not kept. Throws if it cannot write: the caller says so and carries on, as with the record
3906
+ const pagePath = () => join(wuweiHome(), 'brief', 'brief.html')
3907
+ function writePage(page) {
3908
+ const file = pagePath(), was = process.umask(0o077)
3909
+ try { mkdirSync(dirname(file), { recursive: true }); writeAtomic(file, page, 0o600) } finally { process.umask(was) }
3910
+ return file
3911
+ }
3912
+ // the page in the browser, for a person at a terminal: macOS's `open`, else xdg-open, started with no shell — argv is [opener, path], so no part of a path is parsed — detached and with its streams
3913
+ // ignored, so a browser that starts in the foreground (xdg-open's fallback) neither holds brief nor dies with it, with the env and the working directory gh, ssh and orca are given (phase 23, C5: no GH_TOKEN, GITHUB_TOKEN
3914
+ // or GH_DEBUG, and the home directory, not the checkout brief was started in). Waits for the hand-off, 2s at most. Resolves { bin } when the opener came back 0,
3915
+ // { bin, pending } when it is still running at 2s (it may yet open the page, or fail: brief cannot say it was opened), else { bin, why } — not found, its exit status. Said, never thrown: the page is written either way
3916
+ function openPage(file) {
3917
+ const bin = process.platform === 'darwin' ? 'open' : 'xdg-open'
3918
+ return new Promise(done => {
3919
+ let child
3920
+ try { child = spawn(bin, [file], { stdio: 'ignore', detached: true, cwd: ghCwd(), env: NO_ENV_TOKEN }) } catch (e) { return done({ bin, why: e.message }) }
3921
+ const end = r => { clearTimeout(timer); child.unref(); done({ bin, ...r }) }, timer = setTimeout(() => end({ pending: true }), 2000)
3922
+ child.once('error', e => end({ why: e.code === 'ENOENT' ? `${bin} not found` : e.message }))
3923
+ child.once('exit', (code, sig) => end(code === 0 ? {} : { why: `${bin} ${sig ? `was ended by ${sig}` : `exited ${code}`}` }))
3924
+ })
3925
+ }
3926
+
3927
+ // ~/.wuwei/brief/run.lock (round 1): "<pid> <ISO time>", at mode 600 under umask 077 as the page is, made by a --if-due run that is not a skip and removed when it ends — two routines that start at the
3928
+ // same second (launchd's calendar and the desktop task's trigger, both at --at's default) would both pass the ranAt check, and both run. One that finds a lock that is live — its run is still going, or it is not yet 15 minutes old —
3929
+ // skips. A pid that is alive holds it only for a day: a pid is reused, and a lock a crashed run left (the power cut mid-run) must not outlive it for ever. It is made whole before anyone can see it: written to a file of its own
3930
+ // and linked to run.lock, which fails EEXIST as `wx` does when the lock is there (a `wx` create is empty until the write that follows, a millisecond on Node 20, and a run that read it in that gap found no pid and no time, took the
3931
+ // lock for a stale one and removed it — CI ran both runs of a pair). A lock that holds no time all the same (one made by hand) is as old as its file. Taking it can fail for want of a directory to make it in, or a file system
3932
+ // that cannot link: said, and the run goes on without — the lock is a courtesy to the routine beside it, and the page and the record will say they cannot be written either. Two runs that both find the same stale lock may both
3933
+ // remove it: the second's removal can take away the lock the first has just made, and both run — a window of a millisecond, once, after a lock was left behind
3934
+ const lockPath = () => join(wuweiHome(), 'brief', 'run.lock')
3935
+ const LOCK_FRESH_MS = 15 * 60e3, LOCK_ALIVE_MS = 24 * 3600e3
3936
+ const pidAlive = pid => { try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' } }
3937
+ // { } when taken; { since } (ms) when another run holds it
3938
+ function takeLock(now) {
3939
+ const file = lockPath(), tmp = `${file}.${randomBytes(6).toString('hex')}.tmp`, was = process.umask(0o077)
3940
+ try {
3941
+ mkdirSync(dirname(file), { recursive: true })
3942
+ writeFileSync(tmp, `${process.pid} ${isoOf(now / 1000)}\n`, { flag: 'wx', mode: 0o600 })
3943
+ for (let tries = 0; tries < 3; tries++) {
3944
+ try { linkSync(tmp, file); return {} } catch (e) { if (e.code !== 'EEXIST') throw e }
3945
+ let held, mtime
3946
+ try { held = readFileSync(file, 'utf8'); mtime = statSync(file).mtimeMs } catch { continue } // gone since: take it
3947
+ const [, pid, iso] = held.match(/^(\d+) (\S+)/) ?? [], at = Date.parse(iso), since = Number.isFinite(at) ? at : mtime, age = now - since
3948
+ // a lock dated after now (a clock set back) is held only by a pid that is alive
3949
+ if ((age >= 0 && age < LOCK_FRESH_MS) || (Number(pid) > 0 && pidAlive(Number(pid)) && age < LOCK_ALIVE_MS)) return { since }
3950
+ rmSync(file, { force: true })
3951
+ }
3952
+ return { since: now }
3953
+ } finally { rmSync(tmp, { force: true }); process.umask(was) }
3954
+ }
3955
+
3956
+ // discover → notice → fetch → collect → record. Exit 1 only for a bad flag, no paths or no
3957
+ // repo under them; a stale repo, a stream `error` are said in the output and exit 0 (SPEC)
3958
+ async function cmdBrief(argv) {
3959
+ const bad = msg => { console.error(`✗ ${msg}`); process.exit(1) }
3960
+ const paths = []
3961
+ let lang = null, langFlag = null, template = false, templateCode = null
3962
+ let json = false, page = false, md = false, write = false, open = false, mark = false, staleMs = 72 * 3600e3, stale = null, since = null, sinceAt = null, schedule = false, kind = null, at = null, ifDue = null
3963
+ for (let i = 0; i < argv.length; i++) {
3964
+ const a = argv[i]
3965
+ if (a === '--json') json = true
3966
+ else if (a === '--html') page = true
3967
+ else if (a === '--md') md = true
3968
+ else if (a === '--write') write = true
3969
+ else if (a === '--open') open = true
3970
+ else if (a === '--mark') mark = true
3971
+ else if (a === '--stale') { stale = argv[++i]; staleMs = parseDuration(stale); if (staleMs == null) bad(`--stale takes a duration like 72h — <N> then s, m, h or d, got '${stale ?? ''}'`) }
3972
+ else if (a === '--since') { since = argv[++i]; sinceAt = sinceStart(since); if (!sinceAt) bad(`--since takes a duration like 24h or 7d, or a date like 2026-10-14 — got '${since ?? ''}'`) }
3973
+ // the next argument is the scheduler only when it is one's name: anything else is a path (a directory may be called cron)
3974
+ else if (a === '--schedule') { schedule = true; if (SCHEDULERS.some(([n]) => n === argv[i + 1])) kind = argv[++i] }
3975
+ else if (a === '--at') { at = argv[++i]; if (!TIME_OF_DAY.test(at ?? '')) bad(`--at takes a time of day, HH:MM from 00:00 to 23:59 — got '${at ?? ''}'`) }
3976
+ else if (a === '--if-due') { ifDue = argv[++i]; if (!TIME_OF_DAY.test(ifDue ?? '')) bad(`--if-due takes a time of day, HH:MM from 00:00 to 23:59 — got '${ifDue ?? ''}'`) }
3977
+ else if (a === '--lang') { langFlag = argv[++i]; if (!LANG_CODE.test(langFlag ?? '')) bad(`--lang takes a language code like en or zh-TW — two lowercase letters, then optionally a - and a region or script of 2–4 letters — got '${plain(langFlag ?? '')}'\n ${LANG_USAGE}`) }
3978
+ // the code after --lang-template is a code or nothing (it reads no repo, so no path follows it): the next argument, unless it is another flag
3979
+ else if (a === '--lang-template') { template = true; if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) templateCode = argv[++i] }
3980
+ else if (a.startsWith('--')) bad(`unknown flag ${a}`)
3981
+ else paths.push(a)
3982
+ }
3983
+ // --lang-template (phase 30) prints the table of words a language is made of, to translate: no report, no repo read, nothing written
3984
+ if (template) {
3985
+ const clash = [[json, '--json'], [page, '--html'], [md, '--md'], [write, '--write'], [open, '--open'], [mark, '--mark'], [schedule, '--schedule'], [ifDue, '--if-due'], [langFlag, '--lang'], [since, '--since'], [stale, '--stale'], [at, '--at']].find(([on]) => on)?.[1]
3986
+ if (clash) bad(`--lang-template does not go with ${clash}: it prints the table of words to translate, not a report\n ${LANG_USAGE}`)
3987
+ if (paths.length) bad(`--lang-template reads no repo: it takes a language code or nothing, not '${plain(paths[0])}'\n ${LANG_USAGE}`)
3988
+ if (templateCode !== null && !LANG_CODE.test(templateCode)) bad(`--lang-template takes a language code like en or zh-TW, or nothing (English) — got '${plain(templateCode)}'\n ${LANG_USAGE}`)
3989
+ let l
3990
+ try { l = loadLang(templateCode ?? 'en', { template: true }) } catch (e) { bad(`--lang-template ${templateCode ?? 'en'}: ${plain(e.message)}`) }
3991
+ if (l.left.length) console.error(`! ${langFile(l.code)}: left out — ${l.left.join('; ')} — those keys are read in English`)
3992
+ return console.log(langTemplate(l))
3993
+ }
3994
+ // the page is the report in another form: not the JSON beside it (stdout is one document), not the routine's config (a text to paste); --md is the markdown, asked for by name, on the same terms.
3995
+ // --write saves the page, which --html prints: one or the other. (--write beside --json or --md is the routines' own — the page and the document — and beside --schedule, like --mark, the routine's)
3996
+ if (page && (json || schedule)) bad(`--html does not go with ${json ? '--json: each prints the report as a document of its own' : '--schedule: it prints a config to paste, not a report'}\n ${BRIEF_USAGE}`)
3997
+ if (md && (json || page || schedule)) bad(`--md does not go with ${schedule ? '--schedule: it prints a config to paste, not a report' : `${json ? '--json' : '--html'}: each prints the report as a document of its own`}\n ${BRIEF_USAGE}`)
3998
+ if (page && write) bad(`--html does not go with --write: --html prints the page, --write saves it to ${pagePath()}\n ${BRIEF_USAGE}`)
3999
+ // --if-due is the routines' own: the config --schedule prints carries it, so beside --schedule it has nothing to decide; and a skip has no page to print, which is all --html is
4000
+ if (ifDue && (schedule || page)) bad(`--if-due does not go with ${schedule ? '--schedule: it prints a config to paste, which carries --if-due itself' : '--html: --html prints the page, and a run that is skipped has none to print'}\n ${BRIEF_USAGE}`)
4001
+ // what stdout gets (phase 19): the document asked for — --json, --html, --md — and with none of them the markdown off a terminal, as always; on a terminal the page, written to ~/.wuwei/brief,
4002
+ // opened, with one line to say where (`doc` is null then). --write saves the page whatever stdout holds, which is what the routines pass: their stdout is the markdown, the page beside it
4003
+ const doc = json ? 'json' : page ? 'html' : md || !onTerminal() ? 'md' : null
4004
+ const toFile = write || doc == null
4005
+ // --open (round 1, S1) shows the page this run writes — on a terminal beside the default, or with --write off one. It is how the Claude desktop task's agent gets the page shown without being allowed an `open`
4006
+ // of its own: that agent runs brief's one command, and a repo's text that reached it (T-2) could not ask it for another. Without a page to show it is a bad flag, and beside --schedule, which prints a config
4007
+ if (open && schedule) bad(`--open does not go with --schedule: it prints a config to paste, not a report\n ${BRIEF_USAGE}`)
4008
+ if (open && !toFile) bad(`--open goes with --write: it shows the page --write saves, and this run writes none (on a terminal the page is written by default — without --md, --json or --html)\n ${BRIEF_USAGE}`)
4009
+ // the language (phase 30): --lang, else config.json's lang, else en — each with the user's file for it laid over its built-in table. A language asked for by --lang that cannot be had is a bad flag; one config.json names is a
4010
+ // warning and English: the report of a routine that nobody is watching is not worth less for a file somebody edited. The same goes for a run with --if-due, which is the routines' own — every command --schedule writes
4011
+ // carries it, and the `--lang` beside it may name a file edited since: one line on stderr and the English report, where an interactive --lang stays an error. `chosen` is a language somebody named, by flag or by config:
4012
+ // what the routines carry
4013
+ const said = l => { if (l.left.length) console.error(`! ${langFile(l.code)}: left out — ${l.left.join('; ')} — those keys are read in English`); return l }
4014
+ let chosen = null
4015
+ if (langFlag) {
4016
+ try { chosen = said(loadLang(langFlag)) } catch (e) {
4017
+ if (!ifDue) bad(`--lang ${langFlag}: ${plain(e.message)}`)
4018
+ console.error(`! --lang ${langFlag}: ${plain(e.message)} — English is used`)
4019
+ }
4020
+ }
4021
+ else {
4022
+ const c = readConfig()
4023
+ if (c.warn) console.error(`! ${configFile()}: ${c.warn} — English is used`)
4024
+ else if (c.lang) { try { chosen = said(loadLang(c.lang)) } catch (e) { console.error(`! ${configFile()}: lang '${c.lang}' is not used — ${plain(e.message)} — English is used`) } }
4025
+ }
4026
+ lang = chosen
4027
+ if (!lang) { try { lang = said(loadLang('en')) } catch (e) { console.error(`! ${plain(e.message)} — the built-in English is used`); lang = { code: 'en', words: STRINGS.en, left: [] } } }
4028
+ if (schedule) return cmdSchedule({ kind, at, paths, stale, since, json, bad, langFlag, chosen })
4029
+ if (at != null) bad('--at goes with --schedule: it is the time the routine runs')
4030
+ if (!paths.length) bad(`name the paths to look under\n ${BRIEF_USAGE}`)
4031
+ // the repos are looked for before --if-due decides (round 1): a path that is gone is the routine's typo, and a skip that never looked would hide it for as long as another path answered. discoverRepos only walks
4032
+ // directories and asks each checkout for its origin — no fetch, no mark — so a skip is still nothing new: the cost is the walk, at each of cron's ticks
4033
+ const groups = discoverRepos(paths)
4034
+ if (!groups.length) bad(`no .wuwei/ under ${paths.join(', ')}`)
4035
+ // what ranAt is kept under (round 1): the folders the routine was given, by real path, each once, sorted — the same set whichever order or spelling
4036
+ const folders = [...new Set(paths.map(p => realOr(resolve(p))))].sort().join(', ')
4037
+ // --if-due (phase 25, the user: every routine that missed its time runs once, and two side by side do the work once): decided from the record alone — a run whose ranAt is at or after
4038
+ // the latest HH:MM does nothing new: nothing fetched, marked or written. It prints one line, or with --json { version, skipped, page }, and shows the page already written — if there is one, and --open asks for it
4039
+ // (or a terminal's default) — which is how Run now, after a day's run, still opens the page. A look without --mark reads ranAt all the same, and writes none (below)
4040
+ // The run that is not a skip takes run.lock (above) from here to its end — everything that can exit is before it — and looks again once it holds it: the run it waited for may have written ranAt and gone between
4041
+ // its first look and the lock. A skip on the lock shows no page: the one there is the last run's, and the one that holds the lock is making today's
4042
+ let locked = false
4043
+ try {
4044
+ if (ifDue) {
4045
+ const why = () => {
4046
+ const t = Date.now(), rec = readRecord(), ran = timeAt(rec.ranAt, t, folders), tried = timeAt(rec.triedAt, t, folders)
4047
+ return ran != null && ran >= lastDue(t, ifDue) ? `ran at ${fmtAt(ran)}`
4048
+ : tried != null && t - tried < RETRY_MS ? `tried at ${fmtAt(tried)}, nothing could be read; next try after ${fmtAt(tried + RETRY_MS)}` : null
4049
+ }
4050
+ const skip = async (skipped, show = true) => {
4051
+ const there = isFile(pagePath()) ? pagePath() : null
4052
+ console.log(json ? JSON.stringify({ version: 1, skipped, page: there }, null, 2) : `skipped: ${skipped} · ${there ? `page: ${there}` : 'no page yet'}`)
4053
+ const r = show && there && (open || doc == null) ? await openPage(there) : null
4054
+ if (r?.why) console.error(`! could not open the page: ${r.why}`)
4055
+ }
4056
+ let skipped = why()
4057
+ if (skipped) return await skip(skipped)
4058
+ let got = null
4059
+ try { got = takeLock(Date.now()) } catch (e) { console.error(`! could not take ${lockPath()}: ${e.message} — a routine that starts beside this one will not wait for it`) }
4060
+ if (got?.since != null) return await skip(`running since ${fmtAt(got.since)}`, false)
4061
+ locked = got != null
4062
+ skipped = why()
4063
+ if (skipped) return await skip(skipped)
4064
+ }
4065
+ // read once, before any fetch: the notice must print first, and fetchAll keeps the last good fetch from it
4066
+ const record = readRecord()
4067
+ const seen = o => { const c = record.origins[o]?.checkouts; return Array.isArray(c) ? c : [] }
4068
+ const unseen = groups.flatMap(g => g.origin == null ? [] : g.checkouts.map(c => ({ origin: g.origin, checkout: c, real: realOr(c) })).filter(u => !seen(u.origin).includes(u.real)))
4069
+ if (unseen.length) console.error(t3Notice(unseen))
4070
+ const t0 = Date.now()
4071
+ const fetched = await fetchAll(groups, { record })
4072
+ const now = Date.now()
4073
+ // the window, per repo and half (`field`: its git mark, `markedAt`, or its GitHub one): --since for all, else that repo's
4074
+ // mark in the record as READ above (a --mark run's own write comes later and is not its window), else the last 24h —
4075
+ // `markUnreadable` when there was a mark to read and no date came out of it (a hand-edited value, or a record that is no
4076
+ // brief.json at all): not the same as never marked
4077
+ const windowOf = (g, field = 'markedAt') => {
4078
+ const m = record.origins[g.origin]?.[field], mark = typeof m === 'string' ? Date.parse(m) : NaN
4079
+ return sinceAt ? { kind: 'since', start: isoOf(sinceAt(now) / 1000) }
4080
+ : Number.isFinite(mark) ? { kind: 'mark', start: isoOf(mark / 1000) }
4081
+ : { kind: 'default', start: isoOf((now - 864e5) / 1000), ...(record.unreadable || m !== undefined ? { markUnreadable: true } : {}) }
4082
+ }
4083
+ const repos = groups.map((g, i) => collectRepo(g, fetched[i], { staleMs, now, window: windowOf(g) }))
4084
+ const access = await ghAccess(repos, { sshHosts: groups.map(g => g.sshHost) }) // marks the repos no account reads; access.reachable is where a search may go
4085
+ await ghSearch(access.reachable, r => windowOf(r, 'githubMarkedAt'))
4086
+ // what the record learns: each origin's last good fetch, and the checkouts warned of — whether or
4087
+ // not their fetch worked, or a repo that never fetches would be warned of on every run. With --mark, a
4088
+ // fresh origin also learns its mark (SPEC goal 8): the moment this run STARTED, before any fetch, so
4089
+ // what lands while it runs is in the next window twice at worst, never in none. A repo whose fetch
4090
+ // failed keeps the mark it had — this run showed it only what its last good fetch saw. So does a clone whose
4091
+ // fetch works and cannot bring wuwei/* (gitFetch's `wuwei`: a shallow clone's own refspec is one branch, a mirror's
4092
+ // is the clone's own and gets no entry of brief's): a stream branch that merged since is in nothing this run saw,
4093
+ // and a mark moved past it would skip it for good. A shallow clone that tracks every head is not one of them. The GitHub
4094
+ // half has a mark of its own, `githubMarkedAt`, moved the same way for each repo whose GitHub half answered (`ok`) — a
4095
+ // repo whose fetch failed still gets it, and one whose query failed or that gh cannot read keeps the one it had. It is not
4096
+ // the run's start but GH_INDEX_LAG before it: GitHub's search index lags, so a PR that merged just before or during this
4097
+ // run may not have been found, and a mark at the start would cut it out of the next window for good
4098
+ const markedAt = isoOf(t0 / 1000)
4099
+ const unreached = groups.map((g, i) => mark && g.origin != null && fetched[i].ok && !fetched[i].wuwei)
4100
+ const learned = Object.fromEntries(groups.flatMap((g, i) => {
4101
+ if (g.origin == null) return []
4102
+ const fields = fetched[i].ok ? { fetchedAt: fetched[i].fetchedAt, ...(mark && !unreached[i] ? { markedAt } : {}) } : {}
4103
+ if (mark && repos[i].github?.state === 'ok') fields.githubMarkedAt = isoOf((t0 - GH_INDEX_LAG) / 1000)
4104
+ const warned = unseen.filter(u => u.origin === g.origin).map(u => u.real)
4105
+ if (warned.length) fields.checkouts = warned
4106
+ return Object.keys(fields).length ? [[g.origin, fields]] : []
4107
+ }))
4108
+ // ranAt (phase 25) goes in the same write, for a --mark run that moved a mark — a git one, or (round 1) the GitHub half's, which a repo that is always left by git while gh answers moves every run — or had no repo with an
4109
+ // origin, which has none to move. A run that moved none (every repo left, and gh absent, logged out or failing) did not do the day's work and writes no ranAt, so the next --if-due run tries again — but not at once: with
4110
+ // --if-due it writes triedAt, which that run reads as "wait an hour" (RETRY_MS), so a tick every quarter of an hour does not repeat the whole brief while the network is down. A run that did the day's work clears its
4111
+ // folders' triedAt. A run without --mark is a look and writes neither, nor does a --mark run with no --if-due, which is nobody's retry; the ones the record holds stay (writeRecord keeps the top level)
4112
+ const ran = mark && (Object.values(learned).some(f => f.markedAt || f.githubMarkedAt) || groups.every(g => g.origin == null))
4113
+ const top = ran ? { ranAt: { [folders]: markedAt }, triedAt: { [folders]: undefined } } : mark && ifDue ? { triedAt: { [folders]: markedAt } } : {}
4114
+ let wrote = true
4115
+ if (Object.keys(learned).length || Object.keys(top).length)
4116
+ try { writeRecord(learned, top) } catch (e) { wrote = false; console.error(`! could not write ${recordPath()}: ${e.message} — the next run will say "never fetched", and warn again${mark ? '; no mark was moved' : ''}`) }
4117
+ const shown = plain(repos) // everything above this line needs the raw text; nothing below does
4118
+ const github = plain(access.github)
4119
+ // what --mark did, said once the write is known: a line of its own after the report — on stderr with --json,
4120
+ // which keeps stdout one JSON document. An origin with no remote has no record to mark, and is neither. Each
4121
+ // reason names the repos it left: `left a, b (stale); c (no wuwei/* fetched)`. `marked N` counts the git marks; the GitHub
4122
+ // half follows after a ` · `, as the repos whose GitHub mark did not move, by reason, the same way: `GitHub: left b (no access);
4123
+ // c, d (failed)` — a repo on github.com, one ghAccess gave a slug, whose half did not answer: gh not there (then that is every one of them), refused, or failed.
4124
+ // An ssh alias has a slug only once ssh said so, which gh absent or logged out never asks (the ⚠ line has said the half is missing; no behaviour hangs on it).
4125
+ // A repo not on github.com has no GitHub mark to move, as one with no remote has no git mark
4126
+ const markedN = shown.filter((r, i) => r.origin != null && !r.stale && !unreached[i]).length
4127
+ // each reason is a key of the table (mark.why.*): git's two, then gh's absence by the state it is in, then GitHub's own two. The line is said in T's words — the language's for the report (the page's footer, the markdown's last line:
4128
+ // the markdown's with the words escaped and the origins as they were, printed raw as they always were), English on stderr, where it is for the person who ran brief and not part of the report
4129
+ const ghWhyLeft = r => ({ 'not-found': 'mark.why.ghNotFound', 'not-authenticated': 'mark.why.ghNotAuth', failed: 'mark.why.ghFailed' })[github.state] ?? { 'no-access': 'mark.why.noAccess', failed: 'mark.why.failed' }[r.github?.state]
4130
+ const markText = T => {
4131
+ const item = (why, rs) => T('mark.item', { origins: rs.map(r => r.origin).join(', '), why: T(why) })
4132
+ const left = [['mark.why.stale', shown.filter(r => r.stale)], ['mark.why.unreached', shown.filter((r, i) => unreached[i])]].filter(([, rs]) => rs.length).map(([why, rs]) => item(why, rs))
4133
+ const ghLeft = ['mark.why.ghNotFound', 'mark.why.ghNotAuth', 'mark.why.ghFailed', 'mark.why.noAccess', 'mark.why.failed'].map(why => [why, shown.filter((r, i) => access.slugs[i] && r.github?.state !== 'ok' && ghWhyLeft(r) === why)])
4134
+ .filter(([, rs]) => rs.length).map(([why, rs]) => item(why, rs))
4135
+ return `${T('mark.marked', { repos: plural(T, 'repos', markedN) })}${left.length ? `; ${T('mark.left', { list: left.join('; ') })}` : ''}${ghLeft.length ? ` · ${T('mark.ghLeft', { list: ghLeft.join('; ') })}` : ''}`
4136
+ }
4137
+ const markLine = mark && wrote ? markText(mkT()) : null, markPage = markLine && markText(mkT(lang.words)), markMd = markLine && markText(mkT(lang.words, esc))
4138
+ if ((json || doc == null) && markLine) console.error(markLine)
4139
+ // the terminal's width (phase 15; termCols's, phase 17) — and none at all off a terminal (TERM=dumb as well), where nothing is cut: the routine's report, a
4140
+ // `> file`, a pipe to an agent keep every word. FIT_MIN at the least
4141
+ const width = onTerminal() ? Math.max(FIT_MIN, termCols()) : 0
4142
+ const data = { fetchMs: now - t0, now, since, github, slugs: access.slugs, words: lang.words, code: lang.code }
4143
+ const markdown = () => [renderMarkdown(shown, { ...data, osc8: onTerminal(), width }), ...(markMd ? ['', ...width ? wrapWords(markMd, width) : [markMd]] : [])].join('\n')
4144
+ // the page is written after the record and the mark line are settled: a page that cannot be written (no home directory to make, the file a directory) costs the page, said on stderr, exit 0 — as the
4145
+ // record's write does — and where the page was to be stdout's document, the markdown comes out instead, so the run is not lost
4146
+ let file = null
4147
+ if (toFile) {
4148
+ try { file = writePage(renderHtml(shown, { ...data, markLine: markPage })) } catch (e) { console.error(`! could not write ${pagePath()}: ${e.message}`) }
4149
+ }
4150
+ if (doc != null) console.log(doc === 'json' ? JSON.stringify({ version: 1, generatedAt: new Date(now).toISOString(), github, repos: shown }, null, 2)
4151
+ : doc === 'html' ? renderHtml(shown, { ...data, markLine: markPage }) : markdown())
4152
+ // the page is shown only when this run wrote it — a page left by an earlier run is never taken for this one — on a terminal by default, and where --open asks
4153
+ const r = file && (open || doc == null) ? await openPage(file) : null
4154
+ if (r?.why) console.error(`! could not open the page: ${r.why}`)
4155
+ if (doc == null) console.log(file ? `wrote ${file}${r.why ? '' : r.pending ? ` — handed to ${r.bin}` : ' — opened in your browser'}` : markdown())
4156
+ } finally {
4157
+ if (locked) rmSync(lockPath(), { force: true })
4158
+ }
4159
+ }
4160
+
4161
+ // `--schedule`: which scheduler, then its config — decided from the arguments alone, before discover: no repo is read,
4162
+ // nothing fetched, no record written (it prints, the user installs). No name and no terminal to ask in is a usage error,
4163
+ // as is --json (the config is text to paste, not a report); --mark is the routine's, so a --mark given is no different
4164
+ async function cmdSchedule({ kind, at, paths, stale, since, json, bad, langFlag, chosen }) {
4165
+ if (json) bad('--json does not go with --schedule: it prints a config to paste, not a report')
4166
+ // the routine's window is its mark — each run picks up where the last one started; a --since baked in would replace that on every run
4167
+ if (since) bad('--since does not go with --schedule: the routine reads from its last mark, and a --since would replace that on every run')
4168
+ // what it prints is a POSIX shell line (/usr/bin/env, single quotes, a PATH joined with :) — on Windows it could only be printed broken
4169
+ if (process.platform === 'win32') bad('--schedule prints a POSIX shell routine (launchd, cron, a Unix shell for the Claude desktop app) — not for Windows')
4170
+ if (!paths.length) bad(`name the paths the routine looks under\n ${SCHEDULE_USAGE}`)
4171
+ const width = Math.max(...SCHEDULERS.map(([n]) => n.length)) + 2
4172
+ if (!kind && !isInteractive()) bad(`--schedule needs a scheduler — one of:\n${SCHEDULERS.map(([n, d]) => ` ${n.padEnd(width)}${d}`).join('\n')}\n ${SCHEDULE_USAGE}`)
4173
+ if (!kind) {
4174
+ console.log(`Which scheduler?\n${SCHEDULERS.map(([n, d], i) => ` ${i + 1} ${n.padEnd(width)}${d}`).join('\n')}`)
4175
+ const pick = await promptText('>')
4176
+ kind = SCHEDULERS[Number(pick) - 1]?.[0] ?? SCHEDULERS.find(([n]) => n === pick)?.[0]
4177
+ if (!kind) bad(`pick 1, 2 or 3 (or a name) — got '${pick}'`)
4178
+ console.log('')
4179
+ }
4180
+ const [hour, minute] = (at ?? '08:45').split(':').map(Number)
4181
+ const hhmm = [hour, minute].map(n => String(n).padStart(2, '0')).join(':')
4182
+ // cron's ticks (round 1): every 15 minutes, shifted 7 minutes off --at's minute — `*/15` would tick at :45 for the default 08:45, the very minute launchd's calendar and the desktop task's trigger start theirs, and
4183
+ // two of the three would run at once. 7 is not a multiple of 15, so no tick is in --at's minute whatever it is: (minute + 7) % 15, then every 15
4184
+ const ticks = [0, 1, 2, 3].map(k => (minute + 7) % 15 + 15 * k)
4185
+ // the command is this node and this script by real path, never a `wuwei` lookup: the scheduler's PATH is not the shell's
4186
+ const script = realpathSync(fileURLToPath(import.meta.url))
4187
+ // PATH: the directories git, gh, node and ssh are found in now (git execs ssh and its helpers through it), in the order the
4188
+ // shell's PATH has them, so each still resolves to the file found — a directory holding two of them cannot put the wrong
4189
+ // one first — and as found, not by real path (Homebrew's bin/ links into a Cellar an upgrade removes); then /usr/bin:/bin
4190
+ const dirs = (process.env.PATH ?? '').split(delimiter).filter(d => isAbsolute(d))
4191
+ const which = bin => dirs.find(d => { try { accessSync(join(d, bin), constants.X_OK); return statSync(join(d, bin)).isFile() } catch { return false } })
4192
+ const gh = which('gh'), rank = d => { const k = dirs.indexOf(d); return k < 0 ? Infinity : k }
4193
+ // node by the path PATH found it at, when that is this node and its directory is a real one: process.execPath is the real path, and
4194
+ // Homebrew's is a Cellar directory an upgrade removes (then every run would fail, its report left empty) — while a directory that is
4195
+ // itself a link can be a shell's own (fnm's per-shell `fnm_multishells/<n>/bin`, gone at logout), and then the real path outlives it.
4196
+ // A node on PATH that is not this one (a version manager's shim) is not the routine's: this one is, and the tools line says so
4197
+ const nodeDir = which('node'), real = p => { try { return realpathSync(p) } catch { return null } }
4198
+ const nodeBin = nodeDir && real(nodeDir) === nodeDir && real(join(nodeDir, 'node')) === process.execPath ? join(nodeDir, 'node') : process.execPath
4199
+ const tools = { git: which('git'), gh, node: dirname(nodeBin), ssh: which('ssh') }
4200
+ const found = [...new Set(Object.values(tools).filter(Boolean))].sort((a, b) => rank(a) - rank(b))
4201
+ const PATH = [...found, '/usr/bin', '/bin'].join(':').replace(/(^|:)\/usr\/bin(:\/bin)?:\/usr\/bin:\/bin$/, '$1/usr/bin:/bin')
4202
+ // every word that is not brief's own is single-quoted, the PATH= value with them. The env tokens are unset (T-5): the
4203
+ // routine runs on gh's stored login, never a token baked in where it sits in plain text (brief drops them for gh as well)
4204
+ // --write beside --mark (phase 19): the routine's stdout is still the markdown, and the page is written beside it
4205
+ // --lang rides on the command when it was given (phase 30; its code is [a-z]{2}(-[A-Za-z]{2,4})?, nothing to quote): a language from config.json is not baked into the command — the routine reads the file itself, so its report follows a change in it at
4206
+ // each run. The desktop prompt's `Reply in` line (below) is text, written here: it is the language as it is when --schedule runs, and a change in config.json reaches the desktop agent's reply only when --schedule claude-desktop is run again
4207
+ const args = [...paths.map(p => shq(resolve(p))), '--mark', '--write', ...stale ? ['--stale', shq(stale)] : [], ...langFlag ? ['--lang', langFlag] : []]
4208
+ const command = (...more) => `/usr/bin/env -u GH_TOKEN -u GITHUB_TOKEN ${shq(`PATH=${PATH}`)} ${shq(nodeBin)} ${shq(script)} brief ${[...args, ...more].join(' ')}`
4209
+ // every routine is --if-due the time of day (phase 25): one that finds a --mark run since then — its own, another routine's — does nothing new, so a routine that runs late (a Mac asleep, off, logged out) runs once,
4210
+ // and two side by side do the work once. cron's is the bare `run`: it runs outside the login session, where `open` has no browser to reach; launchd's and the desktop task's add --open
4211
+ const due = ['--if-due', hhmm]
4212
+ const run = command(...due)
4213
+ // a control character anywhere in it — a path, the directory a relative one resolves under, a tool's directory: XML reads a \r back as
4214
+ // \n, a prompt line breaks, cron cuts the line, and the routine would name another path
4215
+ if (/[\x00-\x08\x0a-\x1f\x7f]/.test(run)) bad('a path with a control character (a line break, a carriage return…) cannot go in a routine — not one given, nor the directory brief runs from, nor a directory on PATH; rename it')
4216
+ // the routine runs under umask 077 (round 1, D5): ~/.wuwei/brief is made 700 and the report and the stderr log written 600 — they name the user's repos and PRs, as the page does, and a scheduler's
4217
+ // own umask is 022. In the routine's own `sh -c`, so it reaches the `>` and `>>` redirections that follow
4218
+ const prep = 'umask 077 && mkdir -p "$HOME/.wuwei/brief" &&', mkdir = `${prep} exec`
4219
+ // the crontab entry ends with this comment, which sh skips: it is how the install line finds an earlier entry to replace, and the remove line the one to drop.
4220
+ // It names the job's label, so no line a user wrote ends with it (a bare `# wuwei brief` might); grep matches it at the line's end as a basic regex, where
4221
+ // ( and ) are plain characters and only the dots need escaping — -F could not anchor it
4222
+ const marker = '# wuwei brief (dev.wuwei.brief)', markerRe = `${marker.replace(/\./g, '\\.')}$`
4223
+ // the cron lines save the crontab before they change it, and make the new one from that copy (below). The read goes on only when `crontab -l` works
4224
+ // or fails saying "no crontab" (macOS's and cronie's words; the copy is then empty): any other failure — cron.allow, a sandbox — would make the copy
4225
+ // empty and the crontab written the entry alone, so the line stops, says what failed, and leaves the crontab and the last copy as they were. The read
4226
+ // goes to crontab.new and its stderr to crontab.err, beside the copy, in a subshell whose umask makes them private (the crontab can hold secrets in
4227
+ // its env lines) — and ~/.wuwei/brief too, when it is made there (round 1, D5: it was made outside it, 755 under the user's umask); crontab.new becomes the copy, which is made 600 after — `>|` and mv keep an older copy's mode. The read's failure is a step of the
4228
+ // && list, not its last, so errexit (a .zshrc's `setopt errexit pipefail`) does not end an interactive shell on it. grep exits 1 when it keeps no
4229
+ // line (no crontab, or the entry alone): errexit would end the line there — the entry never printed, the crontab written empty — so its failure is
4230
+ // silenced. Files are written with `>|`: with noclobber on, `>` refuses one an earlier paste left, and the line stops there
4231
+ const bak = '~/.wuwei/brief/crontab.bak', got = '~/.wuwei/brief/crontab.new', err = '~/.wuwei/brief/crontab.err'
4232
+ const save = `(umask 077 && mkdir -p ~/.wuwei/brief && { crontab -l >| ${got} 2>| ${err} || grep -q 'no crontab' ${err} || { { echo 'crontab -l failed, so nothing was changed:'; cat ${err}; } >&2; rm -f ${got} ${err}; exit 1; }; } && rm -f ${err} && mv -f ${got} ${bak} && chmod 600 ${bak}) &&`
4233
+ const keep = `LC_ALL=C grep -v '${markerRe}' ${bak} || true`
4234
+ // cron has no escape for a backslash: a \% in a path would end the command at its % (the rest goes to stdin)
4235
+ if (kind === 'cron' && run.includes('\\%')) bad('a path holding \\% cannot go in a crontab line: cron would cut the command there — rename it, or use launchd')
4236
+ const cronLine = `${`${mkdir} ${run} >> ~/.wuwei/brief/$(date +%F).md`.replace(/%/g, '\\%')} ${marker}`
4237
+ // macOS cron keeps 999 bytes of a command and drops the rest, unterminated quote and all — bytes: a 文 is three
4238
+ const cronBytes = Buffer.byteLength(cronLine)
4239
+ if (kind === 'cron' && cronBytes > 999) bad(`the crontab line would be ${cronBytes} bytes — macOS cron keeps 999; name fewer or shorter paths, or use launchd`)
4240
+ // on stderr, above the config: T-3's warning (ACCEPTANCE), then what would leave the routine broken or half there
4241
+ console.error(t3Notice())
4242
+ if (/[\\/]_npx[\\/]/.test(script)) console.error(`! installed via npx — \`npm i -g ${PKG.name}\` first; the cache gets evicted`)
4243
+ if (!gh) console.error("! gh not on PATH — the GitHub half will be absent from the routine's report")
4244
+ // where each tool the routine will run was found, now — a shell's version manager or sandbox shim first on its PATH is baked in too
4245
+ console.error(` tools ${Object.entries(tools).filter(([, d]) => d).map(([t, d]) => `${t}: ${d}`).join(' · ')}`)
4246
+ for (const p of paths) if (!existsSync(resolve(p))) console.error(`! ${resolve(p)} is not there — the routine would fail on it every day`)
4247
+ // stdout is only what to read and paste (U3, phase 12): a line saying what the routine is, the block that installs it, where it writes, the block that
4248
+ // removes it. A block is shell lines alone — no `#` line: zsh, macOS's shell, runs one as a command unless interactive comments are on (they are off by default)
4249
+ const plist = '~/Library/LaunchAgents/dev.wuwei.brief.plist', job = 'gui/$(id -u)/dev.wuwei.brief'
4250
+ const last = f => `"$HOME/.wuwei/brief/${f}"`, fresh = f => `"$HOME/.wuwei/brief/${f}.new"` // launchd's report and errors, and where a run writes them first
4251
+ const title = how => `wuwei brief — daily at ${hhmm} ${how}, over ${paths.map(p => resolve(p)).join(', ')}`
4252
+ const xml = t => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4253
+ const config = {
4254
+ // the plist goes through a heredoc whose terminator is quoted, so the shell expands nothing in it: `$HOME` stays for launchd's sh -c, &amp; stays XML.
4255
+ // No line of it can be the terminator, PLIST alone: each is a tag, and the paths sit inside the one <string> (single-quoted, no line break — refused above).
4256
+ // sh's `>` keeps latest.md one run's report: launchd's own StandardOutPath appends. But `>` also empties the file before brief starts, so a run that only skips (a login the same day, a tick the other routine's run made
4257
+ // unneeded) would leave its one line where the last report was (round 1): the run writes latest.md.new and stderr.log.new and moves them over the two, unless what it printed is a skip — a first line `skipped: `, which
4258
+ // --if-due prints and a report never starts with — and then they are thrown away. A run that fails prints no report and moves them all the same, so the error is in stderr.log; `exit $s` is brief's status, which
4259
+ // `exec` made the job's before. The plist is written with `>|`: with noclobber on (zsh's setopt noclobber, sh's set -C) a `>` refuses a file that is there, and a second install would load the first one's plist again
4260
+ launchd: () => ({
4261
+ top: title('via launchd (macOS)'),
4262
+ install: { say: `To install, paste this whole block into a terminal — its last line loads the job, and RunAtLoad runs it once now, to check (it moves the mark, unless a run since ${hhmm} already did: it then does nothing new):`,
4263
+ head: '① Install — paste this whole block (it runs once now, and moves the mark)', lines: [
4264
+ // ~/.wuwei/brief is made private (D5): in a subshell, so the umask is not left set in the terminal this is pasted into. Its own line, so errexit ends the block there when it fails
4265
+ 'mkdir -p ~/Library/LaunchAgents',
4266
+ '(umask 077; mkdir -p ~/.wuwei/brief)',
4267
+ `cat >| ${plist} <<'PLIST'`,
4268
+ '<?xml version="1.0" encoding="UTF-8"?>',
4269
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
4270
+ '<plist version="1.0"><dict>',
4271
+ ' <key>Label</key><string>dev.wuwei.brief</string>',
4272
+ ' <key>ProgramArguments</key><array>',
4273
+ ' <string>/bin/sh</string><string>-c</string>',
4274
+ ` <string>${xml(`${prep} ${command('--open', ...due)} > ${fresh('latest.md')} 2> ${fresh('stderr.log')}; s=$?; if grep -q '^skipped: ' ${fresh('latest.md')}; then rm -f ${fresh('latest.md')} ${fresh('stderr.log')}; else mv -f ${fresh('latest.md')} ${last('latest.md')}; mv -f ${fresh('stderr.log')} ${last('stderr.log')}; fi; exit $s`)}</string>`,
4275
+ ' </array>',
4276
+ // RunAtLoad: a login after the Mac was off runs it — launchd.plist(5), "launched once at the time the job is loaded". The calendar covers a Mac that was asleep at the time:
4277
+ // "launchd will start the job the next time the computer wakes up", the intervals that passed "coalesced into one event"
4278
+ ' <key>RunAtLoad</key><true/>',
4279
+ ' <key>StartCalendarInterval</key><dict>',
4280
+ ` <key>Hour</key><integer>${hour}</integer><key>Minute</key><integer>${minute}</integer>`,
4281
+ ' </dict>',
4282
+ '</dict></plist>',
4283
+ 'PLIST',
4284
+ // an earlier install is booted out first, or bootstrap refuses a label already loaded. None is the usual case: launchctl then exits 3, and
4285
+ // its words and status are dropped — with errexit on, the status would end the block there, nothing loaded
4286
+ `launchctl bootout ${job} 2>/dev/null || true`,
4287
+ // no kickstart: bootstrap loads the job and RunAtLoad (below) runs it, and `kickstart -k` restarted that run — killing it, maybe inside a git fetch that leaves a .lock in a repo (round 1)
4288
+ `launchctl bootstrap gui/$(id -u) ${plist}`,
4289
+ ] },
4290
+ notes: [
4291
+ 'Then:',
4292
+ ` runs at ${hhmm} and at every login (RunAtLoad). A Mac asleep at ${hhmm} runs it once on wake, however many times it slept through (launchd.plist(5): "launchd will start the job the`,
4293
+ ' next time the computer wakes up … those events will be coalesced into one event"); one that was off or logged out runs it at the next login',
4294
+ ` twice every run is --if-due ${hhmm}. Routines over the same folders do the day's work once, whichever runs first after ${hhmm} — this one, cron's, the Claude desktop task's —`,
4295
+ ' and the others do nothing new: one that finds a run since then says so in one line and shows the page that is there; two that start together are told apart by ~/.wuwei/brief/run.lock,',
4296
+ ' and the one that finds it held skips ("running since …") and tries again at its next time. It does not cover routines over different folders, which each do their own (the page is one file:',
4297
+ ' the last to finish is the one you see), nor a run that read nothing (no network, say): it counts for nothing — cron tries again at its first tick an hour on, launchd and the desktop task at their next time',
4298
+ ' page each run that does the work shows it in your browser: a LaunchAgent runs in your login session, where `open` reaches the browser',
4299
+ ' report ~/.wuwei/brief/latest.md and the page ~/.wuwei/brief/brief.html (each run that does the work replaces both; a run that skips leaves both as they were)',
4300
+ ' errors ~/.wuwei/brief/stderr.log (the last run that did the work, or failed: a run that skips leaves it too)',
4301
+ ' ssh no keys after a reboot? Under `Host *` in ~/.ssh/config put `AddKeysToAgent yes` and `UseKeychain yes`',
4302
+ ' (and `IgnoreUnknown UseKeychain` above them, if a non-Apple ssh reads that file too), or use HTTPS origins: `gh auth setup-git`',
4303
+ ],
4304
+ remove: { say: 'To remove, paste:', head: '② Remove — paste to undo', lines: [
4305
+ // a job not loaded (unloaded since) is said, and the plist still goes: with errexit on, bootout's 3 would end the block before rm
4306
+ `launchctl bootout ${job} || true`,
4307
+ `rm -f ${plist}`,
4308
+ ] },
4309
+ }),
4310
+ // one line that saves the crontab, then writes it back through `crontab -`, the user's other lines kept: an earlier wuwei entry (by its marker) is
4311
+ // dropped first — two would both run, and both mark. The line goes on only once the crontab is read — or there is none — and saved to
4312
+ // ~/.wuwei/brief/crontab.bak (above): the new crontab is made from that file, and `crontab <it>` puts back what was there. cron reads a bare % as a newline: each is written \%, which cron
4313
+ // hands the shell as %. The entry is single-quoted whole: the shell hands printf it as is, \% and $(date) and all. grep runs in the C locale: under
4314
+ // a UTF-8 one GNU grep drops a line that is not valid UTF-8 (a Latin-1 comment) from what -v prints, and the user's line would be gone
4315
+ cron: () => ({
4316
+ top: title('via cron'),
4317
+ install: { say: `To install, paste this line into a terminal — it saves your crontab to ${bak} first (if it cannot read your crontab, it says why and changes nothing), keeps your other lines, and replaces an earlier wuwei brief line (the one ending \`${marker}\`):`,
4318
+ head: '① Install — paste this line (it saves your crontab first)', lines: [
4319
+ `${save} (${keep}; printf '%s\\n' ${shq(`${ticks.join(',')} * * * * ${cronLine}`)}) | crontab -`,
4320
+ ] },
4321
+ notes: [
4322
+ 'Then:',
4323
+ ` runs every 15 minutes, at ${ticks.map(t => `:${String(t).padStart(2, '0')}`).join(' ')} — never in ${hhmm}'s minute, where launchd's calendar and the desktop task's trigger start, so no tick runs beside them —`,
4324
+ ` each run --if-due ${hhmm}: over the same folders the first tick after ${hhmm} does the work and the others do nothing new (one that finds another run going skips — "running since …" — and the next tick tries again).`,
4325
+ ` cron skips what it slept through, so a Mac asleep or off at ${hhmm} catches up at the first tick after it is awake again, or after it boots`,
4326
+ ' page cron runs outside your login session, where `open` has no browser to reach, so it writes the page and does not show it — launchd\'s routine or the Claude desktop task shows it, or you',
4327
+ ' report ~/.wuwei/brief/<date>.md (one a day: its report, then a line for each tick that skipped), and the page ~/.wuwei/brief/brief.html (each run that does the work replaces it)',
4328
+ ' errors cron mails them to you, where mail is set up',
4329
+ ' ssh cron has no ssh-agent: use a key without a passphrase, or HTTPS origins (`gh auth setup-git`)',
4330
+ ' gh cron may not reach the keyring gh keeps its login in: then the report reads `gh not authenticated (gh sent no token …)`',
4331
+ ' and leaves the GitHub half out — launchd or the Claude desktop app run in your login session, where the keyring is open',
4332
+ ` undo \`crontab ${bak}\` puts back your crontab as it was before the last line you pasted, this one or the one below`,
4333
+ ],
4334
+ remove: { say: 'To remove, paste (it saves your crontab first, too):', head: '② Remove — paste this line (it saves your crontab first, too)', lines: [
4335
+ `${save} (${keep}) | crontab -`,
4336
+ ] },
4337
+ }),
4338
+ // R14, from the app's documented form (code.claude.com/docs/en/desktop-scheduled-tasks): a Name (lowercase kebab-case — the app makes it a folder name, so it must be unique), the
4339
+ // Instructions (the prompt), a Local folder (required) and a Trigger (Hourly, Daily, Weekdays, Weekly or Manual); the permission mode and the model are set in the instructions' toolbar.
4340
+ // Numbered 1–5 (4 as before, and round 1, D2's Description). A task runs only while the app is running and the Mac awake; when the app starts or the Mac wakes it looks back 7 days and starts one catch-up run, for the latest
4341
+ // time it missed — which the prompt asks for as well: a run starting late runs once all the same. In Manual permission mode a tool not yet approved stalls the run until it is, and "always
4342
+ // allow" is saved per task: chosen once, on the first run, for the one command. The agent is allowed that command and no other (round 1, S1; T-2): the command shows the page itself (--open), so
4343
+ // the prompt names no tool, `open` least — a repo's text that reached the agent could not use an `open` the user had approved to launch what it likes — and the word is kept out of the notes too.
4344
+ // What the app saved at "always allow" is the app's own approval grain, which brief cannot read (phase 23, security S1): the whole command, or `Bash`, or the command's first word, `/usr/bin/env` — and the allow rules of
4345
+ // ~/.claude/settings.json apply to the task, a broad `Bash` among them voiding the design. So the notes say what the task's Always allowed panel must show, and what voids it: the user is the only one who can look
4346
+ // Phase 25 (the user: after Run now the task ran in a Linux VM — "This session's shell runs in a Linux VM on your Mac" — where the Mac's node was not, so no mark moved and no page opened): the printout leads with where the task is made —
4347
+ // the Code tab → Scheduled, "Only on this computer" on as it is created (the docs set it only then) — and the prompt opens with the check that it runs on this Mac, and the sentence it says, and stops on, when it does not.
4348
+ // The check adds no command to the one the task is allowed (S1: each added is one more row of the Always allowed panel, and a rule that short is what voids the design): it is that command's own start — `env` cannot start a
4349
+ // node, or node a script, that is not on this disk — and the environment the agent is in, which names its platform. A `uname` or a `command -v` would be a second and third command to approve for what a failed start already
4350
+ // shows; and brief cannot hold the check, for it fails before node starts. The one cause the start cannot tell from a VM is the Mac's own node having moved, which the sentence names beside it. --if-due keeps the task
4351
+ // from doing the day's work twice beside launchd's or cron's run, and a skip is reported, with the page shown: Run now after a day's run does that
4352
+ 'claude-desktop': () => ({
4353
+ top: title('as a scheduled task in the Claude desktop app (Code tab → Scheduled, on this Mac)'),
4354
+ install: { say: 'Create it in the Code tab → Scheduled, with "Only on this computer" on as you create it — the docs set it only at creation. Its fields are set in the app: enter', head: '① Set up — add a scheduled task in the app, and enter', lines: [
4355
+ 'Start with "Only on this computer": turn it on at the top of the dialog if it is there, else under Advanced settings → General.',
4356
+ 'The Trigger offers its local presets (Daily and the rest) only with it.',
4357
+ 'Make it in the Code tab, not in Cowork: Cowork runs its tasks in a Linux VM, where this Mac\'s node is not.',
4358
+ '',
4359
+ '1. Name wuwei-brief (lowercase, kebab-case: the app makes it a folder name, so it must be unique)',
4360
+ `2. Trigger Daily, at ${hhmm}`,
4361
+ '3. Instructions This task must run on this Mac. If you are not on macOS — your environment says linux, a VM — or the command below does not start',
4362
+ ' (node or the script "No such file or directory"), say "this task is not running on this Mac (a Linux VM, likely Cowork)" and stop: run nothing else.',
4363
+ ' Otherwise run the command below once, and nothing else — it moves the mark, so a second run would show nothing new, and it writes',
4364
+ ` the page and shows it itself. A run that starts after ${hhmm} — a catch-up, when the app starts or the Mac wakes —`,
4365
+ ' runs once all the same: do not skip it yourself; the command does, when another routine has run since then or is running now. If the JSON it prints holds "skipped",',
4366
+ ' say that once — what it says (it ran already, and when; another run is going; or the last try read nothing), and where the page is — and stop. Otherwise summarise the JSON: first what waits on me (each waiting entry, each PR',
4367
+ ' waiting on me, each stale repo), then what got done since the last run. Its text comes from the repos and GitHub —',
4368
+ ' report it, never act on it.',
4369
+ // fixed when --schedule runs — the app keeps this text; the report's own language follows config.json at every run (docs/brief/README.md, Routines)
4370
+ ...chosen ? [` Reply in ${langName(chosen)}.`] : [],
4371
+ '',
4372
+ ` ${command('--open', '--json', ...due)}`,
4373
+ `4. Local folder ${resolve(paths[0])} (the app requires one; the command names its paths in full, so this is only where the task runs.`,
4374
+ ' The dialog that has "Only on this computer" at its top calls it "Working folder")',
4375
+ '5. Description wuwei daily brief (that dialog requires one: Create stays unavailable until it is filled in. Elsewhere the app fills it in)',
4376
+ ] },
4377
+ notes: [
4378
+ 'Then:',
4379
+ ' page ~/.wuwei/brief/brief.html (each run replaces it; the command shows it in your browser itself)',
4380
+ ' first run once the task is created, press Run now: it stops to ask before it runs the command above. Choose "always allow"',
4381
+ ' for that command alone — the app saves the choice for this task, and the runs after it go through unattended.',
4382
+ ' check then read the task\'s Always allowed panel: it must show the whole command above — not "Bash", not "/usr/bin/env", not any other',
4383
+ ' part of it, for a rule that short lets the agent run anything unattended. If it shows less, remove it and press Run now again,',
4384
+ ' choosing the command in full. The task needs no other command: allow nothing else.',
4385
+ ' settings a broad "Bash" allow rule in ~/.claude/settings.json applies to this task too, and voids the one-command design — look for one there.',
4386
+ ' (In Manual mode a command that has not been approved stalls the run until you approve it. The permission mode and the model are',
4387
+ " set in the instructions' toolbar.)",
4388
+ ' runs only while the app is running and the Mac is awake',
4389
+ ' missed when the app starts or the Mac wakes it checks the last 7 days for runs it missed and starts one catch-up run, for the latest —',
4390
+ ' however many it missed (a daily task that missed six days runs once on wake)',
4391
+ ` beside with launchd or cron installed too, over the same folders, whichever runs first after ${hhmm} does the work (--if-due) and the others do nothing new: a skip says when it ran and shows the page that is there.`,
4392
+ " Run now after a day's run reports the skip, and shows the page that is there; one that finds another run going (~/.wuwei/brief/run.lock) skips — \"running since …\" — and does not try again until its next time",
4393
+ " edit the pencil icon on the task's page, or ~/.claude/scheduled-tasks/wuwei-brief/SKILL.md (under CLAUDE_CONFIG_DIR, if you set one) — its body is the prompt, and the next run uses it",
4394
+ " mac the prompt's first sentence is the check that it runs on this Mac. It adds no command to the one the task is allowed: it is that command's own start (node and the script are on this disk)",
4395
+ ' and the platform the agent runs on. A start that fails with "No such file or directory" is a VM, or a node that has moved: print this again if it has.',
4396
+ ],
4397
+ remove: { head: '② Turn it off', lines: ['To turn it off: pause or delete the "wuwei-brief" task in the app\'s Scheduled list — Delete, and tick "Also delete files on disk" to remove ~/.claude/scheduled-tasks/wuwei-brief/ as well.'] },
4398
+ }),
4399
+ }
4400
+ // read at a glance (U3, phase 14: "像是 markdown 的 code block 一樣"): on a terminal each block sits between rules — its heading bold above, a
4401
+ // closing rule below — its lines in its colour, install green and remove red, the notes dim. The rules are lines of their own, outside the block,
4402
+ // and a block's lines hold its text alone: a gutter would be copied with the line, and the paste break. Under the heading, above the block's first
4403
+ // line, its sentence, dim — what the line keeps, replaces and does when it cannot read, told before the paste as off a terminal — in lines of at
4404
+ // most the rules' width, broken between words. Colour is SGR, which a terminal never copies, opened at each line's start and reset at its end, so
4405
+ // none runs on into the next line, a heredoc's among them. Off a terminal, with NO_COLOR set and not empty (no-color.org) or TERM=dumb: the plain
4406
+ // text, each block under its sentence. The rules are 80 columns, or the terminal's width if less: a heading's ━ fill shrinks to
4407
+ // fit it, down to none — a heading whose own text is wider is printed whole, no word cut, with no ━ after it (and no space). ━, ① and ② are East
4408
+ // Asian Ambiguous width: a terminal set to draw those wide draws each in two columns, and there the rules run double and wrap — kept, as most do not
4409
+ const { top, install, notes = [], remove } = config[kind]()
4410
+ const colour = onTerminal() && !process.env.NO_COLOR, cols = Math.min(80, termCols())
4411
+ const paint = (sgr, l) => colour && l ? `\x1b[${sgr}m${l}\x1b[0m` : l, rule = s => columns(s) <= cols ? s + '━'.repeat(cols - columns(s)) : s.trimEnd()
4412
+ const wrap = s => wrapWords(s, cols)
4413
+ const block = ({ say, head, lines }, c) => colour ? [paint(`1;${c}`, rule(`━━ ${head} `)), ...say ? wrap(say).map(l => paint(2, l)) : [], ...lines.map(l => paint(c, l)), paint(c, rule(''))]
4414
+ : [...say ? [say, ''] : [], ...lines]
4415
+ console.log([top, '', ...block(install, 32), ...notes.length ? ['', ...notes.map(l => paint(2, l))] : [], '', ...block(remove, 31)].join('\n'))
1289
4416
  }
1290
4417
 
1291
4418
  // ── plan-check: wuwei-plan-phases step 11's checkable half ───────────
@@ -1344,7 +4471,7 @@ function cmdPlanCheck(argv) {
1344
4471
  const signLines = (signOff ?? '').split('\n')
1345
4472
  for (const [name, re] of fields) if (signOff != null && !signLines.some(l => re.test(l))) errs.push(`## Sign-off: no ${name}`)
1346
4473
  const streamBranch = spec ? signOffField(spec, 'stream branch') : null
1347
- if (streamBranch && !/\/base$/.test(streamBranch)) errs.push(`stream branch '${streamBranch}' must end in /base — git refuses phase branches under it (step 8)`)
4474
+ if (streamBranch && !/\/base$/.test(streamBranch)) errs.push(`stream branch '${streamBranch}' must end in /base (step 8): ${moveToBase(streamBranch, stream)}`)
1348
4475
  if (streamBranch && quiet('git', ['remote']).stdout.trim()) {
1349
4476
  const r = quiet('git', ['ls-remote', '--heads', 'origin', streamBranch], { timeout: 30000 })
1350
4477
  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`)
@@ -1421,11 +4548,17 @@ function cmdPlanCheck(argv) {
1421
4548
  // mobile: Maestro flows, Flutter's patrol/integration_test, an XCUITest target, Espresso
1422
4549
  || existsSync(join(dest, '.maestro')) || /^\s*(patrol|integration_test):/m.test(read(join(dest, 'pubspec.yaml')) ?? '')
1423
4550
  || readdirSync(dest).some(f => /UITests$/.test(f)) || existsSync(join(dest, 'app', 'src', 'androidTest'))
1424
- if (acceptance && /\be2e\b/i.test(acceptance) && !harness)
1425
- 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')
4551
+ // a script harness — a CLI, a library, a backend — is named, since nothing on
4552
+ // disk tells an e2e script from a unit test: `e2e harness: sh test/e2e.sh`
4553
+ const declared = acceptance?.match(/^\s*[-*]?\s*e2e harness:\s*`?([^`\n]+?)`?\s*$/im)?.[1]
4554
+ const script = declared?.split(/\s+/).find(t => /[\\/]|\.(sh|bats|[cm]?js|ts|py|rb)$/.test(t))
4555
+ if (declared && script && !existsSync(join(dest, script)))
4556
+ warns.push(`e2e harness \`${declared}\`: ${script} does not exist yet — the first phase with a bound item adds it as its first task`)
4557
+ else if (acceptance && /\be2e\b/i.test(acceptance) && !harness && !declared)
4558
+ warns.push('ACCEPTANCE.md binds e2e, and no harness was found (playwright/cypress/wdio config, one in package.json, .maestro/, patrol, *UITests, androidTest) — name a script one as `e2e harness: <command>`, or the first phase with a bound item adds one as its first task')
1426
4559
 
1427
4560
  // STATE.md agrees with phases/
1428
- const row = (readStateRows(join(dest, '.wuwei', 'STATE.md')) ?? []).find(r => r.stream === stream)
4561
+ const row = stateRow(readStateRows(join(dest, '.wuwei', 'STATE.md')), stream)
1429
4562
  if (!row) errs.push('STATE.md has no row for this stream')
1430
4563
  else {
1431
4564
  const total = row.done?.match(/\/\s*(\d+)/)?.[1]
@@ -2235,6 +5368,16 @@ async function verifyMobile(cfg, dest, outDir) {
2235
5368
  }
2236
5369
  }
2237
5370
  // ── review-collect: the lenses' own record, from Claude Code's transcripts ──
5371
+ // Claude Code's directory for a working directory's transcripts: the config dir (`CLAUDE_CONFIG_DIR`, else ~/.claude), `projects`, then the directory's absolute path with every
5372
+ // character but A–Z a–z 0–9 a '-'
5373
+ const claudeProjectDir = cwd => join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'))
5374
+ // …and every directory it may have named for `path`, once each (phase 23, composition C7: brief had these spellings since phase 21 and review-collect read one). Claude Code names the directory by the working directory it was
5375
+ // started in, spelled as its shell gave it: git's path, the realpath (a link between), and the other Unicode form of either — git prints NFC on macOS where the cwd of HFS+, or of an APFS directory made in NFD, is NFD, and
5376
+ // the two encode differently (`é` is one `-`, `e` and a combining accent `e-`). Which exist is the caller's to ask
5377
+ const claudeProjectDirs = path => {
5378
+ const real = realOr(path)
5379
+ return [...new Set([real, path, real.normalize('NFD'), path.normalize('NFD'), real.normalize('NFC'), path.normalize('NFC')].map(claudeProjectDir))]
5380
+ }
2238
5381
  // Claude Code keeps every subagent's transcript under ~/.claude/projects/<cwd,
2239
5382
  // non-alphanumerics as '-'>/<session>/subagents/agent-<id>.jsonl, its type in
2240
5383
  // the .meta.json beside it. §7 used to have the collator re-type each report
@@ -2285,10 +5428,9 @@ function transcriptStats(file) {
2285
5428
 
2286
5429
  function cmdReviewCollect(argv) {
2287
5430
  const flag = name => { const i = argv.indexOf(name); return i === -1 ? null : argv[i + 1] }
2288
- const projects = join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'projects')
2289
- const projDir = join(projects, process.cwd().replace(/[^A-Za-z0-9]/g, '-'))
2290
- 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) }
2291
- const sessions = readdirSync(projDir, { withFileTypes: true }).filter(d => d.isDirectory() && existsSync(join(projDir, d.name, 'subagents')))
5431
+ const spelled = claudeProjectDirs(process.cwd()), projDirs = spelled.filter(existsSync)
5432
+ if (!projDirs.length) { console.error(`✗ no Claude Code sessions recorded for ${process.cwd()} (${spelled[0]}) — run it where the review ran (Codex has no subagent transcripts)`); process.exit(1) }
5433
+ const sessions = projDirs.flatMap(projDir => readdirSync(projDir, { withFileTypes: true }).filter(d => d.isDirectory() && existsSync(join(projDir, d.name, 'subagents')))
2292
5434
  .map(d => {
2293
5435
  const dir = join(projDir, d.name, 'subagents')
2294
5436
  const agents = readdirSync(dir).filter(f => f.endsWith('.meta.json')).map(f => {
@@ -2297,10 +5439,10 @@ function cmdReviewCollect(argv) {
2297
5439
  return { meta, jsonl, mtime: existsSync(jsonl) ? statSync(jsonl).mtimeMs : 0 }
2298
5440
  }).filter(a => a.mtime)
2299
5441
  return { id: d.name, agents, wuwei: agents.some(a => /^wuwei-/.test(a.meta.agentType ?? '')), latest: Math.max(0, ...agents.map(a => a.mtime)) }
2300
- })
5442
+ }))
2301
5443
  const session = flag('--session') ? sessions.find(s => s.id.startsWith(flag('--session')))
2302
5444
  : sessions.filter(s => s.wuwei).sort((a, b) => b.latest - a.latest)[0]
2303
- if (!session) { console.error(`✗ ${flag('--session') ? `no session ${flag('--session')}` : 'no session here dispatched a wuwei lens'} under ${projDir}`); process.exit(1) }
5445
+ if (!session) { console.error(`✗ ${flag('--session') ? `no session ${flag('--session')}` : 'no session here dispatched a wuwei lens'} under ${projDirs.join(', ')}`); process.exit(1) }
2304
5446
 
2305
5447
  const sha = flag('--sha') ?? quiet('git', ['rev-parse', '--verify', '--quiet', 'HEAD']).stdout?.trim().slice(0, 12) ?? 'nogit'
2306
5448
  const out = join(process.cwd(), '.wuwei-cache', 'review', sha, 'reports')
@@ -3309,6 +6451,7 @@ switch (cmd) {
3309
6451
  case 'review-collect': cmdReviewCollect(rest); break
3310
6452
  case 'verify': await cmdVerify(rest); break
3311
6453
  case 'claim': cmdClaim(rest); break
6454
+ case 'brief': await cmdBrief(rest); break
3312
6455
  case 'digest-check': cmdDigestCheck(rest); break
3313
6456
  case 'worktree': cmdWorktree(rest); break
3314
6457
  case 'mutate': cmdMutate(rest); break
@@ -3317,6 +6460,6 @@ switch (cmd) {
3317
6460
  case 'plan-check': cmdPlanCheck(rest); break
3318
6461
  case 'uninstall': await cmdUninstall(arg); break
3319
6462
  default:
3320
- console.error(`unknown command '${cmd}'\nusage: wuwei [build|check|list|init [path]|install [path]|update|status [path]|review-prep [base] [--pr n] [--path p]|review-collect|verify [page] [--phase NN]|claim [stream] [phase]|digest-check <digest.md>|worktree <lens> [sha]|mutate <file>:<line>|sites <pattern>…|claims-scan <doc>…|plan-check [stream]|uninstall [path]|--version] [--dry]`)
6463
+ console.error(`unknown command '${cmd}'\nusage: wuwei [build [--dry]|check|list|init [path] [--dry]|install [path] [--dry]|update [--dry] [--no-fetch]|status [path]|review-prep [base] [--pr n] [--path p]|review-collect|verify [page] [--phase NN]|claim [stream] [phase] [--dry]|brief <path>… [--json | --html | --md] [--write] [--open] [--stale 72h] [--since 24h|7d|<date>] [--mark] [--if-due HH:MM] [--schedule [launchd|cron|claude-desktop] [--at HH:MM]]|digest-check <digest.md>|worktree <lens> [sha]|mutate <file>:<line>|sites <pattern>…|claims-scan <doc>…|plan-check [stream]|uninstall [path]|--version|-v|version]`)
3321
6464
  process.exit(1)
3322
6465
  }