@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.
@@ -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 { dispatchAgent } from './lib/dispatch.js'
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 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.\nHypothesis: ${JSON.stringify(iterations[iterations.length - 1])}`, { role: 'adversarial', risk: 'high', capabilities: ['structured-output', 'reasoning'], label: `verify:${i + 1}`, phase: 'AdversarialVerify', schema: VERDICT })
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.\nPrior: ${JSON.stringify(iterations[iterations.length - 1])}\nRejection: ${JSON.stringify(verdict)}`, { role: 'research-analyst', risk: 'medium', capabilities: ['structured-output', 'reasoning'], label: `refine:${i + 1}`, phase: 'Loop', schema: HYPOTHESIS })
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.\nAccepted hypothesis: ${JSON.stringify(accepted.hypothesis)}`, { role: 'implementer', risk: 'medium', capabilities: ['structured-output', 'reasoning'], label: 'fix', phase: 'Fix' })
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.\nFix: ${String(fix)}`, { role: 'adversarial', risk: 'high', capabilities: ['structured-output', 'reasoning'], label: 'fix-verify', phase: 'Verify' })
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 { dispatchAgent } from './lib/dispatch.js'
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: ${JSON.stringify(SCOPE)}\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 })
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}".\nLane: ${JSON.stringify(lane)}\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.`,
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.\nLanes: ${JSON.stringify(lanes)}\nImplementations: ${JSON.stringify(implementations)}`, { role: 'implementer', risk: 'high', capabilities: ['structured-output', 'reasoning', 'architecture'], label: 'barrier-merge', phase: 'Barrier' })
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.\nScope: ${JSON.stringify(SCOPE)}\nMerge: ${String(merge)}`, { role: 'adversarial', risk: 'high', capabilities: ['structured-output', 'reasoning'], label: 'barrier-verify', phase: 'Verify' })
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.\nMerge: ${String(merge)}\nVerify: ${String(verify)}`, { role: 'implementer', risk: 'medium', capabilities: ['structured-output', 'reasoning'], label: 'integration-report', phase: 'Synthesis' })
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',