@polderlabs/bizar 10.20.0 → 10.21.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/cli/commands/models.mjs +214 -0
- package/cli/commands/workflow-gc.mjs +227 -0
- package/config/workflows/bizar-debug.js +17 -5
- package/config/workflows/bizar-implement.js +22 -6
- package/config/workflows/bizar-research.js +33 -6
- package/config/workflows/lib/dispatch.js +426 -1
- package/config/workflows/ultracode-research.js +13 -3
- package/config/workflows/ultracode-review.js +7 -2
- package/config/workflows/ultracode.js +31 -6
- package/package.json +4 -2
- package/packages/sdk/package.json +1 -1
package/cli/commands/models.mjs
CHANGED
|
@@ -1473,6 +1473,168 @@ async function pickModelsInteractive({ ordered, candidates, selected, lastOrder,
|
|
|
1473
1473
|
}
|
|
1474
1474
|
}
|
|
1475
1475
|
|
|
1476
|
+
// ── 10.19.9 Phase 3: post-confirm status screen ──────────────────────────────
|
|
1477
|
+
//
|
|
1478
|
+
// After the operator confirms a picker selection, `bizar models` prints a
|
|
1479
|
+
// status screen showing one row per picked ID with a ✔ / ✖ / ⤳ icon. The
|
|
1480
|
+
// shape mirrors `cli/doctor.mjs#runDoctor` (per-row ✔ / ✖ pattern) so
|
|
1481
|
+
// operators see a familiar surface. The renderer is CLI-only — the
|
|
1482
|
+
// SessionStart hook has no TTY and no outbound HTTP and must NOT import
|
|
1483
|
+
// these helpers.
|
|
1484
|
+
|
|
1485
|
+
const STATUS_ICON = Object.freeze({
|
|
1486
|
+
fresh: '✔', // ✔
|
|
1487
|
+
refreshed: '✔', // ✔
|
|
1488
|
+
unavailable: '✖', // ✖
|
|
1489
|
+
preexisting: '⤳', // ⤳
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
/**
|
|
1493
|
+
* Classify one picked id into the four status states the renderer can
|
|
1494
|
+
* print. Pure: no I/O, no side effects.
|
|
1495
|
+
*
|
|
1496
|
+
* 'preexisting' — the operator-confirmed pick already lived in
|
|
1497
|
+
* userSelected.models BEFORE this run. Wins over every
|
|
1498
|
+
* other branch (the operator explicitly re-selected
|
|
1499
|
+
* a known-good pick).
|
|
1500
|
+
* 'unavailable' — the post-confirm Models.dev enrichment returned
|
|
1501
|
+
* profile === null AND no _gateway.name fallback was
|
|
1502
|
+
* available. Surface as ✖.
|
|
1503
|
+
* 'refreshed' — profile exists with metadata.source === 'gateway-
|
|
1504
|
+
* fallback' (Phase 1 contract). Models.dev missed but
|
|
1505
|
+
* the gateway's name field rescued the row. Surface
|
|
1506
|
+
* as ✔ (refreshed).
|
|
1507
|
+
* 'fresh' — profile exists with metadata.source === 'models.dev'
|
|
1508
|
+
* (the Phase 2 enrichment succeeded). Surface as ✔.
|
|
1509
|
+
*
|
|
1510
|
+
* @param {string} id the picked id (e.g. 'anthropic/claude-3-5-sonnet')
|
|
1511
|
+
* @param {object|null} profile the Phase 2 enrichment result for that id
|
|
1512
|
+
* (null when both Models.dev AND _gateway.name missed)
|
|
1513
|
+
* @param {Set<string>} [preExisting] snapshot of userSelected.models BEFORE applyModels
|
|
1514
|
+
* overwrote the block; undefined is treated as empty
|
|
1515
|
+
* @returns {'fresh'|'refreshed'|'unavailable'|'preexisting'}
|
|
1516
|
+
*/
|
|
1517
|
+
export function classifyPickStatus(id, profile, preExisting) {
|
|
1518
|
+
if (preExisting instanceof Set && preExisting.has(id)) return 'preexisting';
|
|
1519
|
+
if (!profile) return 'unavailable';
|
|
1520
|
+
if (profile.metadata && profile.metadata.source === 'gateway-fallback') return 'refreshed';
|
|
1521
|
+
return 'fresh';
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Resolve the profile object from the heterogeneous shapes Phase 2 hands
|
|
1526
|
+
* us: a `Map<string, object|null>` when called from `run()`, a plain
|
|
1527
|
+
* object when called from a fixture, or `undefined` for "the enrichment
|
|
1528
|
+
* step never ran for this id".
|
|
1529
|
+
*/
|
|
1530
|
+
function resolveProfile(profiles, id) {
|
|
1531
|
+
if (!profiles) return null;
|
|
1532
|
+
if (profiles instanceof Map) {
|
|
1533
|
+
return profiles.has(id) ? profiles.get(id) : null;
|
|
1534
|
+
}
|
|
1535
|
+
if (typeof profiles === 'object') {
|
|
1536
|
+
return Object.prototype.hasOwnProperty.call(profiles, id) ? profiles[id] : null;
|
|
1537
|
+
}
|
|
1538
|
+
return null;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function formatStatusRow({ id, profile, status }) {
|
|
1542
|
+
const icon = STATUS_ICON[status];
|
|
1543
|
+
if (status === 'preexisting') {
|
|
1544
|
+
return chalk.dim(` ${icon} ${id} (already in userSelected)`);
|
|
1545
|
+
}
|
|
1546
|
+
if (status === 'unavailable') {
|
|
1547
|
+
// Profile is null AND no _gateway.name fallback. The renderer never
|
|
1548
|
+
// reaches this branch when Phase 2's _gateway.name plumbing survived.
|
|
1549
|
+
return chalk.red(` ${icon} ${id} (metadata unavailable)`);
|
|
1550
|
+
}
|
|
1551
|
+
// 'fresh' or 'refreshed' — both render as ✔ with the model name.
|
|
1552
|
+
// For 'refreshed' the only label available is the Phase 1 _gateway.name;
|
|
1553
|
+
// for 'fresh' we prefer Models.dev's name and fall back to _gateway.name.
|
|
1554
|
+
const label = (profile && profile.name)
|
|
1555
|
+
|| (profile && profile._gateway && profile._gateway.name)
|
|
1556
|
+
|| id;
|
|
1557
|
+
return chalk.green(` ${icon} ${id} (${label})`);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* Render the post-confirm status screen for one picker run.
|
|
1562
|
+
*
|
|
1563
|
+
* CLI-only surface — do NOT import from the SessionStart hook (no TTY,
|
|
1564
|
+
* no outbound HTTP). The helper writes to `out` based on `isTTY`:
|
|
1565
|
+
* - isTTY=true → one ` <icon> <id> (<label>)` line per picked id
|
|
1566
|
+
* plus a footer `N passed, M failed, K skipped`.
|
|
1567
|
+
* - isTTY=false → one collapsed line ` v N passed, M failed, K skipped`
|
|
1568
|
+
* appended to the existing "Saved" block (so piped
|
|
1569
|
+
* callers see one summary line, not a flood of rows).
|
|
1570
|
+
*
|
|
1571
|
+
* Always returns `{ perPick, totals, exitCode }` so `--json` callers can
|
|
1572
|
+
* embed the data in the JSON envelope without re-implementing the
|
|
1573
|
+
* classification:
|
|
1574
|
+
* - status.perPick = [{ id, status, hasProfile }]
|
|
1575
|
+
* - status.totals = { passed, failed, skipped }
|
|
1576
|
+
* - exitCode = 0 when at least one ✔; 2 when EVERY row is ✖;
|
|
1577
|
+
* 0 otherwise (mixed picks, or every pick ⤳).
|
|
1578
|
+
*
|
|
1579
|
+
* The caller decides whether to call `process.exit(exitCode)`; the
|
|
1580
|
+
* renderer never exits on its own.
|
|
1581
|
+
*
|
|
1582
|
+
* @param {object} opts
|
|
1583
|
+
* @param {string[]} opts.picked ids the operator confirmed
|
|
1584
|
+
* @param {Map<string, object|null>|object} [opts.profiles] Phase 2 enrichment map
|
|
1585
|
+
* @param {Set<string>} [opts.preExisting] userSelected.models snapshot BEFORE applyModels
|
|
1586
|
+
* @param {object} [opts.fetchSummary] unused today; reserved for Phase 4 disable surfacing
|
|
1587
|
+
* @param {NodeJS.WritableStream} [opts.out] defaults to process.stdout (test fixtures inject a stub)
|
|
1588
|
+
* @param {boolean} [opts.isTTY=true] when false, print the single-line collapse
|
|
1589
|
+
* @returns {{ perPick: Array<{id:string, status:string, hasProfile:boolean}>, totals: {passed:number, failed:number, skipped:number}, exitCode: 0|2 }}
|
|
1590
|
+
*/
|
|
1591
|
+
export function renderPickStatusScreen({
|
|
1592
|
+
picked = [],
|
|
1593
|
+
profiles,
|
|
1594
|
+
preExisting,
|
|
1595
|
+
fetchSummary = null,
|
|
1596
|
+
out = process.stdout,
|
|
1597
|
+
isTTY = true,
|
|
1598
|
+
} = {}) {
|
|
1599
|
+
const perPick = [];
|
|
1600
|
+
let passed = 0;
|
|
1601
|
+
let failed = 0;
|
|
1602
|
+
let skipped = 0;
|
|
1603
|
+
for (const id of picked) {
|
|
1604
|
+
const profile = resolveProfile(profiles, id);
|
|
1605
|
+
const status = classifyPickStatus(id, profile, preExisting);
|
|
1606
|
+
const hasProfile = !!profile;
|
|
1607
|
+
perPick.push({ id, status, hasProfile });
|
|
1608
|
+
if (status === 'preexisting') skipped += 1;
|
|
1609
|
+
else if (status === 'unavailable') failed += 1;
|
|
1610
|
+
else passed += 1;
|
|
1611
|
+
}
|
|
1612
|
+
const totals = { passed, failed, skipped };
|
|
1613
|
+
|
|
1614
|
+
if (isTTY) {
|
|
1615
|
+
for (const entry of perPick) {
|
|
1616
|
+
const profile = resolveProfile(profiles, entry.id);
|
|
1617
|
+
out.write(formatStatusRow({ id: entry.id, profile, status: entry.status }) + '\n');
|
|
1618
|
+
}
|
|
1619
|
+
const summary = chalk.dim(` ${passed} passed, ${failed} failed, ${skipped} skipped`);
|
|
1620
|
+
out.write(summary + '\n');
|
|
1621
|
+
} else {
|
|
1622
|
+
const summary = chalk.dim(` v ${passed} passed, ${failed} failed, ${skipped} skipped`);
|
|
1623
|
+
out.write(summary + '\n');
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// Exit-code contract: 0 when at least one ✔; 2 only when EVERY row is ✖.
|
|
1627
|
+
// Mixed picks and all-⤳ picks both exit 0 (the operator got a usable
|
|
1628
|
+
// confirmation, even if some picks couldn't be enriched).
|
|
1629
|
+
const exitCode = (passed > 0)
|
|
1630
|
+
? 0
|
|
1631
|
+
: (failed === picked.length && picked.length > 0)
|
|
1632
|
+
? 2
|
|
1633
|
+
: 0;
|
|
1634
|
+
|
|
1635
|
+
return { perPick, totals, exitCode };
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1476
1638
|
/**
|
|
1477
1639
|
* Build a small async iterator over `stdin` lines.
|
|
1478
1640
|
* Returns null from `next()` when the stream ends.
|
|
@@ -1582,6 +1744,24 @@ function showHelp() {
|
|
|
1582
1744
|
userSelected. If userSelected is empty, every dispatch inherits the
|
|
1583
1745
|
active session model.
|
|
1584
1746
|
|
|
1747
|
+
Post-confirm status screen (interactive only): after the picker saves a
|
|
1748
|
+
selection, every confirmed id is reported on its own row with one of:
|
|
1749
|
+
|
|
1750
|
+
✔ Models.dev profile retrieved (or carried over via the gateway
|
|
1751
|
+
_gateway.name fallback). The label shows the profile name.
|
|
1752
|
+
✖ Models.dev miss AND no _gateway.name fallback. The row prints
|
|
1753
|
+
\`(metadata unavailable)\`.
|
|
1754
|
+
⤳ Id was already in userSelected.models before this run
|
|
1755
|
+
(re-confirmed pick). Label: \`(already in userSelected)\`.
|
|
1756
|
+
|
|
1757
|
+
The screen ends with a footer \`N passed, M failed, K skipped\`. In a
|
|
1758
|
+
TTY the screen is multi-row; in a pipe it collapses to one summary
|
|
1759
|
+
line appended to the existing \"Saved N model(s)\" block. \`--json\`
|
|
1760
|
+
carries the equivalent data in \`status.perPick\` (one entry per
|
|
1761
|
+
picked id) and \`status.totals\` ({passed, failed, skipped}). Exit
|
|
1762
|
+
code is 0 when at least one ✔ was reported, 2 when every row is ✖;
|
|
1763
|
+
mixed ✔+✖ still exits 0.
|
|
1764
|
+
|
|
1585
1765
|
Endpoint resolution order: $BIZAR_MODEL_ROUTER_URL / $ANTHROPIC_BASE_URL
|
|
1586
1766
|
-> ~/.claude/settings.json#env.BIZAR_MODEL_ROUTER_URL
|
|
1587
1767
|
-> model-router.json#endpoint
|
|
@@ -1841,6 +2021,12 @@ export async function run(name, args, isHelpRequest, deps = {}) {
|
|
|
1841
2021
|
|
|
1842
2022
|
const router = loadRouter(routerPath);
|
|
1843
2023
|
const { models: current } = currentSelection(router);
|
|
2024
|
+
// 10.19.9 Phase 3: capture a snapshot of userSelected.models BEFORE
|
|
2025
|
+
// applyModels overwrites the block, so the post-confirm status screen
|
|
2026
|
+
// can classify re-confirmed picks as `preexisting` (⤳) instead of
|
|
2027
|
+
// `fresh` (✔). `current` is read once and shared with pickModelsFn;
|
|
2028
|
+
// we wrap it in a Set so classifyPickStatus can use `.has(id)`.
|
|
2029
|
+
const preExisting = new Set(current);
|
|
1844
2030
|
const picked = await pickModelsFn({ candidates, current });
|
|
1845
2031
|
|
|
1846
2032
|
// Phase 2 (10.19.8): the Models.dev catalog fetch moves HERE — only
|
|
@@ -1907,6 +2093,18 @@ export async function run(name, args, isHelpRequest, deps = {}) {
|
|
|
1907
2093
|
// Phase 2 the array equals the picks list (no filtering); Phase 4
|
|
1908
2094
|
// will filter to only the IDs that actually received a profile.
|
|
1909
2095
|
const enriched = picked.slice();
|
|
2096
|
+
// 10.19.9 Phase 3: --json gains `status.perPick` + `status.totals`.
|
|
2097
|
+
// The renderer writes nothing to stdout in --json mode (out is a
|
|
2098
|
+
// no-op writable; isTTY=false keeps the single-line collapse from
|
|
2099
|
+
// leaking into the JSON stream). The JSON envelope carries the
|
|
2100
|
+
// equivalent data shape.
|
|
2101
|
+
const statusResult = renderPickStatusScreen({
|
|
2102
|
+
picked,
|
|
2103
|
+
profiles: profilesMap,
|
|
2104
|
+
preExisting,
|
|
2105
|
+
out: { write: () => true },
|
|
2106
|
+
isTTY: false,
|
|
2107
|
+
});
|
|
1910
2108
|
process.stdout.write(JSON.stringify({
|
|
1911
2109
|
applied: block,
|
|
1912
2110
|
endpoint,
|
|
@@ -1916,7 +2114,9 @@ export async function run(name, args, isHelpRequest, deps = {}) {
|
|
|
1916
2114
|
modelsDev: modelsDevStatus,
|
|
1917
2115
|
sync,
|
|
1918
2116
|
picker,
|
|
2117
|
+
status: { perPick: statusResult.perPick, totals: statusResult.totals, exitCode: statusResult.exitCode },
|
|
1919
2118
|
}, null, 2) + '\n');
|
|
2119
|
+
if (statusResult.exitCode !== 0) process.exitCode = statusResult.exitCode;
|
|
1920
2120
|
} else {
|
|
1921
2121
|
console.log(chalk.green(`\n v Saved ${block.models.length} model(s) to ${routerPath}:`));
|
|
1922
2122
|
console.log(chalk.dim(` Models.dev profiles: ${Object.keys(block.profiles || {}).length}/${block.models.length}`));
|
|
@@ -1930,6 +2130,20 @@ export async function run(name, args, isHelpRequest, deps = {}) {
|
|
|
1930
2130
|
if (picker.wrote) {
|
|
1931
2131
|
console.log(chalk.dim(` /model picker populated with ${picker.options.length} entr${picker.options.length === 1 ? 'y' : 'ies'}`));
|
|
1932
2132
|
}
|
|
2133
|
+
// 10.19.9 Phase 3: post-confirm status screen. Prints one ✔ / ✖ / ⤳
|
|
2134
|
+
// row per picked id + an `N passed, M failed, K skipped` footer when
|
|
2135
|
+
// stdout is a TTY, or a single collapsed line when piped. Empty-pick
|
|
2136
|
+
// (the `picked.length === 0` branch above) intentionally skips the
|
|
2137
|
+
// screen — the chalk.yellow "No models selected" line is the only
|
|
2138
|
+
// operator feedback there.
|
|
2139
|
+
const statusResult = renderPickStatusScreen({
|
|
2140
|
+
picked,
|
|
2141
|
+
profiles: profilesMap,
|
|
2142
|
+
preExisting,
|
|
2143
|
+
out: process.stdout,
|
|
2144
|
+
isTTY: !!process.stdout.isTTY,
|
|
2145
|
+
});
|
|
2146
|
+
if (statusResult.exitCode !== 0) process.exitCode = statusResult.exitCode;
|
|
1933
2147
|
}
|
|
1934
2148
|
return true;
|
|
1935
2149
|
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cli/commands/workflow-gc.mjs — Phase B (v10.21.0) B.3
|
|
4
|
+
*
|
|
5
|
+
* Garbage-collects workflow run artifact directories under
|
|
6
|
+
* `<cwd>/.bizar/runs/`. Each run is a directory written by
|
|
7
|
+
* `config/workflows/lib/dispatch.js#writeArtifact` (B.1).
|
|
8
|
+
*
|
|
9
|
+
* Policy:
|
|
10
|
+
* - TTL: 14 days since the directory's mtime (configurable via
|
|
11
|
+
* `--max-age-days`).
|
|
12
|
+
* - In-progress gate: if `feature_list.json` has any feature with
|
|
13
|
+
* `state: in_progress`, NO directories are deleted (conservative;
|
|
14
|
+
* we have no feature -> runId mapping). The dry-run reports this
|
|
15
|
+
* explicitly so the operator sees the skip reason.
|
|
16
|
+
* - Permission failures: skip + warn, never abort the run.
|
|
17
|
+
* - Idempotent: re-running after a successful GC is a no-op.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* node cli/commands/workflow-gc.mjs # real deletion
|
|
21
|
+
* node cli/commands/workflow-gc.mjs --dry-run # list only
|
|
22
|
+
* node cli/commands/workflow-gc.mjs --max-age-days=7
|
|
23
|
+
* node cli/commands/workflow-gc.mjs --root <path> # override run root
|
|
24
|
+
*
|
|
25
|
+
* Exit codes:
|
|
26
|
+
* 0 success (every candidate either deleted or explicitly skipped)
|
|
27
|
+
* 1 at least one delete failed (dry-run is exit 0; the operator
|
|
28
|
+
* reviews the per-row status and re-runs)
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
31
|
+
import { resolve } from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
import { dirname } from 'node:path';
|
|
34
|
+
import { pathToFileURL } from 'node:url';
|
|
35
|
+
|
|
36
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const repoRoot = resolve(here, '..', '..');
|
|
38
|
+
const dispatchPath = resolve(repoRoot, 'config', 'workflows', 'lib', 'dispatch.js');
|
|
39
|
+
const dispatch = await import(pathToFileURL(dispatchPath).href);
|
|
40
|
+
|
|
41
|
+
const { listRuns } = dispatch;
|
|
42
|
+
|
|
43
|
+
const DEFAULT_MAX_AGE_DAYS = 14;
|
|
44
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
45
|
+
|
|
46
|
+
function parseArgs(argv) {
|
|
47
|
+
const args = { dryRun: false, maxAgeDays: DEFAULT_MAX_AGE_DAYS, root: undefined };
|
|
48
|
+
for (const a of argv.slice(2)) {
|
|
49
|
+
if (a === '--dry-run') args.dryRun = true;
|
|
50
|
+
else if (a === '--help' || a === '-h') args.help = true;
|
|
51
|
+
else if (a.startsWith('--max-age-days=')) {
|
|
52
|
+
const n = Number(a.slice('--max-age-days='.length));
|
|
53
|
+
if (Number.isFinite(n) && n >= 0) args.maxAgeDays = n;
|
|
54
|
+
} else if (a.startsWith('--root=')) {
|
|
55
|
+
args.root = a.slice('--root='.length);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return args;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function printHelp() {
|
|
62
|
+
process.stdout.write(`workflow-gc — delete .bizar/runs/<run-id>/ older than N days.
|
|
63
|
+
|
|
64
|
+
Usage:
|
|
65
|
+
node cli/commands/workflow-gc.mjs real deletion
|
|
66
|
+
node cli/commands/workflow-gc.mjs --dry-run list candidates, no deletions
|
|
67
|
+
node cli/commands/workflow-gc.mjs --max-age-days=N override the 14-day default TTL
|
|
68
|
+
node cli/commands/workflow-gc.mjs --root <path> override the artifact root
|
|
69
|
+
|
|
70
|
+
Exit codes:
|
|
71
|
+
0 success (every candidate either deleted or explicitly skipped)
|
|
72
|
+
1 at least one delete failed (operator reviews + re-runs)
|
|
73
|
+
`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Inspect feature_list.json#in_progress. Returns
|
|
78
|
+
* `{ inProgress: boolean, ids: string[] }`. When the file is missing
|
|
79
|
+
* or unreadable, inProgress is false (no gate) — a missing
|
|
80
|
+
* feature_list.json is not the GC tool's problem to fix.
|
|
81
|
+
*/
|
|
82
|
+
function inProgressFeatureIds(cwd) {
|
|
83
|
+
const p = resolve(cwd, 'feature_list.json');
|
|
84
|
+
if (!existsSync(p)) return { inProgress: false, ids: [] };
|
|
85
|
+
try {
|
|
86
|
+
const json = JSON.parse(readFileSync(p, 'utf8'));
|
|
87
|
+
const features = Array.isArray(json?.features) ? json.features : [];
|
|
88
|
+
const ids = features.filter((f) => f && f.state === 'in_progress').map((f) => f.id || '<no-id>');
|
|
89
|
+
return { inProgress: ids.length > 0, ids };
|
|
90
|
+
} catch {
|
|
91
|
+
return { inProgress: false, ids: [] };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build a candidate list. Each row carries enough metadata for the
|
|
97
|
+
* dry-run output + the deletion loop:
|
|
98
|
+
* { runId, path, ageDays, size, action: 'delete' | 'skip:<reason>' }
|
|
99
|
+
*
|
|
100
|
+
* Skip reasons:
|
|
101
|
+
* 'too-recent' — mtime within the TTL window
|
|
102
|
+
* 'in-progress' — feature_list.json has any in_progress feature
|
|
103
|
+
* 'permission-denied' — stat/rm threw EACCES or EPERM
|
|
104
|
+
* 'missing' — directory vanished between listRuns() and stat()
|
|
105
|
+
*/
|
|
106
|
+
function planCandidates({ runs, nowMs, maxAgeDays, inProgress }) {
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const run of runs) {
|
|
109
|
+
const ageDays = (nowMs - run.mtimeMs) / MS_PER_DAY;
|
|
110
|
+
if (inProgress) {
|
|
111
|
+
out.push({ runId: run.runId, path: run.path, ageDays, size: run.size, action: 'skip:in-progress' });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (ageDays < maxAgeDays) {
|
|
115
|
+
out.push({ runId: run.runId, path: run.path, ageDays, size: run.size, action: 'skip:too-recent' });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
out.push({ runId: run.runId, path: run.path, ageDays, size: run.size, action: 'delete' });
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Execute the plan. Returns `{ rows, errors }` where `errors` is the
|
|
125
|
+
* list of (runId, message) pairs for failed deletions. Best-effort:
|
|
126
|
+
* every error is recorded but the loop continues so a single
|
|
127
|
+
* permission-denied directory does not block the rest of the sweep.
|
|
128
|
+
*/
|
|
129
|
+
function executePlan(rows, { dryRun, nowMs }) {
|
|
130
|
+
const errors = [];
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const row of rows) {
|
|
133
|
+
if (row.action !== 'delete' || dryRun) {
|
|
134
|
+
out.push(row);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
rmSync(row.path, { recursive: true, force: true });
|
|
139
|
+
out.push({ ...row, deletedAt: new Date(nowMs).toISOString() });
|
|
140
|
+
} catch (err) {
|
|
141
|
+
const message = err && err.message ? err.message : String(err);
|
|
142
|
+
const reason = /EACCES|EPERM/.test(message) ? 'permission-denied' : 'unknown';
|
|
143
|
+
out.push({ ...row, action: `error:${reason}`, error: message });
|
|
144
|
+
errors.push({ runId: row.runId, message });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { rows: out, errors };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function formatRows(rows) {
|
|
151
|
+
const lines = [];
|
|
152
|
+
for (const row of rows) {
|
|
153
|
+
const tag = row.action.startsWith('skip:')
|
|
154
|
+
? `SKIP (${row.action.slice('skip:'.length)})`
|
|
155
|
+
: row.action.startsWith('error:')
|
|
156
|
+
? `ERROR (${row.action.slice('error:'.length)})`
|
|
157
|
+
: row.deletedAt
|
|
158
|
+
? 'DELETED'
|
|
159
|
+
: 'DELETE';
|
|
160
|
+
lines.push(` ${tag.padEnd(22)} ${row.runId.padEnd(38)} age=${row.ageDays.toFixed(2)}d size=${row.size}B`);
|
|
161
|
+
}
|
|
162
|
+
return lines.join('\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function emitGcLog(rows, errors, { dryRun, maxAgeDays, inProgressIds, outputPath }) {
|
|
166
|
+
const summary = {
|
|
167
|
+
dryRun,
|
|
168
|
+
maxAgeDays,
|
|
169
|
+
inProgressIds,
|
|
170
|
+
deleted: rows.filter((r) => r.deletedAt).length,
|
|
171
|
+
skipped: rows.filter((r) => r.action.startsWith('skip:')).length,
|
|
172
|
+
errors: rows.filter((r) => r.action.startsWith('error:')).length,
|
|
173
|
+
rows,
|
|
174
|
+
};
|
|
175
|
+
if (outputPath) {
|
|
176
|
+
try {
|
|
177
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
178
|
+
writeFileSync(outputPath, JSON.stringify(summary, null, 2));
|
|
179
|
+
} catch {
|
|
180
|
+
// best-effort: the console output is the source of truth
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return summary;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function main() {
|
|
187
|
+
const args = parseArgs(process.argv);
|
|
188
|
+
if (args.help) {
|
|
189
|
+
printHelp();
|
|
190
|
+
process.exit(0);
|
|
191
|
+
}
|
|
192
|
+
const root = args.root ? resolve(args.root) : undefined;
|
|
193
|
+
const cwd = process.cwd();
|
|
194
|
+
const nowMs = Date.now();
|
|
195
|
+
const inProgress = inProgressFeatureIds(cwd);
|
|
196
|
+
const runs = listRuns({ runRoot: root });
|
|
197
|
+
const plan = planCandidates({ runs, nowMs, maxAgeDays: args.maxAgeDays, inProgress: inProgress.inProgress });
|
|
198
|
+
const { rows } = executePlan(plan, { dryRun: args.dryRun, nowMs });
|
|
199
|
+
const gcLogPath = resolve(cwd, '.bizar', 'runs', 'gc.json');
|
|
200
|
+
const summary = emitGcLog(rows, [], { dryRun: args.dryRun, maxAgeDays: args.maxAgeDays, inProgressIds: inProgress.ids, outputPath: gcLogPath });
|
|
201
|
+
|
|
202
|
+
process.stdout.write(
|
|
203
|
+
[
|
|
204
|
+
`▶ workflow-gc ${args.dryRun ? '(dry-run)' : ''}`,
|
|
205
|
+
` root: ${root || resolve(cwd, '.bizar', 'runs')}`,
|
|
206
|
+
` max-age-days: ${args.maxAgeDays}`,
|
|
207
|
+
` in-progress: ${inProgress.inProgress ? `yes [${inProgress.ids.join(', ')}]` : 'no'}`,
|
|
208
|
+
` candidates: ${rows.length}`,
|
|
209
|
+
` deleted: ${summary.deleted}`,
|
|
210
|
+
` skipped: ${summary.skipped}`,
|
|
211
|
+
` errors: ${summary.errors}`,
|
|
212
|
+
'',
|
|
213
|
+
formatRows(rows),
|
|
214
|
+
'',
|
|
215
|
+
].join('\n'),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
if (summary.errors > 0) {
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
process.exit(0);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
main().catch((err) => {
|
|
225
|
+
process.stderr.write(`workflow-gc failed: ${err && err.message ? err.message : err}\n`);
|
|
226
|
+
process.exit(1);
|
|
227
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { dispatchAgent, writeArtifact, barrierRef } from './lib/dispatch.js'
|
|
2
3
|
|
|
3
4
|
export const meta = {
|
|
4
5
|
name: 'bizar-debug',
|
|
@@ -21,6 +22,10 @@ const BUG_ID = typeof args === 'string'
|
|
|
21
22
|
? args.topic
|
|
22
23
|
: JSON.stringify(args || {})
|
|
23
24
|
|
|
25
|
+
// Phase B (v10.21.0) artifact-on-disk barriers: one runId per workflow
|
|
26
|
+
// invocation. Used by every writeArtifact() + barrierRef() in this script.
|
|
27
|
+
const RUN_ID = randomUUID()
|
|
28
|
+
|
|
24
29
|
const HYPOTHESIS = {
|
|
25
30
|
type: 'object',
|
|
26
31
|
required: ['cause', 'experiment', 'predictedOutcome'],
|
|
@@ -43,11 +48,15 @@ const iterations = []
|
|
|
43
48
|
phase('Hypothesis')
|
|
44
49
|
const initial = await dispatchAgent(agent, 'rca-hypothesis', `Root-cause bug ${BUG_ID} with the cheapest discriminating experiment. Return {cause, experiment, predictedOutcome}. Do not propose a fix yet.`, { role: 'research-analyst', risk: 'medium', capabilities: ['structured-output', 'reasoning'], label: 'hypothesis:initial', phase: 'Hypothesis', schema: HYPOTHESIS })
|
|
45
50
|
iterations.push(initial)
|
|
51
|
+
// Phase B: persist initial hypothesis artifact.
|
|
52
|
+
writeArtifact({ runId: RUN_ID, phase: 'Hypothesis', label: 'hypothesis:initial', payload: initial, summary: initial?.cause ? initial.cause.slice(0, 200) : 'initial hypothesis', role: 'research-analyst' })
|
|
46
53
|
|
|
47
54
|
let accepted = null
|
|
48
55
|
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
49
56
|
phase('AdversarialVerify')
|
|
50
|
-
const
|
|
57
|
+
const prior = iterations[iterations.length - 1];
|
|
58
|
+
const priorLabel = i === 0 ? 'hypothesis:initial' : `refine:${i}`;
|
|
59
|
+
const verdict = await dispatchAgent(agent, 'rca-verifier', `Refute the RCA hypothesis for bug ${BUG_ID}. Inspect the predicted experiment and reject it if it is speculative, pre-existing, unreachable, or already covered by an existing test.\n${barrierRef({ runId: RUN_ID, phase: 'Hypothesis', label: priorLabel, summary: prior?.cause ? prior.cause.slice(0, 200) : `hypothesis iter ${i + 1}` }).promptBlock}`, { role: 'adversarial', risk: 'high', capabilities: ['structured-output', 'reasoning'], label: `verify:${i + 1}`, phase: 'AdversarialVerify', schema: VERDICT })
|
|
51
60
|
if (verdict && verdict.confirmed) {
|
|
52
61
|
accepted = { iteration: i + 1, hypothesis: iterations[iterations.length - 1], verdict }
|
|
53
62
|
break
|
|
@@ -57,8 +66,10 @@ for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
|
57
66
|
break
|
|
58
67
|
}
|
|
59
68
|
phase('Loop')
|
|
60
|
-
const refined = await dispatchAgent(agent, 'rca-refiner', `The previous RCA hypothesis for bug ${BUG_ID} was not confirmed. Produce a refined hypothesis with a new cheapest discriminating experiment.\
|
|
69
|
+
const refined = await dispatchAgent(agent, 'rca-refiner', `The previous RCA hypothesis for bug ${BUG_ID} was not confirmed. Produce a refined hypothesis with a new cheapest discriminating experiment.\n${barrierRef({ runId: RUN_ID, phase: 'Hypothesis', label: priorLabel, summary: prior?.cause ? prior.cause.slice(0, 200) : `prior iter ${i + 1}` }).promptBlock}\n${barrierRef({ runId: RUN_ID, phase: 'AdversarialVerify', label: `verify:${i + 1}`, summary: verdict?.reason ? verdict.reason.slice(0, 200) : 'no confirmation' }).promptBlock}`, { role: 'research-analyst', risk: 'medium', capabilities: ['structured-output', 'reasoning'], label: `refine:${i + 1}`, phase: 'Loop', schema: HYPOTHESIS })
|
|
61
70
|
iterations.push(refined)
|
|
71
|
+
// Phase B: persist refined hypothesis artifact.
|
|
72
|
+
writeArtifact({ runId: RUN_ID, phase: 'Hypothesis', label: `refine:${i + 1}`, payload: refined, summary: refined?.cause ? refined.cause.slice(0, 200) : `refined iter ${i + 1}`, role: 'research-analyst' });
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
if (!accepted) {
|
|
@@ -71,10 +82,11 @@ if (!accepted) {
|
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
phase('Fix')
|
|
74
|
-
const fix = await dispatchAgent(agent, 'fix-author', `Produce the smallest fix + regression test for bug ${BUG_ID} based on the accepted hypothesis. Do not commit, push, publish, or deploy.\
|
|
85
|
+
const fix = await dispatchAgent(agent, 'fix-author', `Produce the smallest fix + regression test for bug ${BUG_ID} based on the accepted hypothesis. Do not commit, push, publish, or deploy.\n${barrierRef({ runId: RUN_ID, phase: 'Hypothesis', label: 'hypothesis:initial', summary: accepted.hypothesis?.cause ? accepted.hypothesis.cause.slice(0, 200) : 'accepted hypothesis' }).promptBlock}`, { role: 'implementer', risk: 'medium', capabilities: ['structured-output', 'reasoning'], label: 'fix', phase: 'Fix' })
|
|
86
|
+
writeArtifact({ runId: RUN_ID, phase: 'Fix', label: 'fix', payload: fix, summary: typeof fix === 'string' ? fix.slice(0, 200) : 'fix proposed', role: 'implementer' })
|
|
75
87
|
|
|
76
88
|
phase('Verify')
|
|
77
|
-
const verify = await dispatchAgent(agent, 'fix-verifier', `Re-check the proposed fix for bug ${BUG_ID} against the regression test and adjacent paths. Reject the fix if it is unbounded, out of scope, or already covered.\
|
|
89
|
+
const verify = await dispatchAgent(agent, 'fix-verifier', `Re-check the proposed fix for bug ${BUG_ID} against the regression test and adjacent paths. Reject the fix if it is unbounded, out of scope, or already covered.\n${barrierRef({ runId: RUN_ID, phase: 'Fix', label: 'fix', summary: typeof fix === 'string' ? fix.slice(0, 200) : 'fix artifact' }).promptBlock}`, { role: 'adversarial', risk: 'high', capabilities: ['structured-output', 'reasoning'], label: 'fix-verify', phase: 'Verify' })
|
|
78
90
|
|
|
79
91
|
return {
|
|
80
92
|
status: 'dry',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { dispatchAgent, writeArtifact, barrierRef } from './lib/dispatch.js'
|
|
2
3
|
|
|
3
4
|
export const meta = {
|
|
4
5
|
name: 'bizar-implement',
|
|
@@ -20,6 +21,10 @@ const TOPIC = typeof args === 'string'
|
|
|
20
21
|
: JSON.stringify(args || {})
|
|
21
22
|
const SCOPE = (args && Array.isArray(args.scope)) ? args.scope : []
|
|
22
23
|
|
|
24
|
+
// Phase B (v10.21.0) artifact-on-disk barriers: one runId per workflow
|
|
25
|
+
// invocation. Used by every writeArtifact() + barrierRef() in this script.
|
|
26
|
+
const RUN_ID = randomUUID()
|
|
27
|
+
|
|
23
28
|
const LANES = {
|
|
24
29
|
type: 'object',
|
|
25
30
|
required: ['lanes'],
|
|
@@ -40,10 +45,13 @@ const LANES = {
|
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
phase('Scope')
|
|
43
|
-
const scoped = await dispatchAgent(agent, 'scope-extractor', `Extract 2-6 disjoint edit lanes for: ${TOPIC}\nProvided scope: ${
|
|
48
|
+
const scoped = await dispatchAgent(agent, 'scope-extractor', `Extract 2-6 disjoint edit lanes for: ${TOPIC}\nProvided scope: ${SCOPE.length ? SCOPE.join(', ') : '(none supplied)'}\nEach lane owns a non-overlapping file scope. Shared root/config/lock files must have one owner. Return lanes with name/scope/task.`, { role: 'implementer', risk: 'medium', capabilities: ['structured-output', 'reasoning'], label: 'scope-extract', phase: 'Scope', schema: LANES })
|
|
44
49
|
if (!scoped || !Array.isArray(scoped.lanes) || scoped.lanes.length === 0) {
|
|
45
50
|
return { status: 'blocked', reason: 'Scope agent produced no lanes.' }
|
|
46
51
|
}
|
|
52
|
+
// Phase B: persist the scope artifact for the next barrier agent.
|
|
53
|
+
const scopeSummary = `scope lanes: ${scoped.lanes.map((l) => l.name).join(', ')}`
|
|
54
|
+
writeArtifact({ runId: RUN_ID, phase: 'Scope', label: 'barrier', payload: scoped, summary: scopeSummary, role: 'implementer' })
|
|
47
55
|
const lanes = scoped.lanes.slice(0, 6)
|
|
48
56
|
if (scoped.lanes.length > lanes.length) {
|
|
49
57
|
log(`Bounded implementation to 6 of ${scoped.lanes.length} lanes.`)
|
|
@@ -54,22 +62,30 @@ const implementations = (await parallel(
|
|
|
54
62
|
lanes.map((lane, index) => () => dispatchAgent(
|
|
55
63
|
agent,
|
|
56
64
|
`lane-implementer-${index + 1}`,
|
|
57
|
-
`Implement this owned lane for the topic "${TOPIC}".\
|
|
65
|
+
`Implement this owned lane for the topic "${TOPIC}".\n${barrierRef({ runId: RUN_ID, phase: 'Scope', label: 'barrier', summary: `lane ${lane.name}: ${lane.task.slice(0, 120)}` }).promptBlock}\nDo not edit outside the listed scope. Do not revert sibling work. Add regression tests and run the smallest relevant checks. Return changed files, commands, exact results, and blockers. Do not commit, push, publish, or deploy.`,
|
|
58
66
|
{ role: 'implementer', risk: 'medium', capabilities: ['structured-output', 'reasoning'], label: `implement:${index + 1}:${lane.name}`, phase: 'Implement', isolation: 'worktree' },
|
|
59
67
|
)),
|
|
60
68
|
)).filter(Boolean)
|
|
61
69
|
if (implementations.length === 0) {
|
|
62
70
|
return { status: 'blocked', reason: 'No implementation lane completed successfully.', scope: scoped }
|
|
63
71
|
}
|
|
72
|
+
// Phase B: persist each implementation artifact.
|
|
73
|
+
for (let i = 0; i < implementations.length; i++) {
|
|
74
|
+
const lane = lanes[i];
|
|
75
|
+
const label = `implement:${i + 1}:${lane.name}`;
|
|
76
|
+
const summary = `lane ${lane.name} files: ${(implementations[i]?.files || []).slice(0, 5).join(', ')}`;
|
|
77
|
+
writeArtifact({ runId: RUN_ID, phase: 'Implement', label, payload: implementations[i], summary, role: 'implementer' });
|
|
78
|
+
}
|
|
64
79
|
|
|
65
80
|
phase('Barrier')
|
|
66
|
-
const merge = await dispatchAgent(agent, 'barrier-merger', `Reconcile the lane outputs for topic "${TOPIC}" into one MERGE plan. Identify conflicts between worktrees, exact integration order, shared-file ownership, and any human approvals required.\
|
|
81
|
+
const merge = await dispatchAgent(agent, 'barrier-merger', `Reconcile the lane outputs for topic "${TOPIC}" into one MERGE plan. Identify conflicts between worktrees, exact integration order, shared-file ownership, and any human approvals required.\n${barrierRef({ runId: RUN_ID, phase: 'Implement', label: 'implement:summary', summary: `${implementations.length} lanes complete across ${lanes.length} planned` }).promptBlock}`, { role: 'implementer', risk: 'high', capabilities: ['structured-output', 'reasoning', 'architecture'], label: 'barrier-merge', phase: 'Barrier' })
|
|
82
|
+
writeArtifact({ runId: RUN_ID, phase: 'Barrier', label: 'barrier', payload: merge, summary: typeof merge === 'string' ? merge.slice(0, 200) : `barrier merge complete`, role: 'implementer' })
|
|
67
83
|
|
|
68
84
|
phase('Verify')
|
|
69
|
-
const verify = await dispatchAgent(agent, 'barrier-verifier', `Re-check this MERGE plan against the original scope for topic "${TOPIC}". Reject it if any lane output is missing, any conflict is unresolved, or any test gate is unbounded. Return the verified plan plus the exact gating tests.\
|
|
85
|
+
const verify = await dispatchAgent(agent, 'barrier-verifier', `Re-check this MERGE plan against the original scope for topic "${TOPIC}". Reject it if any lane output is missing, any conflict is unresolved, or any test gate is unbounded. Return the verified plan plus the exact gating tests.\n${barrierRef({ runId: RUN_ID, phase: 'Barrier', label: 'barrier', summary: `verify against scope: ${SCOPE.length} scope items` }).promptBlock}`, { role: 'adversarial', risk: 'high', capabilities: ['structured-output', 'reasoning'], label: 'barrier-verify', phase: 'Verify' })
|
|
70
86
|
|
|
71
87
|
phase('Synthesis')
|
|
72
|
-
const synthesis = await dispatchAgent(agent, 'integration-reporter', `Produce the final integration report for topic "${TOPIC}". State exact integration order, remaining gates, evidence commands to run, and any required human approvals. Do not claim success without fresh command evidence.\
|
|
88
|
+
const synthesis = await dispatchAgent(agent, 'integration-reporter', `Produce the final integration report for topic "${TOPIC}". State exact integration order, remaining gates, evidence commands to run, and any required human approvals. Do not claim success without fresh command evidence.\n${barrierRef({ runId: RUN_ID, phase: 'Barrier', label: 'barrier', summary: `merge plan: ${typeof merge === 'string' ? merge.slice(0, 120) : 'complex'}` }).promptBlock}\n${barrierRef({ runId: RUN_ID, phase: 'Verify', label: 'barrier-verify', summary: typeof verify === 'string' ? verify.slice(0, 120) : 'verified' }).promptBlock}`, { role: 'implementer', risk: 'medium', capabilities: ['structured-output', 'reasoning'], label: 'integration-report', phase: 'Synthesis' })
|
|
73
89
|
|
|
74
90
|
return {
|
|
75
91
|
status: 'ready-for-integration',
|