flow-workflows 0.65.0 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +25 -4
  2. package/adapters/README.md +11 -3
  3. package/adapters/codex/CORE.md +31 -5
  4. package/adapters/codex/skills/flow-bug-fix/SKILL.md +5 -0
  5. package/adapters/codex/skills/flow-bug-review/SKILL.md +19 -6
  6. package/adapters/codex/skills/flow-bug-ship/SKILL.md +5 -3
  7. package/adapters/codex/skills/flow-feat-build/SKILL.md +16 -2
  8. package/adapters/codex/skills/flow-feat-review/SKILL.md +51 -47
  9. package/adapters/codex/skills/flow-feat-ship/SKILL.md +23 -4
  10. package/adapters/codex/skills/flow-feat-validate/SKILL.md +5 -1
  11. package/adapters/codex/skills/flow-init/SKILL.md +4 -3
  12. package/adapters/codex/skills/flow-work-README/SKILL.md +9 -2
  13. package/adapters/codex/skills/flow-work-respond/SKILL.md +1 -1
  14. package/adapters/gemini/CORE.md +31 -5
  15. package/adapters/gemini/commands/flow/bug/fix.toml +5 -0
  16. package/adapters/gemini/commands/flow/bug/review.toml +19 -6
  17. package/adapters/gemini/commands/flow/bug/ship.toml +5 -3
  18. package/adapters/gemini/commands/flow/feat/build.toml +16 -2
  19. package/adapters/gemini/commands/flow/feat/review.toml +51 -47
  20. package/adapters/gemini/commands/flow/feat/ship.toml +23 -4
  21. package/adapters/gemini/commands/flow/feat/validate.toml +5 -1
  22. package/adapters/gemini/commands/flow/init.toml +4 -3
  23. package/adapters/gemini/commands/flow/work/README.toml +9 -2
  24. package/adapters/gemini/commands/flow/work/respond.toml +1 -1
  25. package/adapters/hermes/CORE.md +31 -5
  26. package/adapters/hermes/skills/flow-bug-fix/SKILL.md +5 -0
  27. package/adapters/hermes/skills/flow-bug-review/SKILL.md +19 -6
  28. package/adapters/hermes/skills/flow-bug-ship/SKILL.md +5 -3
  29. package/adapters/hermes/skills/flow-feat-build/SKILL.md +16 -2
  30. package/adapters/hermes/skills/flow-feat-review/SKILL.md +51 -47
  31. package/adapters/hermes/skills/flow-feat-ship/SKILL.md +23 -4
  32. package/adapters/hermes/skills/flow-feat-validate/SKILL.md +5 -1
  33. package/adapters/hermes/skills/flow-init/SKILL.md +4 -3
  34. package/adapters/hermes/skills/flow-work-README/SKILL.md +9 -2
  35. package/adapters/hermes/skills/flow-work-respond/SKILL.md +1 -1
  36. package/adapters/opencode/CORE.md +31 -5
  37. package/adapters/opencode/commands/flow-bug-fix.md +5 -0
  38. package/adapters/opencode/commands/flow-bug-review.md +19 -6
  39. package/adapters/opencode/commands/flow-bug-ship.md +5 -3
  40. package/adapters/opencode/commands/flow-feat-build.md +16 -2
  41. package/adapters/opencode/commands/flow-feat-review.md +51 -47
  42. package/adapters/opencode/commands/flow-feat-ship.md +23 -4
  43. package/adapters/opencode/commands/flow-feat-validate.md +5 -1
  44. package/adapters/opencode/commands/flow-init.md +4 -3
  45. package/adapters/opencode/commands/flow-work-README.md +9 -2
  46. package/adapters/opencode/commands/flow-work-respond.md +1 -1
  47. package/bin/cli.mjs +330 -24
  48. package/package.json +2 -1
  49. package/plugins/flow/.claude-plugin/plugin.json +5 -2
  50. package/plugins/flow/CHANGELOG.md +227 -0
  51. package/plugins/flow/examples/FLOW.template.md +2 -2
package/bin/cli.mjs CHANGED
@@ -158,6 +158,17 @@ function claude() {
158
158
  console.log('Codex has its own too: codex plugin marketplace add https://github.com/mashware/flow-workflows.git')
159
159
  }
160
160
 
