flow-workflows 0.64.0 → 0.65.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +43 -8
  2. package/adapters/codex/CORE.md +18 -1
  3. package/adapters/codex/skills/flow-bug-review/SKILL.md +29 -0
  4. package/adapters/codex/skills/flow-doctor/SKILL.md +21 -0
  5. package/adapters/codex/skills/flow-feat-review/SKILL.md +29 -0
  6. package/adapters/codex/skills/flow-init/SKILL.md +10 -0
  7. package/adapters/codex/skills/flow-work-status/SKILL.md +2 -0
  8. package/adapters/gemini/CORE.md +18 -1
  9. package/adapters/gemini/commands/flow/bug/review.toml +29 -0
  10. package/adapters/gemini/commands/flow/doctor.toml +21 -0
  11. package/adapters/gemini/commands/flow/feat/review.toml +29 -0
  12. package/adapters/gemini/commands/flow/init.toml +10 -0
  13. package/adapters/gemini/commands/flow/work/status.toml +2 -0
  14. package/adapters/hermes/CORE.md +18 -1
  15. package/adapters/hermes/skills/flow-bug-review/SKILL.md +29 -0
  16. package/adapters/hermes/skills/flow-doctor/SKILL.md +21 -0
  17. package/adapters/hermes/skills/flow-feat-review/SKILL.md +29 -0
  18. package/adapters/hermes/skills/flow-init/SKILL.md +10 -0
  19. package/adapters/hermes/skills/flow-work-status/SKILL.md +2 -0
  20. package/adapters/opencode/CORE.md +18 -1
  21. package/adapters/opencode/commands/flow-bug-review.md +29 -0
  22. package/adapters/opencode/commands/flow-doctor.md +21 -0
  23. package/adapters/opencode/commands/flow-feat-review.md +29 -0
  24. package/adapters/opencode/commands/flow-init.md +10 -0
  25. package/adapters/opencode/commands/flow-work-status.md +2 -0
  26. package/bin/cli.mjs +543 -4
  27. package/package.json +1 -1
  28. package/plugins/flow/.claude-plugin/plugin.json +1 -1
  29. package/plugins/flow/CHANGELOG.md +69 -0
  30. package/plugins/flow/examples/FLOW.template.md +20 -0
package/bin/cli.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs'
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
3
3
  import { homedir } from 'node:os'
4
4
  import { dirname, join } from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
- import { execFileSync } from 'node:child_process'
6
+ import { execFileSync, spawn } from 'node:child_process'
7
7
 
8
8
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
9
9
  const ADAPTERS = join(ROOT, 'adapters')
@@ -189,11 +189,532 @@ function check() {
189
189
  }
190
190
  }
191
191
 
