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.
- package/README.md +43 -8
- package/adapters/codex/CORE.md +18 -1
- package/adapters/codex/skills/flow-bug-review/SKILL.md +29 -0
- package/adapters/codex/skills/flow-doctor/SKILL.md +21 -0
- package/adapters/codex/skills/flow-feat-review/SKILL.md +29 -0
- package/adapters/codex/skills/flow-init/SKILL.md +10 -0
- package/adapters/codex/skills/flow-work-status/SKILL.md +2 -0
- package/adapters/gemini/CORE.md +18 -1
- package/adapters/gemini/commands/flow/bug/review.toml +29 -0
- package/adapters/gemini/commands/flow/doctor.toml +21 -0
- package/adapters/gemini/commands/flow/feat/review.toml +29 -0
- package/adapters/gemini/commands/flow/init.toml +10 -0
- package/adapters/gemini/commands/flow/work/status.toml +2 -0
- package/adapters/hermes/CORE.md +18 -1
- package/adapters/hermes/skills/flow-bug-review/SKILL.md +29 -0
- package/adapters/hermes/skills/flow-doctor/SKILL.md +21 -0
- package/adapters/hermes/skills/flow-feat-review/SKILL.md +29 -0
- package/adapters/hermes/skills/flow-init/SKILL.md +10 -0
- package/adapters/hermes/skills/flow-work-status/SKILL.md +2 -0
- package/adapters/opencode/CORE.md +18 -1
- package/adapters/opencode/commands/flow-bug-review.md +29 -0
- package/adapters/opencode/commands/flow-doctor.md +21 -0
- package/adapters/opencode/commands/flow-feat-review.md +29 -0
- package/adapters/opencode/commands/flow-init.md +10 -0
- package/adapters/opencode/commands/flow-work-status.md +2 -0
- package/bin/cli.mjs +543 -4
- package/package.json +1 -1
- package/plugins/flow/.claude-plugin/plugin.json +1 -1
- package/plugins/flow/CHANGELOG.md +69 -0
- 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 === '
|
|
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 (
|
|
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.
|
|
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.
|
|
6
|
+
"version": "0.65.0-rc.2",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Alberto Vioque",
|
|
9
9
|
"email": "mashware@gmail.com"
|