161
+ function zcode() {
162
+ console.log('zcode reads this plugin package directly, from its own marketplace, not from npm:')
163
+ console.log()
164
+ console.log(' zcode plugins marketplace add mashware/flow-workflows')
165
+ console.log(' zcode plugins install flow@flow-plugins')
166
+ console.log()
167
+ console.log('Then /flow:init in your repo. Updates arrive through `zcode plugins update flow`.')
168
+ console.log('Type the /flow:-prefixed commands: zcode also exposes the plugin\'s Claude pages')
169
+ console.log('unprefixed (/feat:start, /init, …), and those carry --harness claude.')
170
+ }
171
+
161
172
  function check() {
162
173
  const pkg = version()
163
174
  console.log(`this package: v${pkg}`)
@@ -276,7 +287,7 @@ const configRoot = (repo) => rootHolding(repo, 'FLOW.md')
276
287
  // The overlay names flow-core §0 recognises. A `--harness` outside this set reads no
277
288
  // overlay and looks exactly like a repo that has none, so it stops the run instead: a typo
278
289
  // here is worth one error message and never worth a review round nobody can trust.
279
- const OVERLAY_HARNESSES = ['claude', 'codex', 'gemini', 'hermes', 'opencode']
290
+ const OVERLAY_HARNESSES = ['claude', 'codex', 'gemini', 'hermes', 'opencode', 'zcode']
280
291
 
281
292
  // Every overlay sitting next to the base, whether or not this run reads one.
282
293
  function overlaysPresent(root) {
@@ -390,7 +401,23 @@ function buildBundle(argv) {
390
401
  console.error('reads exactly like a branch with no changes, which is worse than this error.')
391
402
  process.exit(1)
392
403
  }
393
- const from = git(['merge-base', base, 'HEAD'], repo).trim() || base
404
+ // `--rev` freezes what is read. Without it the pack is the live checkout, which is the
405
+ // right answer for a phase that is about to edit it and the wrong one for a review: the
406
+ // round applies its own fixes while its reviewers are still reading, so "the tree" is a
407
+ // different tree for each of them and for the sha recorded at the end. A caller that has
408
+ // resolved a candidate — a commit, or a `git stash create` object holding a dirty tree —
409
+ // passes it here and every section below reads that one revision.
410
+ const rev = flag('--rev')
411
+ if (rev && !resolves(rev)) {
412
+ console.error(`flow bundle: --rev \`${rev}\` does not resolve in this checkout.`)
413
+ console.error('Pass a commit, a tag, or the object `git stash create` printed. A pack')
414
+ console.error('built from a revision nobody can name is evidence about nothing.')
415
+ process.exit(1)
416
+ }
417
+ const to = rev ? git(['rev-parse', rev], repo).trim() : null
418
+ const from = git(['merge-base', base, to ?? 'HEAD'], repo).trim() || base
419
+ // Every diff below runs `from` → `ends`, and an empty `ends` means the working tree.
420
+ const ends = to ? [to] : []
394
421
 
395
422
  const exclude = Array.isArray(cfg.git?.diff_exclude) ? cfg.git.diff_exclude
396
423
  : cfg.git?.diff_exclude ? [cfg.git.diff_exclude] : []
@@ -403,13 +430,13 @@ function buildBundle(argv) {
403
430
  // exists to carry, and says so in a section nobody reads before the diff they were handed.
404
431
  // A file whose *diff* is enormous is the one a reviewer cannot use, and a generated file
405
432
  // nobody reviews at all belongs in `git.diff_exclude`, which is the key for exactly that.
406
- const changed = git(['diff', '--name-only', from, ...paths], repo).split('\n').filter(Boolean)
433
+ const changed = git(['diff', '--name-only', from, ...ends, ...paths], repo).split('\n').filter(Boolean)
407
434
 
408
435
  // `--numstat -z`: `<add>\t<del>\t<path>\0`, and for a rename an empty path field followed by
409
436
  // the old and new paths as their own records. A path we fail to match simply keeps its diff,
410
437
  // which is the safe direction for a rule whose failure mode was dropping too much.
411
438
  const churn = new Map()
412
- const fields = git(['diff', '--numstat', '-z', from, ...paths], repo).split('\0')
439
+ const fields = git(['diff', '--numstat', '-z', from, ...ends, ...paths], repo).split('\0')
413
440
  for (let i = 0; i < fields.length; i += 1) {
414
441
  const m = /^(\d+|-)\t(\d+|-)\t(.*)$/.exec(fields[i])
415
442
  if (!m) continue
@@ -424,11 +451,33 @@ function buildBundle(argv) {
424
451
  })
425
452
  const diffPaths = excluded([...exclude, ...unreadableDiff])
426
453
 
454
+ // With `--rev` the contents come out of that revision too. Reading them off disk instead
455
+ // would hand a reviewer a diff of one tree and the files of another — the exact confusion
456
+ // freezing exists to remove, and invisible in the pack, because both halves look right.
457
+ const blobs = new Map()
458
+ const contents = (rel) => {
459
+ if (!to) return existsSync(join(repo, rel)) ? readFileSync(join(repo, rel), 'utf8') : null
460
+ if (blobs.has(rel)) return blobs.get(rel)
461
+ let body = null
462
+ try {
463
+ body = execFileSync('git', ['show', `${to}:${rel}`], {
464
+ cwd: repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'],
465
+ })
466
+ } catch { body = null } // deleted in the candidate
467
+ blobs.set(rel, body)
468
+ return body
469
+ }
470
+
427
471
  // The contents section keeps the file-size rule: it is the one place a file's own size is
428
472
  // what it costs.
429
- const oversize = changed.filter((rel) => {
473
+ const sizeOf = (rel) => {
474
+ if (to) { const b = contents(rel); return b === null ? null : Buffer.byteLength(b) }
430
475
  const abs = join(repo, rel)
431
- return existsSync(abs) && statSync(abs).size > maxFile
476
+ return existsSync(abs) ? statSync(abs).size : null
477
+ }
478
+ const oversize = changed.filter((rel) => {
479
+ const size = sizeOf(rel)
480
+ return size !== null && size > maxFile
432
481
  })
433
482
 
434
483
  const out = []
@@ -436,14 +485,19 @@ function buildBundle(argv) {
436
485
  out.push('')
437
486
  out.push(`Base \`${base}\` · merge base \`${from.slice(0, 12)}\` · generated by \`flow bundle\`.`)
438
487
  out.push(`Config read: ${configFiles(repo, harness)}.`)
439
- out.push('Committed and uncommitted changes together. Nothing here is parsed or summarised.')
488
+ if (to) {
489
+ out.push(`Revision \`${to.slice(0, 12)}\` (\`--rev ${rev}\`) — **frozen**. Every diff and every`)
490
+ out.push('file below is that one revision, not the checkout, which may have moved since.')
491
+ } else {
492
+ out.push('Committed and uncommitted changes together. Nothing here is parsed or summarised.')
493
+ }
440
494
  out.push('')
441
495
 
442
496
  out.push('## Worklist')
443
497
  out.push('')
444
498
  // The whole change, not the part that survived the limits below: a worklist that agrees
445
499
  // with a reduced diff is how a reviewer reads half a change and never finds out.
446
- const stat = git(['diff', '--stat', from, ...paths], repo).trim()
500
+ const stat = git(['diff', '--stat', from, ...ends, ...paths], repo).trim()
447
501
  out.push(stat ? fence(stat) : '_no changes against the base_')
448
502
  out.push('')
449
503
  if (exclude.length) {
@@ -461,7 +515,9 @@ function buildBundle(argv) {
461
515
  }
462
516
  const untracked = git(['ls-files', '--others', '--exclude-standard'], repo).trim()
463
517
  if (untracked) {
464
- out.push('Untracked, so absent from the diff below:')
518
+ out.push(to
519
+ ? 'Untracked in the checkout, so in no revision and absent from the diff below:'
520
+ : 'Untracked, so absent from the diff below:')
465
521
  out.push('')
466
522
  out.push(fence(untracked))
467
523
  out.push('')
@@ -469,27 +525,25 @@ function buildBundle(argv) {
469
525
 
470
526
  out.push('## Diff')
471
527
  out.push('')
472
- const diff = git(['diff', from, ...diffPaths], repo)
528
+ const diff = git(['diff', from, ...ends, ...diffPaths], repo)
473
529
  out.push(diff.trim() ? fence(diff, 'diff') : '_empty_')
474
530
  out.push('')
475
531
 
476
532
  out.push('## Changed files, in full')
477
533
  out.push('')
478
- const skipped = oversize.map((rel) => {
479
- const size = statSync(join(repo, rel)).size
480
- return `${rel} — ${size} bytes, over --max-file-bytes (${maxFile}); its diff is above`
481
- })
534
+ const skipped = oversize.map((rel) =>
535
+ `${rel} — ${sizeOf(rel)} bytes, over --max-file-bytes (${maxFile}); its diff is above`)
482
536
  let spent = 0
483
537
  for (const rel of changed) {
484
- const abs = join(repo, rel)
485
- if (!existsSync(abs)) continue // deleted: the diff already carries it
486
538
  if (oversize.includes(rel)) continue
487
- const size = statSync(abs).size
539
+ const body = contents(rel)
540
+ if (body === null) continue // deleted: the diff already carries it
541
+ const size = Buffer.byteLength(body)
488
542
  if (spent + size > MAX_TOTAL_BYTES) { skipped.push(`${rel} — bundle full`); continue }
489
543
  spent += size
490
544
  out.push(`### ${rel}`)
491
545
  out.push('')
492
- out.push(fence(readFileSync(abs, 'utf8')))
546
+ out.push(fence(body))
493
547
  out.push('')
494
548
  }
495
549
  if (skipped.length) {
@@ -507,6 +561,12 @@ function buildBundle(argv) {
507
561
  out.push('_`quality.static_analysis` is empty — nothing to run_')
508
562
  } else {
509
563
  out.push(`\`quality.static_analysis\`: \`${cmd}\``)
564
+ if (to) {
565
+ out.push('')
566
+ out.push('Run against the **checkout**, not the frozen revision above — a linter reads the')
567
+ out.push('working tree and this does not rewrite it. Where the two differ, the diff is the')
568
+ out.push('evidence and this output is a second reading, taken later.')
569
+ }
510
570
  out.push('')
511
571
  let body, code = 0
512
572
  try {
@@ -539,6 +599,150 @@ function buildBundle(argv) {
539
599
  return { text: out.join('\n'), repo, cfg, branch, work, configs: configFiles(repo, harness) }
540
600
  }
541
601
 
602
+ // --- tier -------------------------------------------------------------------------------
603
+ // §2.0 of feat/review.md was 822 words of arithmetic addressed to a model: measure the diff,
604
+ // read a four-row table, take the lower of that and meta.json.size, apply the bump, flag a
605
+ // measurement landing just under a threshold. None of it is judgement, and the cost was never
606
+ // mainly the tokens — it is that the result cannot be checked. `06-review.md` recorded the tier
607
+ // the agent *said* it derived, and a round that eyeballed the diff wrote a byte-identical
608
+ // artifact. The section is explicitly aware the number carries an incentive, and then left the
609
+ // measuring to the party the incentive acts on.
610
+ //
611
+ // What stays with the model is what a command cannot see: whether a diff on a sensitive path
612
+ // changes control flow or only observability. This prints the paths and the two tiers; it does
613
+ // not pretend to decide that.
614
+
615
+ const SIZES = [['XS', 150], ['S', 600], ['M', 1500], ['L', Infinity]]
616
+ const LADDER = ['low', 'medium', 'high', 'xhigh', 'max']
617
+ const BASE_EFFORT = { XS: 'medium', S: 'high', M: 'high', L: 'xhigh' }
618
+ const PANEL = { XS: 'no', S: 'only on a sensitive surface', M: 'yes', L: 'yes' }
619
+
620
+ function sizeOfDiff(lines) {
621
+ return SIZES.find(([, max]) => lines <= max)[0]
622
+ }
623
+
624
+ // The globs `quality.sensitive_paths` carries are the shell kind a person writes by hand:
625
+ // `src/**/Payment/**`, `*.sql`, `config/security*`. Translated here rather than shelled out to
626
+ // so the answer does not depend on the shell's globstar setting.
627
+ function globToRe(glob) {
628
+ let re = ''
629
+ for (let i = 0; i < glob.length; i += 1) {
630
+ const c = glob[i]
631
+ if (c === '*') {
632
+ if (glob[i + 1] === '*') {
633
+ re += glob[i + 2] === '/' ? '(?:.*/)?' : '.*'
634
+ i += glob[i + 2] === '/' ? 2 : 1
635
+ } else re += '[^/]*'
636
+ } else if (c === '?') re += '[^/]'
637
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
638
+ }
639
+ return new RegExp(`^${re}$`)
640
+ }
641
+
642
+ function tier(argv) {
643
+ const flag = (name, fallback = null) => {
644
+ const i = argv.indexOf(name)
645
+ return i === -1 ? fallback : argv[i + 1]
646
+ }
647
+ const repo = git(['rev-parse', '--show-toplevel']).trim()
648
+ if (!repo) { console.error('flow tier: not a git checkout'); process.exit(1) }
649
+ const harness = flag('--harness')
650
+ if (harness && !OVERLAY_HARNESSES.includes(harness)) {
651
+ console.error(`flow tier: unknown harness \`${harness}\`. One of: ${OVERLAY_HARNESSES.join(', ')}.`)
652
+ process.exit(1)
653
+ }
654
+ const cfg = flowConfig(repo, harness)
655
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], repo).trim()
656
+ const resolves = (ref) => Boolean(git(['rev-parse', '--verify', '--quiet', ref], repo).trim())
657
+
658
+ const base = flag('--base') || cfg.git?.default_base || 'origin/main'
659
+ if (!resolves(base)) {
660
+ console.error(`flow tier: base \`${base}\` does not resolve. Set git.default_base or pass --base.`)
661
+ process.exit(1)
662
+ }
663
+ const rev = flag('--rev')
664
+ if (rev && !resolves(rev)) {
665
+ console.error(`flow tier: --rev \`${rev}\` does not resolve in this checkout.`)
666
+ process.exit(1)
667
+ }
668
+ const to = rev ? git(['rev-parse', rev], repo).trim() : null
669
+ const from = git(['merge-base', base, to ?? 'HEAD'], repo).trim() || base
670
+ const ends = to ? [to] : []
671
+
672
+ const exclude = Array.isArray(cfg.git?.diff_exclude) ? cfg.git.diff_exclude
673
+ : cfg.git?.diff_exclude ? [cfg.git.diff_exclude] : []
674
+ const paths = exclude.length ? ['--', '.', ...exclude.map((p) => `:(exclude)${p}`)] : []
675
+
676
+ // Changed lines, not the file count: the table this resolves is written in lines.
677
+ let changedLines = 0
678
+ for (const line of git(['diff', '--numstat', from, ...ends, ...paths], repo).split('\n')) {
679
+ const m = /^(\d+)\t(\d+)\t/.exec(line)
680
+ if (m) changedLines += Number(m[1]) + Number(m[2])
681
+ }
682
+ const files = git(['diff', '--name-only', from, ...ends, ...paths], repo).split('\n').filter(Boolean)
683
+
684
+ const diffSize = sizeOfDiff(changedLines)
685
+ const work = workFolder(repo, branch)
686
+ let meta = {}
687
+ if (work) { try { meta = JSON.parse(readFileSync(join(work, 'meta.json'), 'utf8')) } catch {} }
688
+ const recorded = typeof meta.size === 'string' && BASE_EFFORT[meta.size] ? meta.size : null
689
+ const order = (s) => SIZES.findIndex(([n]) => n === s)
690
+ const effective = recorded ? (order(diffSize) < order(recorded) ? diffSize : recorded) : diffSize
691
+
692
+ const globs = Array.isArray(cfg.quality?.sensitive_paths) ? cfg.quality.sensitive_paths
693
+ : cfg.quality?.sensitive_paths ? [cfg.quality.sensitive_paths] : []
694
+ const res = globs.map((g) => [g, globToRe(g)])
695
+ const sensitiveHits = []
696
+ for (const f of files) for (const [g, re] of res) if (re.test(f)) sensitiveHits.push(`${f} (${g})`)
697
+
698
+ const depth = cfg.quality?.review_depth || 'proportional'
699
+ const budget = cfg.agents?.budget_max === '0' ? 'no ceiling' : (Number(cfg.agents?.budget_max) || 12)
700
+ const fanout = Number(cfg.agents?.fanout_max) || 4
701
+
702
+ // Within 10% under a threshold that changes the tier: the one number in this command with an
703
+ // incentive on it, so the measurement says when it landed just under one.
704
+ const thresholds = [150, 600, 1500]
705
+ const near = thresholds.find((t) => changedLines <= t && changedLines >= Math.ceil(t * 0.9))
706
+
707
+ const bumped = LADDER[Math.min(LADDER.indexOf(BASE_EFFORT[effective]) + 1, LADDER.length - 1)]
708
+ const out = []
709
+ out.push(`Changed lines: ${changedLines} over ${files.length} file(s), \`${from.slice(0, 12)}\`..`
710
+ + `${to ? `\`${to.slice(0, 12)}\`` : 'working tree'} (base \`${base}\`)`)
711
+ out.push(`Config read: ${configFiles(repo, harness)}`)
712
+ out.push(`review_depth: ${depth}`)
713
+ out.push(`Diff size: ${diffSize} · meta.json.size: ${recorded ?? 'not recorded'}`
714
+ + ` · Effective size: ${effective}`)
715
+ if (recorded && order(diffSize) > order(recorded)) {
716
+ out.push(`Note: the diff points higher than the recorded size — the work may be misclassified.`
717
+ + ` One line in 06-review.md, not a heavier review.`)
718
+ }
719
+ if (depth === 'full') {
720
+ out.push('Tier: full → built-in `code-review` at `xhigh` + the project panel, whatever the size.')
721
+ } else if (depth === 'light') {
722
+ out.push('Tier: light → built-in (or quality.review_skill) at `medium`, no panel, no §3, no §6 —'
723
+ + ' unless a sensitive surface applies, which raises it to proportional for this work.')
724
+ } else {
725
+ out.push(`Tier: proportional → built-in at \`${BASE_EFFORT[effective]}\`, panel: ${PANEL[effective]}`)
726
+ out.push(`With the sensitive-surface bump: built-in at \`${bumped}\`, panel always`)
727
+ }
728
+ out.push(`Budget: budget_max ${budget} · fanout_max ${fanout}`)
729
+ out.push(near
730
+ ? `Within 10% under ${near}: yes (${changedLines} lines) — say so in 06-review.md beside the`
731
+ + ' effective size. It changes no tier; it is what makes flow-core §9 checkable afterwards.'
732
+ : 'Within 10% under a threshold: no')
733
+ out.push(sensitiveHits.length
734
+ ? `quality.sensitive_paths matched:\n${sensitiveHits.map((h) => ` - ${h}`).join('\n')}`
735
+ : `quality.sensitive_paths matched: none${globs.length ? '' : ' (the key is empty)'}`)
736
+ out.push('')
737
+ out.push('Left to you, because a command cannot see it: whether the generic categories apply')
738
+ out.push('(auth, secrets, payments, personal data, a public contract, a schema change), and')
739
+ out.push('whether a matched path is a control-flow change or observability only — a log level,')
740
+ out.push('a metric, a message text gets the review its size already earned. Decide, then record')
741
+ out.push('which of the two it was. Any deviation from the tier above goes in 06-review.md with')
742
+ out.push('its reason.')
743
+ console.log(out.join('\n'))
744
+ }
745
+
542
746
  function bundle(argv) {
543
747
  console.log(buildBundle(argv).text)
544
748
  }
@@ -718,7 +922,9 @@ async function review(argv) {
718
922
  for (const f of findings) {
719
923
  out.push(`- **${f.severity ?? 'unrated'}** \`${f.file}${f.line ? `:${f.line}` : ''}\` — ${f.what}`)
720
924
  out.push(` fix: ${f.fix}`)
721
- if (f.roles.length > 1) out.push(` raised by ${f.roles.length} reviewers`)
925
+ // Every source, not a count of them. A finding one pass caught and a finding three
926
+ // passes caught say opposite things about that pass, and a headcount collapses both.
927
+ out.push(` raised by: ${f.roles.map((r) => r.split(':')[0]).join(', ')}`)
722
928
  }
723
929
  if (!findings.length) out.push('_none_')
724
930
  out.push('')
@@ -752,6 +958,16 @@ async function review(argv) {
752
958
  usd: costs.length ? Number(costs.reduce((a, b) => a + b, 0).toFixed(6)) : null,
753
959
  source: costs.length ? 'harness' : 'not reported',
754
960
  })
961
+ // One row per finding per source. Beyond the built-in and the panel, a review runs four
962
+ // specialised passes, every one of them written after a real defect got through, and not
963
+ // one of them can show it is still earning its cost: a finding that reaches `## Blockers`
964
+ // has lost where it came from. No finding text here — that stays in the artifact.
965
+ recordFindings(built.work, findings.flatMap((f) => f.roles.map((role) => ({
966
+ origin: role.split(':')[0],
967
+ severity: f.severity ?? 'unrated',
968
+ file: f.file,
969
+ discarded_by_skeptic: false,
970
+ }))))
755
971
  }
756
972
  const outFile = argv.includes('--out') ? argv[argv.indexOf('--out') + 1] : null
757
973
  if (outFile) { writeFileSync(outFile, `${text}\n`); console.log(`wrote ${outFile}`) } else console.log(text)
@@ -767,6 +983,31 @@ async function review(argv) {
767
983
  // every figure it does show is a client-side estimate, which is what the harnesses call
768
984
  // them too.
769
985
 
986
+ // Read-modify-write against meta.json, the flow's own state, so both writers go through a
987
+ // temporary file: a half-written meta.json loses the work, not just the figure.
988
+ function editMeta(work, fn) {
989
+ if (!work) return
990
+ const p = join(work, 'meta.json')
991
+ if (!existsSync(p)) return
992
+ let meta
993
+ try { meta = JSON.parse(readFileSync(p, 'utf8')) } catch { return }
994
+ fn(meta)
995
+ const tmp = `${p}.tmp`
996
+ writeFileSync(tmp, `${JSON.stringify(meta, null, 2)}\n`)
997
+ renameSync(tmp, p)
998
+ }
999
+
1000
+ function recordFindings(work, rows) {
1001
+ if (!rows.length) return
1002
+ editMeta(work, (meta) => {
1003
+ const prev = Array.isArray(meta.review_findings) ? meta.review_findings : []
1004
+ // The round is this work's nth recorded review, so a pass's contribution can be read
1005
+ // per round and the twenty-review retirement rule has something to count.
1006
+ const round = prev.reduce((n, r) => Math.max(n, Number(r.round) || 0), 0) + 1
1007
+ meta.review_findings = [...prev, ...rows.map((r) => ({ round, ...r }))]
1008
+ })
1009
+ }
1010
+
770
1011
  function recordCost(work, entry) {
771
1012
  if (!work) return
772
1013
  const p = join(work, 'meta.json')
@@ -775,8 +1016,6 @@ function recordCost(work, entry) {
775
1016
  try { meta = JSON.parse(readFileSync(p, 'utf8')) } catch { return }
776
1017
  meta.cost = Array.isArray(meta.cost) ? meta.cost : []
777
1018
  meta.cost.push(entry)
778
- // Write through a temporary file: meta.json is the flow's own state, and a half-written
779
- // one loses the work, not just the figure.
780
1019
  const tmp = `${p}.tmp`
781
1020
  writeFileSync(tmp, `${JSON.stringify(meta, null, 2)}\n`)
782
1021
  renameSync(tmp, p)
@@ -817,6 +1056,52 @@ function cost(argv) {
817
1056
  console.log()
818
1057
  console.log('These are the harness\'s own client-side estimates and can differ from a bill.')
819
1058
  console.log('Phases with no row did not measure anything — absence here is not zero.')
1059
+
1060
+ findingsByOrigin(meta)
1061
+ }
1062
+
1063
+ // What the money was spent ON. A review runs the built-in, a panel, and four specialised
1064
+ // passes — roughly 1 800 words of a 5 683-word command — every one of them added after a real
1065
+ // defect got through, and not one able to show it still earns its cost. The only argument
1066
+ // available was an opinion, and opinions about this converge on whoever spoke last.
1067
+ function findingsByOrigin(meta) {
1068
+ const rows = Array.isArray(meta.review_findings) ? meta.review_findings : []
1069
+ console.log()
1070
+ if (!rows.length) {
1071
+ console.log('No findings recorded by origin yet. `flow review --record` writes one row per')
1072
+ console.log('finding per source, and the agentic panel records the same at §9 of review.')
1073
+ return
1074
+ }
1075
+ const rounds = new Set(rows.map((r) => r.round)).size
1076
+ // A finding is identified by where it is, not by its text — the text lives in the artifact.
1077
+ const key = (r) => `${r.round}::${r.severity}::${r.file}`
1078
+ const sources = new Map()
1079
+ for (const r of rows) {
1080
+ if (!sources.has(key(r))) sources.set(key(r), new Set())
1081
+ sources.get(key(r)).add(r.origin)
1082
+ }
1083
+ const per = new Map()
1084
+ for (const r of rows) {
1085
+ const e = per.get(r.origin) ?? { total: 0, exclusive: 0, survived: 0 }
1086
+ e.total += 1
1087
+ if (sources.get(key(r)).size === 1) {
1088
+ e.exclusive += 1
1089
+ if (!r.discarded_by_skeptic) e.survived += 1
1090
+ }
1091
+ per.set(r.origin, e)
1092
+ }
1093
+ console.log(`## Findings by origin — ${rows.length} row(s) over ${rounds} round(s)`)
1094
+ console.log()
1095
+ console.log('| origin | findings | only this one | and survived §6 |')
1096
+ console.log('|---|---|---|---|')
1097
+ for (const [origin, e] of [...per.entries()].sort((a, b) => b[1].survived - a[1].survived)) {
1098
+ console.log(`| ${origin} | ${e.total} | ${e.exclusive} | ${e.survived} |`)
1099
+ }
1100
+ console.log()
1101
+ console.log('"only this one" is the column that decides anything: a finding three sources')
1102
+ console.log('raised says nothing about any of them. A pass contributing no exclusive surviving')
1103
+ console.log('finding across twenty reviews is removed from the command — the rule is written')
1104
+ console.log('down before the data arrives so it cannot be argued backwards later.')
820
1105
  }
821
1106
 
822
1107
  function usage() {
@@ -824,16 +1109,18 @@ function usage() {
824
1109
 
825
1110
  npx flow-workflows install <harness> [project]
826
1111
  npx flow-workflows check
827
- npx flow-workflows bundle [--base <ref>] [--checks] [--harness <name>]
1112
+ npx flow-workflows bundle [--base <ref>] [--rev <ref>] [--checks] [--harness <name>]
828
1113
  [--max-file-bytes N] [--max-diff-lines N]
1114
+ npx flow-workflows tier [--base <ref>] [--rev <ref>] [--harness <name>]
829
1115
  npx flow-workflows review [--out <file>] [--record] [same options as bundle]
830
1116
  npx flow-workflows cost
831
1117
 
832
1118
  Harnesses: ${Object.keys(HARNESSES).join(' · ')}
833
1119
  "project" installs into the current repo instead of your user folder.
834
1120
 
835
- Claude Code and Codex CLI have their own marketplaces:
1121
+ Claude Code, Codex CLI and zcode have their own marketplaces:
836
1122
  npx flow-workflows install claude shows how
1123
+ npx flow-workflows install zcode shows how
837
1124
 
838
1125
  "bundle" prints one context pack for the current branch — worklist, diff, the changed
839
1126
  files in full, and the work's handoff — so a phase reads it in one call instead of
@@ -844,10 +1131,24 @@ Claude Code and Codex CLI have their own marketplaces:
844
1131
  keeps its diff; the worklist always shows the whole change, and anything held back is
845
1132
  named there rather than at the end. FLOW.md is read from the main checkout when this
846
1133
  runs inside a worktree that does not carry one.
1134
+ --rev freezes what is read: the diff and the files come from that one revision instead
1135
+ of the live checkout, so every reader of the pack reads the same bytes however long
1136
+ they take. Pass a commit, or the object "git stash create" prints for a dirty tree.
1137
+ Untracked files are in no revision and stay out; --checks still runs against the
1138
+ checkout, and the pack says so.
847
1139
  --harness names the overlay to merge on top of it (${OVERLAY_HARNESSES.join(' · ')}).
848
1140
  Without it no overlay is read at all, which the pack's header says out loud: a key set
849
1141
  only there — agents.exec_cmd, a harness's models — would otherwise read as unset.
850
1142
 
1143
+ "tier" resolves the review depth from the same config: changed lines against the base,
1144
+ diff size, meta.json.size, effective size, the built-in effort with and without the
1145
+ sensitive-surface bump, the resolved budget_max/fanout_max, whether the measurement
1146
+ landed within 10% under a threshold that changes the tier, and which
1147
+ quality.sensitive_paths globs the diff matched. Arithmetic, printed once, so the tier in
1148
+ 06-review.md is a computed line rather than a narrated one. What it deliberately does not
1149
+ decide: whether a generic category applies, and whether a matched path is a control-flow
1150
+ change or observability only.
1151
+
851
1152
  "review" runs one reviewer per role through agents.exec_cmd — any non-interactive
852
1153
  harness invocation — over that same pack, one turn each instead of an agentic loop
853
1154
  each, and deduplicates what comes back. Empty exec_cmd = the agentic panel reviews,
@@ -856,6 +1157,9 @@ Claude Code and Codex CLI have their own marketplaces:
856
1157
  "cost" prints what each phase of this branch's work actually reported spending, from
857
1158
  what --record wrote into meta.json. A harness that reports no figure is recorded as
858
1159
  "not reported": a headcount of subagents is not a cost, and neither is a guess.
1160
+ It also breaks the round's findings down by origin — which reviewer or pass raised
1161
+ each one, how many only it raised, and how many of those survived verification. That
1162
+ last column is the only evidence a pass is still earning the words it costs.
859
1163
 
860
1164
  Updating: re-run the install command — it sweeps the previous version first.
861
1165
  Docs: https://github.com/mashware/flow-workflows`)
@@ -864,14 +1168,16 @@ Docs: https://github.com/mashware/flow-workflows`)
864
1168
  const [cmd, tool, scope] = process.argv.slice(2)
865
1169
 
866
1170
  if (cmd === 'bundle') bundle(process.argv.slice(3))
1171
+ else if (cmd === 'tier') tier(process.argv.slice(3))
867
1172
  else if (cmd === 'cost') cost(process.argv.slice(3))
868
1173
  else if (cmd === 'review') review(process.argv.slice(3)).catch((e) => { console.error(`flow review: ${e.message}`); process.exit(1) })
869
1174
  else if (cmd === 'check') check()
870
1175
  else if (cmd === 'install' && tool === 'claude') claude()
1176
+ else if (cmd === 'install' && tool === 'zcode') zcode()
871
1177
  else if (cmd === 'install' && HARNESSES[tool]) install(tool, scope === 'project' ? 'project' : 'global')
872
1178
  else if (cmd === 'install') {
873
1179
  console.error(`Unknown harness: ${tool ?? '(none given)'}`)
874
- console.error(`Expected one of: ${Object.keys(HARNESSES).join(', ')}, claude`)
1180
+ console.error(`Expected one of: ${Object.keys(HARNESSES).join(', ')}, claude, zcode`)
875
1181
  process.exit(1)
876
1182
  } else if (cmd === '--version' || cmd === '-v') console.log(version())
877
1183
  else if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') usage()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flow-workflows",
3
- "version": "0.65.0",
3
+ "version": "0.67.0",
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": {
@@ -23,6 +23,7 @@
23
23
  "gemini-cli",
24
24
  "codex",
25
25
  "hermes",
26
+ "zcode",
26
27
  "coding-agent",
27
28
  "feature-flow",
28
29
  "bug-flow"
@@ -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.65.0",
6
+ "version": "0.67.0",
7
7
  "author": {
8
8
  "name": "Alberto Vioque",
9
9
  "email": "mashware@gmail.com"
@@ -17,5 +17,8 @@
17
17
  "git",
18
18
  "feature-flow",
19
19
  "bug-flow"
20
- ]
20
+ ],
21
+ "experimental": {
22
+ "evals": "evals"
23
+ }
21
24
  }