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.
- package/README.md +25 -4
- package/adapters/README.md +11 -3
- package/adapters/codex/CORE.md +31 -5
- package/adapters/codex/skills/flow-bug-fix/SKILL.md +5 -0
- package/adapters/codex/skills/flow-bug-review/SKILL.md +19 -6
- package/adapters/codex/skills/flow-bug-ship/SKILL.md +5 -3
- package/adapters/codex/skills/flow-feat-build/SKILL.md +16 -2
- package/adapters/codex/skills/flow-feat-review/SKILL.md +51 -47
- package/adapters/codex/skills/flow-feat-ship/SKILL.md +23 -4
- package/adapters/codex/skills/flow-feat-validate/SKILL.md +5 -1
- package/adapters/codex/skills/flow-init/SKILL.md +4 -3
- package/adapters/codex/skills/flow-work-README/SKILL.md +9 -2
- package/adapters/codex/skills/flow-work-respond/SKILL.md +1 -1
- package/adapters/gemini/CORE.md +31 -5
- package/adapters/gemini/commands/flow/bug/fix.toml +5 -0
- package/adapters/gemini/commands/flow/bug/review.toml +19 -6
- package/adapters/gemini/commands/flow/bug/ship.toml +5 -3
- package/adapters/gemini/commands/flow/feat/build.toml +16 -2
- package/adapters/gemini/commands/flow/feat/review.toml +51 -47
- package/adapters/gemini/commands/flow/feat/ship.toml +23 -4
- package/adapters/gemini/commands/flow/feat/validate.toml +5 -1
- package/adapters/gemini/commands/flow/init.toml +4 -3
- package/adapters/gemini/commands/flow/work/README.toml +9 -2
- package/adapters/gemini/commands/flow/work/respond.toml +1 -1
- package/adapters/hermes/CORE.md +31 -5
- package/adapters/hermes/skills/flow-bug-fix/SKILL.md +5 -0
- package/adapters/hermes/skills/flow-bug-review/SKILL.md +19 -6
- package/adapters/hermes/skills/flow-bug-ship/SKILL.md +5 -3
- package/adapters/hermes/skills/flow-feat-build/SKILL.md +16 -2
- package/adapters/hermes/skills/flow-feat-review/SKILL.md +51 -47
- package/adapters/hermes/skills/flow-feat-ship/SKILL.md +23 -4
- package/adapters/hermes/skills/flow-feat-validate/SKILL.md +5 -1
- package/adapters/hermes/skills/flow-init/SKILL.md +4 -3
- package/adapters/hermes/skills/flow-work-README/SKILL.md +9 -2
- package/adapters/hermes/skills/flow-work-respond/SKILL.md +1 -1
- package/adapters/opencode/CORE.md +31 -5
- package/adapters/opencode/commands/flow-bug-fix.md +5 -0
- package/adapters/opencode/commands/flow-bug-review.md +19 -6
- package/adapters/opencode/commands/flow-bug-ship.md +5 -3
- package/adapters/opencode/commands/flow-feat-build.md +16 -2
- package/adapters/opencode/commands/flow-feat-review.md +51 -47
- package/adapters/opencode/commands/flow-feat-ship.md +23 -4
- package/adapters/opencode/commands/flow-feat-validate.md +5 -1
- package/adapters/opencode/commands/flow-init.md +4 -3
- package/adapters/opencode/commands/flow-work-README.md +9 -2
- package/adapters/opencode/commands/flow-work-respond.md +1 -1
- package/bin/cli.mjs +330 -24
- package/package.json +2 -1
- package/plugins/flow/.claude-plugin/plugin.json +5 -2
- package/plugins/flow/CHANGELOG.md +227 -0
- 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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
}
|