@polderlabs/bizar 10.20.0 → 10.20.1

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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "10.20.0",
3
+ "version": "10.20.1",
4
4
  "description": "Autonomous, human-in-the-loop multi-agent harness for Claude Code with guarded workflows, typed SDK primitives, and MCP tools.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar-sdk",
3
- "version": "10.20.0",
3
+ "version": "10.20.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",