192
+ // --- bundle ------------------------------------------------------------------------------
193
+ // A phase costs `turns × context`: every tool call resends the whole conversation, so ten
194
+ // calls spent discovering what changed cost ten times the context they discover. This
195
+ // gathers the same material in one call — and gathers it ONCE for a whole review panel,
196
+ // instead of each reviewer running its own diff and opening the same files.
197
+ //
198
+ // It knows nothing about any ecosystem. `FLOW.md` says which paths to leave out and which
199
+ // command reports problems; this runs what it is told and copies the output out verbatim.
200
+ // Parsing that output is what would couple it to one toolchain, so it never does.
201
+
202
+ const MAX_FILE_BYTES = 64 * 1024
203
+ const MAX_TOTAL_BYTES = 512 * 1024
204
+
205
+ function git(args, cwd = process.cwd()) {
206
+ try {
207
+ return execFileSync('git', args, {
208
+ cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'],
209
+ })
210
+ } catch {
211
+ return ''
212
+ }
213
+ }
214
+
215
+ // `FLOW.md` is `## section` then `- key: value`, with an indented `- item` list where a key
216
+ // has no inline value. `conventions` carries bare prose lines and is skipped by the same
217
+ // rule that skips anything else without a `key:`.
218
+ function parseFlow(text) {
219
+ const out = {}
220
+ let section = null
221
+ let listKey = null
222
+ for (const line of text.split('\n')) {
223
+ const heading = /^##\s+(\S+)/.exec(line)
224
+ if (heading) {
225
+ section = heading[1]
226
+ out[section] ??= {}
227
+ listKey = null
228
+ continue
229
+ }
230
+ if (!section) continue
231
+ const item = /^\s+-\s+(.*)$/.exec(line)
232
+ if (item && listKey) {
233
+ out[section][listKey].push(item[1].trim())
234
+ continue
235
+ }
236
+ const kv = /^-\s+`?([a-z_]+)`?:\s*(.*)$/.exec(line)
237
+ if (!kv) { listKey = null; continue }
238
+ const [, key, value] = kv
239
+ if (value.trim()) { out[section][key] = value.trim(); listKey = null }
240
+ else { out[section][key] = []; listKey = key }
241
+ }
242
+ // A key with no inline value opens a list. One that never got an item is simply unset —
243
+ // and an empty array reads as *set* at every call site, which turns the template's own
244
+ // `- exec_cmd:` into a command to run. Unset is unset, whichever way it was written.
245
+ for (const keys of Object.values(out)) {
246
+ for (const [key, value] of Object.entries(keys)) if (Array.isArray(value) && !value.length) keys[key] = ''
247
+ }
248
+ return out
249
+ }
250
+
251
+ // Base file plus the overlay of the harness running this, exactly as flow-core §0 resolves
252
+ // it: a key present in the overlay replaces the base value.
253
+ function flowConfig(repo, harness) {
254
+ const read = (name) => {
255
+ const p = join(repo, name)
256
+ return existsSync(p) ? parseFlow(readFileSync(p, 'utf8')) : {}
257
+ }
258
+ const base = read('FLOW.md')
259
+ const overlay = harness ? read(`FLOW.${harness}.md`) : {}
260
+ for (const [section, keys] of Object.entries(overlay)) {
261
+ base[section] = { ...(base[section] ?? {}), ...keys }
262
+ }
263
+ return base
264
+ }
265
+
266
+ function workFolder(repo, branch) {
267
+ const root = join(repo, '.claude', 'work')
268
+ if (!existsSync(root)) return null
269
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
270
+ if (!entry.isDirectory() || entry.name === '_archive') continue
271
+ const meta = join(root, entry.name, 'meta.json')
272
+ if (!existsSync(meta)) continue
273
+ let data
274
+ try { data = JSON.parse(readFileSync(meta, 'utf8')) } catch { continue }
275
+ const branches = [data.branch, ...(Array.isArray(data.mrs) ? data.mrs.map((m) => m?.branch) : [])]
276
+ if (branches.includes(branch)) return join(root, entry.name)
277
+ }
278
+ return null
279
+ }
280
+
281
+ function fence(body, lang = '') {
282
+ // A diff can legitimately contain a fence; open a longer one than anything inside it.
283
+ const longest = Math.max(2, ...[...String(body).matchAll(/^`{3,}/gm)].map((m) => m[0].length))
284
+ const bar = '`'.repeat(longest + 1)
285
+ return `${bar}${lang}\n${body.replace(/\n*$/, '\n')}${bar}`
286
+ }
287
+
288
+ function buildBundle(argv) {
289
+ const flag = (name, fallback = null) => {
290
+ const i = argv.indexOf(name)
291
+ return i === -1 ? fallback : argv[i + 1]
292
+ }
293
+ const repo = git(['rev-parse', '--show-toplevel']).trim()
294
+ if (!repo) {
295
+ console.error('flow bundle: not a git checkout')
296
+ process.exit(1)
297
+ }
298
+ const cfg = flowConfig(repo, flag('--harness'))
299
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], repo).trim()
300
+ const maxFile = Number(flag('--max-file-bytes', MAX_FILE_BYTES)) || MAX_FILE_BYTES
301
+
302
+ const resolves = (ref) => Boolean(git(['rev-parse', '--verify', '--quiet', ref], repo).trim())
303
+ let base = flag('--base') || cfg.git?.default_base || ''
304
+ if (!base) {
305
+ // What the remote says it defaults to, then the usual names — remote first, but a repo
306
+ // with no remote at all still has a base branch, and a clone is not a requirement here.
307
+ const head = git(['symbolic-ref', 'refs/remotes/origin/HEAD'], repo).trim()
308
+ const candidates = [head && head.replace('refs/remotes/', ''),
309
+ 'origin/main', 'origin/master', 'main', 'master'].filter(Boolean)
310
+ base = candidates.find(resolves) || candidates[0]
311
+ }
312
+ // One point of comparison for everything below: the merge base, so the diff carries
313
+ // committed and uncommitted work together — which is the diff a review is looking at.
314
+ if (!resolves(base)) {
315
+ console.error(`flow bundle: base \`${base}\` does not resolve in this checkout.`)
316
+ console.error('Set `git.default_base` in FLOW.md, or pass --base. A silently empty bundle')
317
+ console.error('reads exactly like a branch with no changes, which is worse than this error.')
318
+ process.exit(1)
319
+ }
320
+ const from = git(['merge-base', base, 'HEAD'], repo).trim() || base
321
+
322
+ const exclude = Array.isArray(cfg.git?.diff_exclude) ? cfg.git.diff_exclude
323
+ : cfg.git?.diff_exclude ? [cfg.git.diff_exclude] : []
324
+ const excluded = (list) => ['--', '.', ...list.map((p) => `:(exclude)${p}`)]
325
+ const paths = exclude.length ? excluded(exclude) : []
326
+
327
+ // One size rule for the whole pack. Applying it only to the file contents would let a
328
+ // generated file of any size through in the diff instead — which is the same cost, one
329
+ // section lower down, and exactly what a reviewer does not need to read.
330
+ const changed = git(['diff', '--name-only', from, ...paths], repo).split('\n').filter(Boolean)
331
+ const oversize = changed.filter((rel) => {
332
+ const abs = join(repo, rel)
333
+ return existsSync(abs) && statSync(abs).size > maxFile
334
+ })
335
+ const diffPaths = excluded([...exclude, ...oversize])
336
+
337
+ const out = []
338
+ out.push(`# flow bundle — ${branch}`)
339
+ out.push('')
340
+ out.push(`Base \`${base}\` · merge base \`${from.slice(0, 12)}\` · generated by \`flow bundle\`.`)
341
+ out.push('Committed and uncommitted changes together. Nothing here is parsed or summarised.')
342
+ out.push('')
343
+
344
+ out.push('## Worklist')
345
+ out.push('')
346
+ const stat = git(['diff', '--stat', from, ...diffPaths], repo).trim()
347
+ out.push(stat ? fence(stat) : '_no changes against the base_')
348
+ out.push('')
349
+ if (exclude.length) {
350
+ out.push(`Left out by \`git.diff_exclude\`: ${exclude.map((p) => `\`${p}\``).join(' · ')}`)
351
+ out.push('')
352
+ }
353
+ const untracked = git(['ls-files', '--others', '--exclude-standard'], repo).trim()
354
+ if (untracked) {
355
+ out.push('Untracked, so absent from the diff below:')
356
+ out.push('')
357
+ out.push(fence(untracked))
358
+ out.push('')
359
+ }
360
+
361
+ out.push('## Diff')
362
+ out.push('')
363
+ const diff = git(['diff', from, ...diffPaths], repo)
364
+ out.push(diff.trim() ? fence(diff, 'diff') : '_empty_')
365
+ out.push('')
366
+
367
+ out.push('## Changed files, in full')
368
+ out.push('')
369
+ const skipped = oversize.map((rel) => {
370
+ const size = statSync(join(repo, rel)).size
371
+ return `${rel} — ${size} bytes, over --max-file-bytes (${maxFile}); left out of the diff too`
372
+ })
373
+ let spent = 0
374
+ for (const rel of changed) {
375
+ const abs = join(repo, rel)
376
+ if (!existsSync(abs)) continue // deleted: the diff already carries it
377
+ if (oversize.includes(rel)) continue
378
+ const size = statSync(abs).size
379
+ if (spent + size > MAX_TOTAL_BYTES) { skipped.push(`${rel} — bundle full`); continue }
380
+ spent += size
381
+ out.push(`### ${rel}`)
382
+ out.push('')
383
+ out.push(fence(readFileSync(abs, 'utf8')))
384
+ out.push('')
385
+ }
386
+ if (skipped.length) {
387
+ out.push('### Not included')
388
+ out.push('')
389
+ out.push(skipped.map((s) => `- ${s}`).join('\n'))
390
+ out.push('')
391
+ }
392
+
393
+ if (argv.includes('--checks')) {
394
+ const cmd = cfg.quality?.static_analysis
395
+ out.push('## Checks')
396
+ out.push('')
397
+ if (!cmd) {
398
+ out.push('_`quality.static_analysis` is empty — nothing to run_')
399
+ } else {
400
+ out.push(`\`quality.static_analysis\`: \`${cmd}\``)
401
+ out.push('')
402
+ let body, code = 0
403
+ try {
404
+ body = execFileSync('sh', ['-c', cmd], {
405
+ cwd: repo, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
406
+ })
407
+ } catch (e) {
408
+ body = `${e.stdout ?? ''}${e.stderr ?? ''}`
409
+ code = e.status ?? 1
410
+ }
411
+ out.push(fence(`${body.trim() || '(no output)'}\n\nexit ${code}`))
412
+ }
413
+ out.push('')
414
+ }
415
+
416
+ const work = workFolder(repo, branch)
417
+ if (work) {
418
+ out.push('## Work')
419
+ out.push('')
420
+ for (const name of ['meta.json', '00-summary.md']) {
421
+ const p = join(work, name)
422
+ if (!existsSync(p)) continue
423
+ out.push(`### ${name}`)
424
+ out.push('')
425
+ out.push(fence(readFileSync(p, 'utf8'), name.endsWith('.json') ? 'json' : ''))
426
+ out.push('')
427
+ }
428
+ }
429
+
430
+ return { text: out.join('\n'), repo, cfg, branch, work }
431
+ }
432
+
433
+ function bundle(argv) {
434
+ console.log(buildBundle(argv).text)
435
+ }
436
+
437
+ // --- review ----------------------------------------------------------------------------
438
+ // The panel is the most expensive thing the flow does, and most of what it spends is not
439
+ // review: each subagent is an agentic loop that rediscovers the diff before reading it.
440
+ // Once `bundle` hands it the material, a reviewer needs one turn, not eighteen.
441
+ //
442
+ // This spawns a command. It does not know, and must not know, what is behind it: a
443
+ // subscription login, an API key, another vendor's CLI, or a script of your own. That is
444
+ // what `agents.exec_cmd` is — and why no provider SDK belongs in this package.
445
+
446
+ const REVIEW_SCHEMA = JSON.stringify({
447
+ type: 'object',
448
+ properties: {
449
+ findings: {
450
+ type: 'array',
451
+ items: {
452
+ type: 'object',
453
+ properties: {
454
+ file: { type: 'string' },
455
+ line: { type: 'integer' },
456
+ severity: { type: 'string', enum: ['blocker', 'major', 'minor', 'nit'] },
457
+ what: { type: 'string' },
458
+ fix: { type: 'string' },
459
+ },
460
+ required: ['file', 'what', 'fix'],
461
+ },
462
+ },
463
+ },
464
+ required: ['findings'],
465
+ })
466
+
467
+ // Neutral dimensions, used only when `quality.reviewers` is empty. Naming a stack here
468
+ // would be the leak the preflight exists to catch; these are categories, not tools.
469
+ const DEFAULT_ROLES = [
470
+ 'correctness: logic that does not do what the change claims, broken edge cases, error paths',
471
+ 'security: untrusted input reaching a sink, authorisation gaps, secrets, unsafe defaults',
472
+ 'data access: queries in loops, missing bounds, writes without a transaction, schema risk',
473
+ 'tests: what the change broke and the tests do not cover, and assertions that prove nothing',
474
+ ]
475
+
476
+ function firstJson(text) {
477
+ // Tolerant on purpose: only one harness has a structured-output flag, and requiring it
478
+ // would lock every other one out. Find the first balanced JSON object and parse that.
479
+ for (let i = text.indexOf('{'); i !== -1; i = text.indexOf('{', i + 1)) {
480
+ let depth = 0, inStr = false, esc = false
481
+ for (let j = i; j < text.length; j++) {
482
+ const c = text[j]
483
+ if (esc) { esc = false; continue }
484
+ if (c === '\\') { esc = true; continue }
485
+ if (c === '"') { inStr = !inStr; continue }
486
+ if (inStr) continue
487
+ if (c === '{') depth++
488
+ else if (c === '}' && --depth === 0) {
489
+ try { return JSON.parse(text.slice(i, j + 1)) } catch { break }
490
+ }
491
+ }
492
+ }
493
+ return null
494
+ }
495
+
496
+ // A harness with a structured-output flag answers in an envelope of its own: the findings
497
+ // arrive under `structured_output`, or as a JSON string in `result`, alongside the run's
498
+ // cost. Reading only the root is what made every role report nothing while the command
499
+ // exited 0 — the failure that looks like a clean diff. Unwrap one level, and keep the
500
+ // envelope's cost, which the inner object never carries.
501
+ function unwrapFindings(parsed) {
502
+ if (!parsed || typeof parsed !== 'object') return null
503
+ const cost = parsed.total_cost_usd ?? null
504
+ if (Array.isArray(parsed.findings)) return { findings: parsed.findings, cost }
505
+ const inner = Array.isArray(parsed.structured_output?.findings) ? parsed.structured_output
506
+ : typeof parsed.result === 'string' ? firstJson(parsed.result)
507
+ : null
508
+ if (inner && Array.isArray(inner.findings)) return { findings: inner.findings, cost: cost ?? inner.total_cost_usd ?? null }
509
+ return null
510
+ }
511
+
512
+ function runRole(cmd, role, pack, repo) {
513
+ const shellSafe = (s) => String(s).replace(/(["\\$`])/g, '\\$1')
514
+ const line = cmd.replaceAll('{ROLE}', shellSafe(role)).replaceAll('{SCHEMA}', shellSafe(REVIEW_SCHEMA))
515
+ const brief = [
516
+ `You are reviewing a diff. Your dimension, and nothing else: ${role}`,
517
+ '',
518
+ 'Report findings only. One per entry, each with the file, the line if you can place it,',
519
+ 'what is wrong and the fix. No preamble, no summary, no praise. Nothing to report is a',
520
+ 'valid answer: return an empty list.',
521
+ '',
522
+ `Answer with JSON matching this schema and nothing else: ${REVIEW_SCHEMA}`,
523
+ '',
524
+ '--- the change under review ---',
525
+ '',
526
+ pack,
527
+ ].join('\n')
528
+
529
+ return new Promise((resolve) => {
530
+ const child = spawn('sh', ['-c', line], { cwd: repo, stdio: ['pipe', 'pipe', 'pipe'] })
531
+ let out = '', err = ''
532
+ child.stdout.on('data', (d) => { out += d })
533
+ child.stderr.on('data', (d) => { err += d })
534
+ child.on('error', (e) => resolve({ role, error: e.message, findings: [] }))
535
+ // A command that answers without reading the brief closes the pipe under us. That is a
536
+ // review to judge on its output, not a crash of the round the other reviewers are in.
537
+ child.stdin.on('error', () => {})
538
+ child.on('close', (code) => {
539
+ const got = unwrapFindings(firstJson(out))
540
+ if (!got) {
541
+ resolve({ role, code, error: `no JSON findings in the output${err ? ` (stderr: ${err.trim().slice(0, 200)})` : ''}`, findings: [] })
542
+ return
543
+ }
544
+ resolve({ role, code, findings: got.findings, cost: got.cost })
545
+ })
546
+ child.stdin.end(brief)
547
+ })
548
+ }
549
+
550
+ async function review(argv) {
551
+ const built = buildBundle(argv)
552
+ const { cfg, repo } = built
553
+ const cmd = cfg.agents?.exec_cmd
554
+ if (!cmd) {
555
+ console.error('flow review: `agents.exec_cmd` is empty in FLOW.md.')
556
+ console.error('That key is the opt-in for running the panel from here; without it the')
557
+ console.error('agentic panel inside /flow:*:review is what reviews, exactly as before.')
558
+ console.error('It takes any non-interactive harness invocation — {ROLE} and {SCHEMA} are')
559
+ console.error('substituted, and the diff arrives on stdin.')
560
+ process.exit(1)
561
+ }
562
+
563
+ // Substitution escapes for a double-quoted context. Inside single quotes those
564
+ // backslashes reach the harness literally, the schema is no longer valid JSON, and every
565
+ // role answers nothing — paid for, once per reviewer, with a clean-looking panel to show
566
+ // for it. It cannot work, so it stops here rather than in the artifact.
567
+ if (/'[^']*\{(SCHEMA|ROLE)\}[^']*'/.test(cmd)) {
568
+ console.error('flow review: `{SCHEMA}`/`{ROLE}` sit inside single quotes in `agents.exec_cmd`.')
569
+ console.error('The substitution escapes for double quotes, so single ones hand the command')
570
+ console.error('invalid JSON and every role reports nothing. Use "{SCHEMA}" there.')
571
+ process.exit(1)
572
+ }
573
+
574
+ const configured = Array.isArray(cfg.quality?.reviewers) ? cfg.quality.reviewers : []
575
+ const roles = configured.length ? configured : DEFAULT_ROLES
576
+ const fanout = Number(cfg.agents?.fanout_max) || 4
577
+ const budget = cfg.agents?.budget_max === '0' ? Infinity : (Number(cfg.agents?.budget_max) || 12)
578
+ const cap = Math.min(roles.length, budget)
579
+ const dropped = roles.slice(cap)
580
+
581
+ const results = []
582
+ for (let i = 0; i < cap; i += fanout) {
583
+ // `fanout_max` bounds the round, `budget_max` bounds the command: a round that reaches
584
+ // past the ceiling is how a panel ends up an order of magnitude over what was asked for,
585
+ // so the slice is capped by both.
586
+ const round = roles.slice(i, Math.min(i + fanout, cap))
587
+ results.push(...await Promise.all(round.map((r) => runRole(cmd, r, built.text, repo))))
588
+ }
589
+
590
+ // Deduplicate in code. Two reviewers finding the same thing is the panel working; the
591
+ // main thread paying model prices to notice that is not.
592
+ const seen = new Map()
593
+ for (const r of results) {
594
+ for (const f of r.findings) {
595
+ const key = `${f.file}:${f.line ?? ''}:${String(f.what).toLowerCase().replace(/\W+/g, ' ').trim().slice(0, 80)}`
596
+ if (seen.has(key)) { seen.get(key).roles.push(r.role); continue }
597
+ seen.set(key, { ...f, roles: [r.role] })
598
+ }
599
+ }
600
+ const order = { blocker: 0, major: 1, minor: 2, nit: 3 }
601
+ const findings = [...seen.values()].sort((a, b) => (order[a.severity] ?? 9) - (order[b.severity] ?? 9))
602
+
603
+ const out = [`# Review — ${built.branch}`, '']
604
+ out.push(`${findings.length} finding(s) from ${results.length} reviewer(s), deduplicated.`)
605
+ out.push('')
606
+ for (const f of findings) {
607
+ out.push(`- **${f.severity ?? 'unrated'}** \`${f.file}${f.line ? `:${f.line}` : ''}\` — ${f.what}`)
608
+ out.push(` fix: ${f.fix}`)
609
+ if (f.roles.length > 1) out.push(` raised by ${f.roles.length} reviewers`)
610
+ }
611
+ if (!findings.length) out.push('_none_')
612
+ out.push('')
613
+ out.push('## Cost')
614
+ out.push('')
615
+ out.push(`${results.length}/${budget === Infinity ? '∞' : budget} reviewers, ${fanout} per round.`)
616
+ const costs = results.map((r) => r.cost).filter((c) => typeof c === 'number')
617
+ out.push(costs.length
618
+ ? `Reported by the harness: $${costs.reduce((a, b) => a + b, 0).toFixed(4)} over ${costs.length} run(s).`
619
+ : 'The harness reported no cost figure, so this run has none to show.')
620
+ const failed = results.filter((r) => r.error)
621
+ if (failed.length) {
622
+ out.push('')
623
+ out.push('## Reviewers that returned nothing usable')
624
+ out.push('')
625
+ failed.forEach((r) => out.push(`- ${r.role.split(':')[0]} — ${r.error}`))
626
+ }
627
+ if (dropped.length) {
628
+ out.push('')
629
+ out.push(`## Skipped for budget`)
630
+ out.push('')
631
+ dropped.forEach((r) => out.push(`- ${r.split(':')[0]}`))
632
+ }
633
+
634
+ const text = out.join('\n')
635
+ if (argv.includes('--record')) {
636
+ recordCost(built.work, {
637
+ phase: 'review',
638
+ at: new Date().toISOString(),
639
+ reviewers: results.length,
640
+ usd: costs.length ? Number(costs.reduce((a, b) => a + b, 0).toFixed(6)) : null,
641
+ source: costs.length ? 'harness' : 'not reported',
642
+ })
643
+ }
644
+ const outFile = argv.includes('--out') ? argv[argv.indexOf('--out') + 1] : null
645
+ if (outFile) { writeFileSync(outFile, `${text}\n`); console.log(`wrote ${outFile}`) } else console.log(text)
646
+ }
647
+
648
+ // --- cost ------------------------------------------------------------------------------
649
+ // `review` reports `<n>/<budget_max> subagents launched`, which is a headcount: two runs
650
+ // with the same count differ by an order of magnitude depending on the turns each agent
651
+ // took. The ceilings are tuned against that headcount, so they are tuned against the wrong
652
+ // number — and nobody can say whether lowering `review_depth` actually helped.
653
+ //
654
+ // What this does NOT do is guess. A harness that reports nothing gets "not reported", and
655
+ // every figure it does show is a client-side estimate, which is what the harnesses call
656
+ // them too.
657
+
658
+ function recordCost(work, entry) {
659
+ if (!work) return
660
+ const p = join(work, 'meta.json')
661
+ if (!existsSync(p)) return
662
+ let meta
663
+ try { meta = JSON.parse(readFileSync(p, 'utf8')) } catch { return }
664
+ meta.cost = Array.isArray(meta.cost) ? meta.cost : []
665
+ meta.cost.push(entry)
666
+ // Write through a temporary file: meta.json is the flow's own state, and a half-written
667
+ // one loses the work, not just the figure.
668
+ const tmp = `${p}.tmp`
669
+ writeFileSync(tmp, `${JSON.stringify(meta, null, 2)}\n`)
670
+ renameSync(tmp, p)
671
+ }
672
+
673
+ function cost(argv) {
674
+ const repo = git(['rev-parse', '--show-toplevel']).trim()
675
+ if (!repo) { console.error('flow cost: not a git checkout'); process.exit(1) }
676
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], repo).trim()
677
+ const work = workFolder(repo, branch)
678
+ if (!work) {
679
+ console.log(`No work on \`${branch}\`, so nothing has been recorded against it.`)
680
+ return
681
+ }
682
+ let meta = {}
683
+ try { meta = JSON.parse(readFileSync(join(work, 'meta.json'), 'utf8')) } catch {}
684
+ const entries = Array.isArray(meta.cost) ? meta.cost : []
685
+
686
+ console.log(`# Cost — ${meta.ticket ?? branch}`)
687
+ console.log()
688
+ if (!entries.length) {
689
+ console.log('Nothing recorded yet. A phase records here when it can measure what it spent:')
690
+ console.log('`flow review --record` writes what the harness reported for its round, and a')
691
+ console.log('harness that reports no figure is recorded as "not reported" rather than guessed.')
692
+ return
693
+ }
694
+ console.log('| phase | when | agents | reported | source |')
695
+ console.log('|---|---|---|---|---|')
696
+ let total = 0
697
+ for (const e of entries) {
698
+ if (typeof e.usd === 'number') total += e.usd
699
+ console.log(`| ${e.phase ?? '?'} | ${String(e.at ?? '').slice(0, 16).replace('T', ' ')} `
700
+ + `| ${e.reviewers ?? '—'} | ${typeof e.usd === 'number' ? `$${e.usd.toFixed(4)}` : '—'} `
701
+ + `| ${e.source ?? '?'} |`)
702
+ }
703
+ console.log()
704
+ console.log(`Recorded so far: **$${total.toFixed(4)}** over ${entries.length} run(s).`)
705
+ console.log()
706
+ console.log('These are the harness\'s own client-side estimates and can differ from a bill.')
707
+ console.log('Phases with no row did not measure anything — absence here is not zero.')
708
+ }
709
+
192
710
  function usage() {
193
711
  console.log(`flow-workflows v${version()} — guided feat/bug workflows for coding agents
194
712
 
195
713
  npx flow-workflows install <harness> [project]
196
714
  npx flow-workflows check
715
+ npx flow-workflows bundle [--base <ref>] [--checks] [--harness <name>] [--max-file-bytes N]
716
+ npx flow-workflows review [--out <file>] [--record] [same options as bundle]
717
+ npx flow-workflows cost
197
718
 
198
719
  Harnesses: ${Object.keys(HARNESSES).join(' · ')}
199
720
  "project" installs into the current repo instead of your user folder.
@@ -201,20 +722,38 @@ Harnesses: ${Object.keys(HARNESSES).join(' · ')}
201
722
  Claude Code and Codex CLI have their own marketplaces:
202
723
  npx flow-workflows install claude shows how
203
724
 
725
+ "bundle" prints one context pack for the current branch — worklist, diff, the changed
726
+ files in full, and the work's handoff — so a phase reads it in one call instead of
727
+ rediscovering it in ten. --checks also runs quality.static_analysis and copies its
728
+ output out verbatim. Paths in git.diff_exclude are left out of the diff.
729
+
730
+ "review" runs one reviewer per role through agents.exec_cmd — any non-interactive
731
+ harness invocation — over that same pack, one turn each instead of an agentic loop
732
+ each, and deduplicates what comes back. Empty exec_cmd = the agentic panel reviews,
733
+ as it always has.
734
+
735
+ "cost" prints what each phase of this branch's work actually reported spending, from
736
+ what --record wrote into meta.json. A harness that reports no figure is recorded as
737
+ "not reported": a headcount of subagents is not a cost, and neither is a guess.
738
+
204
739
  Updating: re-run the install command — it sweeps the previous version first.
205
740
  Docs: https://github.com/mashware/flow-workflows`)
206
741
  }
207
742
 
208
743
  const [cmd, tool, scope] = process.argv.slice(2)
209
744
 
210
- if (cmd === 'check') check()
745
+ if (cmd === 'bundle') bundle(process.argv.slice(3))
746
+ else if (cmd === 'cost') cost(process.argv.slice(3))
747
+ else if (cmd === 'review') review(process.argv.slice(3)).catch((e) => { console.error(`flow review: ${e.message}`); process.exit(1) })
748
+ else if (cmd === 'check') check()
211
749
  else if (cmd === 'install' && tool === 'claude') claude()
212
750
  else if (cmd === 'install' && HARNESSES[tool]) install(tool, scope === 'project' ? 'project' : 'global')
213
751
  else if (cmd === 'install') {
214
752
  console.error(`Unknown harness: ${tool ?? '(none given)'}`)
215
753
  console.error(`Expected one of: ${Object.keys(HARNESSES).join(', ')}, claude`)
216
754
  process.exit(1)
217
- } else if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') usage()
755
+ } else if (cmd === '--version' || cmd === '-v') console.log(version())
756
+ else if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') usage()
218
757
  else {
219
758
  console.error(`Unknown command: ${cmd}`)
220
759
  usage()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flow-workflows",
3
- "version": "0.64.0",
3
+ "version": "0.65.0-rc.2",
4
4
  "description": "Guided feat/bug workflows (start→ship), post-deploy monitoring and multi-agent code review, stack-agnostic. Each repo uses a shared FLOW.md plus optional harness overlay.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,7 +3,7 @@
3
3
  "name": "flow",
4
4
  "displayName": "Flow — guided dev workflows",
5
5
  "description": "Guided feat/bug workflows (start→ship), post-deploy monitoring and multi-agent code review, stack-agnostic. Each repo uses a shared FLOW.md plus optional harness overlay.",
6
- "version": "0.64.0",
6
+ "version": "0.65.0-rc.2",
7
7
  "author": {
8
8
  "name": "Alberto Vioque",
9
9
  "email": "mashware@gmail.com"