@sabaiway/agent-workflow-kit 1.24.0 → 1.26.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/tools/recipes.mjs CHANGED
@@ -283,21 +283,43 @@ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, s
283
283
  };
284
284
  };
285
285
 
286
+ // ── the one-line backend status (deterministic-first: the tool speaks, the agent pastes) ───────────
287
+
288
+ // composeStatusLine(detection, recommendation) → the ENTIRE one-line backend-status summary the
289
+ // bootstrap/upgrade report footers print. Machine-composed so the agent pastes it verbatim and
290
+ // composes NOTHING factual (this closes the realistic-example contamination class: a session once
291
+ // echoed SKILL.md's canonical example while the detector said otherwise). Display names come from
292
+ // DISPLAY_ALIASES — the ONE alias table the recommendation clause already uses; ordering is the
293
+ // deterministic BACKEND_PRIORITY (codex before agy), independent of detection emission order.
294
+ // Always exactly one line: no part may carry a newline (pinned by tests).
295
+ export const composeStatusLine = (detection, recommendation) => {
296
+ const backends = [...detection]
297
+ .sort((a, b) => priorityIndex(a.name) - priorityIndex(b.name))
298
+ .map((b) => `${DISPLAY_ALIASES[b.name] ?? b.name} ${b.readiness === READY ? '✓' : '✗'} ${b.readiness}`)
299
+ .join(' · ');
300
+ return `backends: ${backends} — run /agent-workflow-kit backends · recipes: ${recommendation.clause} — see /agent-workflow-kit recipes`;
301
+ };
302
+
286
303
  // ── report + CLI ─────────────────────────────────────────────────────────────────
287
304
 
288
- // The structured report behind `--json` — the recipes, the recommendation, and a plan per recipe.
289
- export const buildReport = (detection) => ({
290
- recipes: RECIPES.map(({ id, title, role, minBackends, degradesTo, summary }) => ({
291
- id,
292
- title,
293
- role,
294
- minBackends,
295
- degradesTo,
296
- summary,
297
- })),
298
- recommendation: recommendRecipe(detection),
299
- plans: RECIPES.map((r) => planRecipe(r.id, detection)),
300
- });
305
+ // The structured report behind `--json` — the recipes, the recommendation, a plan per recipe, and
306
+ // (additive) the pasteable one-line backend status composed from the same detection.
307
+ export const buildReport = (detection) => {
308
+ const recommendation = recommendRecipe(detection);
309
+ return {
310
+ recipes: RECIPES.map(({ id, title, role, minBackends, degradesTo, summary }) => ({
311
+ id,
312
+ title,
313
+ role,
314
+ minBackends,
315
+ degradesTo,
316
+ summary,
317
+ })),
318
+ recommendation,
319
+ plans: RECIPES.map((r) => planRecipe(r.id, detection)),
320
+ statusLine: composeStatusLine(detection, recommendation),
321
+ };
322
+ };
301
323
 
302
324
  // formatRecipes(detection) → deterministic human advisor text: the four recipes, the recommendation,
303
325
  // and the per-recipe plan for the current environment (degradation reasons + dispatch + notes).
@@ -320,20 +342,37 @@ export const formatRecipes = (detection) => {
320
342
  return lines.join('\n');
321
343
  };
322
344
 
345
+ // The full argv vocabulary — anything else rejects LOUDLY. The old parse silently routed unknown
346
+ // args into the multi-line human render; with `--status-line` (whose output is pasted as fact) a
347
+ // mistyped flag masquerading as a mode would be a silent failure, so the parse is strict now.
348
+ const KNOWN_ARGS = new Set(['--help', '-h', '--json', '--status-line']);
349
+
323
350
  const main = (argv) => {
324
351
  if (argv.includes('--help') || argv.includes('-h')) {
325
352
  console.log(`recipes — read-only orchestration-recipe advisor for the agent-workflow family.
326
353
 
327
354
  Usage:
328
- node recipes.mjs [--json]
355
+ node recipes.mjs [--json | --status-line]
329
356
 
330
357
  Lists the four recipes (Solo / Reviewed / Council / Delegated) and, from the read-only backend
331
- detector, plans + recommends one for the current environment. Detection only never writes, never
332
- commits, never runs a subscription CLI.`);
358
+ detector, plans + recommends one for the current environment. --status-line prints exactly ONE
359
+ line the machine-composed backend-status summary the bootstrap/upgrade reports paste verbatim.
360
+ --json emits the structured report (incl. the same line as \`statusLine\`); the two are mutually
361
+ exclusive. Detection only — never writes, never commits, never runs a subscription CLI.`);
333
362
  return;
334
363
  }
364
+ const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
365
+ if (unknown !== undefined) {
366
+ console.error(`[agent-workflow-kit] unknown argument: ${unknown}`);
367
+ process.exit(1);
368
+ }
369
+ if (argv.includes('--json') && argv.includes('--status-line')) {
370
+ console.error('[agent-workflow-kit] --json and --status-line are mutually exclusive — pick one output');
371
+ process.exit(1);
372
+ }
335
373
  const detection = detectBackends();
336
- if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection), null, 2));
374
+ if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection)));
375
+ else if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection), null, 2));
337
376
  else console.log(formatRecipes(detection));
338
377
  };
339
378
 
@@ -34,7 +34,18 @@ const renderMembers = (vm, { color, glyph }) => {
34
34
  // refresh.behind drives a HEADLINE COUNT only — the recovery command stays in the verbatim notes
35
35
  // above (the direct CLI never dedupes, never re-prints it). Omitted when nothing is behind.
36
36
  if (vm.headline.behind > 0) {
37
- lines.push(` ${vm.headline.behind} member(s) need a refresh (see the ${glyph.note} notes above).`);
37
+ // A mixed behind+unknown state must not swallow the unknown count (INV-B): append it here so an
38
+ // uncheckable member never disappears behind the refresh headline.
39
+ const unknownTail = vm.headline.unknown > 0 ? ` · ${vm.headline.unknown} could not be checked` : '';
40
+ lines.push(` ${vm.headline.behind} member(s) need a refresh (see the ${glyph.note} notes above)${unknownTail}.`);
41
+ } else if (vm.headline.unknown > 0) {
42
+ // INV-B × INV-C: an uncheckable member BLOCKS the all-current verdict — state the split, never a
43
+ // blanket freshness claim in either direction.
44
+ lines.push(` freshness: ${vm.headline.checked} member(s) checked and current · ${vm.headline.unknown} could not be checked.`);
45
+ } else if (vm.headline.checked > 0) {
46
+ // INV-C: the zero-behind verdict is composed by the TOOL, scoped to what was actually checked —
47
+ // the agent has no gap to fill with an "everything is fresh" gloss.
48
+ lines.push(` freshness: all ${vm.headline.checked} checked member(s) are current.`);
38
49
  }
39
50
  return lines;
40
51
  };
@@ -0,0 +1,23 @@
1
+ // semver-lite.mjs — the dependency-free semver LEAF shared by the npx installer (the never-downgrade
2
+ // gate, bin/install.mjs) and the family registry (the bridge freshness probe).
3
+ //
4
+ // Parses the leading `x.y.z` only (prerelease/build ignored — family versions are plain).
5
+ // compareSemver returns -1 | 0 | 1, or null when EITHER side is unparseable. The null contract is
6
+ // load-bearing at both call sites: a legacy install predates any version stamp (→ no gate), and a
7
+ // freshness probe that cannot parse a version must degrade to "unknown" — never to a false ordering
8
+ // claim in either direction (INV-B). No `let`: a small functional comparison (AGENTS.md §2.3).
9
+ //
10
+ // Pure, no imports, no side effects, Node >= 18.
11
+
12
+ export const parseSemver = (str) => {
13
+ const m = typeof str === 'string' ? str.trim().match(/^(\d+)\.(\d+)\.(\d+)/) : null;
14
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
15
+ };
16
+
17
+ export const compareSemver = (a, b) => {
18
+ const pa = parseSemver(a);
19
+ const pb = parseSemver(b);
20
+ if (!pa || !pb) return null;
21
+ const firstDiff = [0, 1, 2].map((i) => (pa[i] === pb[i] ? 0 : pa[i] < pb[i] ? -1 : 1)).find((c) => c !== 0);
22
+ return firstDiff ?? 0;
23
+ };
@@ -5,6 +5,12 @@
5
5
  // symlinks. Binary install + the interactive subscription login stay GUIDED (printed, never run) —
6
6
  // guideFor() supplies the exact commands. The read-only detector is the reader; this is the writer.
7
7
  //
8
+ // Two write modes: plain setup (opt-in, in-agent — the ONLY path that ever PLACES a bridge) and the
9
+ // refresh-only driver (`--refresh-placed` / refreshPlacedBridges, run by `init` and Mode: upgrade) —
10
+ // it refreshes what setup already placed and re-links wrappers, states an absent bridge as a skip
11
+ // (never a first placement), and never downgrades: a placed bridge newer than the kit-bundled mirror
12
+ // is a stated skip naming the kit update (guarded again at the write boundary in placeSkill).
13
+ //
8
14
  // Safety posture (AD-011): drive off the decoupled axes (a per-bindir wrapper check + an independent
9
15
  // skill-dir inspection), never the detector's collapsed readiness. REFRESH only a dir we provably own
10
16
  // (valid manifest, name+kind match) or one that is absent/empty; STOP on anything else (a marker fs
@@ -27,6 +33,7 @@ import os from 'node:os';
27
33
  import { KNOWN_BACKENDS, detectBackend, detectBackends, resolveDir, guideFor, READY } from './detect-backends.mjs';
28
34
  import { copyTreeRefresh, linkManaged } from './fs-safe.mjs';
29
35
  import { validateManifest, readAuthoritativeVersion, UNSUPPORTED, INVALID } from './manifest/validate.mjs';
36
+ import { compareSemver } from './semver-lite.mjs';
30
37
 
31
38
  const __dirname = dirname(fileURLToPath(import.meta.url));
32
39
  // bridges/ ships beside tools/ in both the repo and the installed kit, so this resolves in both.
@@ -215,11 +222,43 @@ const inspectDst = (dst, source, fs) => {
215
222
 
216
223
  // ── mutating primitives ───────────────────────────────────────────────────────
217
224
 
225
+ // The one kit-update recovery the never-downgrade skip names (the bundle only gets newer via the kit).
226
+ const KIT_UPDATE_RECOVERY = 'npx @sabaiway/agent-workflow-kit@latest init';
227
+
228
+ // Crash-safe placed-version read for the downgrade guard: an unreadable placed version yields null →
229
+ // compareSemver returns null → no ordering claim → the refresh stays allowed (legacy repair). The
230
+ // guard forbids only a PROVEN downgrade; it never turns an unreadable stamp into a false refusal.
231
+ const readPlacedVersionSafe = (skillDir, deps = {}) => {
232
+ const readVersion = deps.readVersion ?? readAuthoritativeVersion;
233
+ try {
234
+ return readVersion(skillDir).version ?? null;
235
+ } catch {
236
+ return null;
237
+ }
238
+ };
239
+
240
+ // Never-downgrade guard at the WRITE boundary (belt to planFor's braces — both read the versions
241
+ // fresh): a placed bridge NEWER than the kit-bundled mirror (an older npx runner / a kit downgrade)
242
+ // is a typed STOP naming the kit update, never a copy. Load-bearing once init/upgrade run the
243
+ // refresh automatically.
244
+ const downgradeReason = (placed, bundled) =>
245
+ `placed bridge is v${placed} but this kit bundles the older v${bundled} — refusing to downgrade; ` +
246
+ `update the kit first: ${KIT_UPDATE_RECOVERY}`;
247
+ const assertNoDowngrade = (plan, deps = {}) => {
248
+ if (plan.action !== 'refresh') return;
249
+ const placed = readPlacedVersionSafe(plan.skillDir, deps);
250
+ const manifest = readBundledManifest(plan.bundleDir, deps);
251
+ const bundled = typeof manifest.version === 'string' ? manifest.version : null;
252
+ // `wouldDowngrade` lets a caller classify this STOP structurally (never by matching message text).
253
+ if (compareSemver(placed, bundled) === 1) throw stop(downgradeReason(placed, bundled), { skillDir: plan.skillDir, wouldDowngrade: true });
254
+ };
255
+
218
256
  // Place/refresh the bundled bridge skill. Re-inspects before writing (never trusts a stale plan).
219
257
  export const placeSkill = (name, deps = {}) => {
220
258
  const entry = registryEntry(name);
221
259
  if (!entry) throw stop(`unknown backend: ${name}`);
222
260
  const plan = inspectSkillDir(entry, deps);
261
+ assertNoDowngrade(plan, deps);
223
262
  copyTreeRefresh(plan.bundleDir, plan.skillDir, plan.skillDir, fsDeps(deps));
224
263
  return plan;
225
264
  };
@@ -326,6 +365,17 @@ export const planFor = (backend, deps = {}) => {
326
365
  // setup never invents a second version reader. A place / absent-prior shows no arrow (never "vnull").
327
366
  version = typeof bundledManifest.version === 'string' ? bundledManifest.version : null;
328
367
  priorVersion = place.action === 'refresh' ? readVersion(skillDir).version ?? null : null;
368
+ // Never-downgrade (predicted here so a --dry-run is faithful and a real run stops before any
369
+ // write; placeSkill re-checks at the write boundary). An unparseable side → compareSemver null →
370
+ // no ordering claim → the refresh stays allowed (legacy repair). `wouldDowngrade` lets the
371
+ // refresh-only driver report this as a stated skip rather than a failure.
372
+ if (place.action === 'refresh' && compareSemver(priorVersion, version) === 1) {
373
+ return {
374
+ name: entry.name, skillDir, bindir, platform, place, links: [], guides: [], bindirHint: null,
375
+ outcome: 'stop', wouldDowngrade: true, version, priorVersion,
376
+ reason: downgradeReason(priorVersion, version),
377
+ };
378
+ }
329
379
  const derived = deriveLinks(bundledManifest, skillDir);
330
380
  // Preflight the BUNDLE sources (what we will copy → link). After place, the skill source IS the
331
381
  // bundle source, so checking it here makes a dry-run faithfully predict linkWrappers instead of
@@ -401,6 +451,77 @@ const formatBackend = (plan, applied) => {
401
451
  return lines.join('\n');
402
452
  };
403
453
 
454
+ // ── the refresh-only driver (the init/upgrade delivery hook — refresh, never place) ──
455
+
456
+ // Refresh-only apply: re-inspects at APPLY time and copies ONLY when the fresh inspection still says
457
+ // `refresh` (TOCTOU: a dir gone absent between plan and apply is a reported skip, NEVER a first
458
+ // placement — placement stays setup's opt-in step, AD-009/AD-011), and re-asserts the downgrade
459
+ // guard against a freshly-read placed version (a NEWER bridge landing between plan and apply is a
460
+ // typed STOP, never overwritten). No cross-process lock beyond that: like every mutating primitive
461
+ // here (see applyBackend), concurrent writers are resolved by CONVERGE-ON-RE-RUN — the Phase-1
462
+ // freshness probe flags any interleaving loser as behind and the next init/upgrade/setup repairs it;
463
+ // a per-bridge lock file would be new machinery for a user-driven, self-healing race.
464
+ const refreshSkillOnly = (entry, deps = {}) => {
465
+ const fresh = inspectSkillDir(entry, deps);
466
+ if (fresh.action !== 'refresh') return false;
467
+ assertNoDowngrade(fresh, deps);
468
+ copyTreeRefresh(fresh.bundleDir, fresh.skillDir, fresh.skillDir, fsDeps(deps));
469
+ return true;
470
+ };
471
+
472
+ const NOT_PLACED_LINE = 'skipped — not placed (placement is opt-in: /agent-workflow-kit setup)';
473
+ const stripPrefix = (message) => message.replace('[agent-workflow-kit] ', '');
474
+
475
+ // Refresh every ALREADY-PLACED bridge from the kit's bundled copies and re-link its wrappers (a newer
476
+ // bridge can add one). One reported outcome per backend — never a crash; a per-backend STOP/error
477
+ // becomes a `failed` result. `line` is the tool-composed sentence callers print VERBATIM (the agent
478
+ // pastes, never composes — deterministic-first). Outcomes: refreshed · already-current · not-placed
479
+ // (absent — NEVER placed here) · kept-newer (INV-D stated skip) · unsupported (win32) · failed.
480
+ export const refreshPlacedBridges = (deps = {}, names = KNOWN_BACKENDS.map((b) => b.name)) =>
481
+ names.map((name) => {
482
+ const canonical = resolveBackendName(name);
483
+ // The whole per-backend path is crash-proof, not just the apply: planFor itself can throw on a
484
+ // truly unexpected dependency error, and one broken backend must never abort the others' report.
485
+ try {
486
+ const plan = planFor(name, deps);
487
+ if (plan.outcome === 'unsupported') {
488
+ return { name: plan.name, outcome: 'unsupported', line: ` ${plan.name}: skipped — ${plan.reason}` };
489
+ }
490
+ if (plan.wouldDowngrade) {
491
+ return { name: plan.name, outcome: 'kept-newer', line: ` ${plan.name}: skipped — ${stripPrefix(plan.reason)}` };
492
+ }
493
+ // An absent/empty skill dir is `not placed` REGARDLESS of any later plan trouble (a foreign
494
+ // wrapper conflict, a bundle-source problem): the refresh-only driver skips an unplaced bridge
495
+ // before those axes matter, so it must never claim "could not refresh" what it would not touch.
496
+ if (plan.place && plan.place.action !== 'refresh') {
497
+ return { name: plan.name, outcome: 'not-placed', line: ` ${plan.name}: ${NOT_PLACED_LINE}` };
498
+ }
499
+ if (plan.outcome === 'stop' || plan.outcome === 'error') {
500
+ return { name: plan.name, outcome: 'failed', line: ` ${plan.name}: could not refresh — ${stripPrefix(plan.reason)}; recover with /agent-workflow-kit setup` };
501
+ }
502
+ if (!refreshSkillOnly(registryEntry(plan.name), deps)) {
503
+ return { name: plan.name, outcome: 'not-placed', line: ` ${plan.name}: ${NOT_PLACED_LINE}` };
504
+ }
505
+ const manifest = readBundledManifest(plan.place.bundleDir, deps);
506
+ linkWrappers(plan.skillDir, manifest, { ...deps, bindir: plan.bindir, platform: plan.platform });
507
+ const current = plan.version !== null && plan.priorVersion === plan.version;
508
+ // The equal-version line still states the copy that ran (repair-on-rerun) — the tool never
509
+ // reports a mutation-free "already current" while it re-synced files underneath.
510
+ return {
511
+ name: plan.name,
512
+ outcome: current ? 'already-current' : 'refreshed',
513
+ line: ` ${plan.name}: ${current ? `already current${versionLabel(plan)} — files re-synced from the bundled copy` : `refreshed${versionLabel(plan)}`}`,
514
+ };
515
+ } catch (err) {
516
+ // A downgrade STOP raised at the write boundary (a newer bridge landed between plan and apply)
517
+ // is the same stated skip as the planned one — classified structurally via the typed field.
518
+ if (err.wouldDowngrade) {
519
+ return { name: canonical, outcome: 'kept-newer', line: ` ${canonical}: skipped — ${stripPrefix(err.message)}` };
520
+ }
521
+ return { name: canonical, outcome: 'failed', line: ` ${canonical}: could not refresh — ${stripPrefix(err.message)}; recover with /agent-workflow-kit setup` };
522
+ }
523
+ });
524
+
404
525
  // ── close-the-loop: surface versions + a proactive recipe offer ───────────────────
405
526
 
406
527
  // The closing pointer: setup surfaces only the bridge it touched; the full family + deployment version
@@ -438,23 +559,28 @@ export const proactiveReviewOffer = (before, after) => {
438
559
 
439
560
  // ── CLI ─────────────────────────────────────────────────────────────────────────
440
561
 
441
- const USAGE = `usage: setup-backends [<backend>] [--bindir <path>] [--dry-run] [--help]
562
+ const USAGE = `usage: setup-backends [<backend>] [--bindir <path>] [--dry-run | --refresh-placed] [--help]
442
563
 
443
- <backend> codex | agy | antigravity | codex-cli-bridge | antigravity-cli-bridge (default: all)
444
- --bindir where to link the wrappers (default: ~/.local/bin)
445
- --dry-run print the plan; change nothing
446
- --help, -h this help
564
+ <backend> codex | agy | antigravity | codex-cli-bridge | antigravity-cli-bridge (default: all)
565
+ --bindir where to link the wrappers (default: ~/.local/bin)
566
+ --dry-run print the plan; change nothing
567
+ --refresh-placed refresh ONLY the already-placed bridges from the kit's bundled copies —
568
+ an absent bridge is a stated skip (never placed), a placed bridge newer
569
+ than the bundle is a stated skip (never downgraded). The refresh-only
570
+ mode init/upgrade run; does not combine with --dry-run.
571
+ --help, -h this help
447
572
 
448
573
  Places the bundled bridge skill + links its wrappers (idempotent; refuses to clobber a non-symlink).
449
574
  Binary install + the interactive subscription login stay manual — the printed guidance has the exact
450
575
  commands. The detector ("/agent-workflow-kit backends") stays read-only; this is the only writer.`;
451
576
 
452
577
  const parseArgs = (argv) => {
453
- const out = { dryRun: false, help: false, bindir: undefined, backend: undefined, bad: null };
578
+ const out = { dryRun: false, refreshPlaced: false, help: false, bindir: undefined, backend: undefined, bad: null };
454
579
  for (let i = 0; i < argv.length; i += 1) {
455
580
  const a = argv[i];
456
581
  if (a === '--help' || a === '-h') out.help = true;
457
582
  else if (a === '--dry-run') out.dryRun = true;
583
+ else if (a === '--refresh-placed') out.refreshPlaced = true;
458
584
  else if (a === '--bindir') {
459
585
  // Do NOT greedily swallow a following flag (e.g. `--bindir --dry-run` must not become
460
586
  // bindir="--dry-run" and silently mutate); a missing or flag-like value is a usage error.
@@ -482,6 +608,7 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
482
608
  log(USAGE);
483
609
  return 0;
484
610
  }
611
+ if (args.refreshPlaced && args.dryRun) args.bad = '--refresh-placed does not combine with --dry-run';
485
612
  if (args.bad) {
486
613
  errlog(args.bad);
487
614
  errlog(USAGE);
@@ -501,6 +628,16 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
501
628
  }
502
629
 
503
630
  const runDeps = { ...deps, bindir: args.bindir ?? deps.bindir };
631
+
632
+ // Refresh-only mode: act on what setup already placed; never place, never downgrade. Every line is
633
+ // tool-composed — callers (init, Mode: upgrade) print/paste them verbatim.
634
+ if (args.refreshPlaced) {
635
+ log('agent-workflow placed-bridge refresh (refresh-only — placement stays opt-in: /agent-workflow-kit setup)');
636
+ const results = refreshPlacedBridges(runDeps, targets);
637
+ for (const r of results) log(r.line);
638
+ return results.some((r) => r.outcome === 'failed') ? 1 : 0;
639
+ }
640
+
504
641
  log(args.dryRun ? 'agent-workflow backend setup — DRY RUN (no changes)' : 'agent-workflow backend setup (link-only)');
505
642
  // Snapshot review readiness BEFORE applying, so we can offer the recipe only on a true readiness flip.
506
643
  const reviewBefore = args.dryRun ? null : reviewReadyCount(runDeps);
@@ -21,6 +21,9 @@ const memberVm = (m) => {
21
21
  notes: m.notes ?? [], // the verbatim caveats — printed as ↳ sub-lines (INV-3: no dedupe)
22
22
  behind: Boolean(refresh.behind), // used ONLY for the headline count on the direct CLI (INV-3)
23
23
  recommend: refresh.recommend ?? null,
24
+ // The checked-vs-unknown freshness signal (INV-C): 'current' | 'behind' | 'unknown' | 'not-checked'.
25
+ // An envelope predating the field defaults to not-checked (behind stays behind) — never a claim.
26
+ freshness: refresh.freshness ?? (refresh.behind ? 'behind' : 'not-checked'),
24
27
  };
25
28
  };
26
29
 
@@ -82,7 +85,14 @@ export const toViewModel = (envelope = {}) => {
82
85
  return {
83
86
  deploymentHead: envelope.deploymentHead ?? null,
84
87
  members,
85
- headline: { total: members.length, behind: members.filter((m) => m.behind).length },
88
+ headline: {
89
+ total: members.length,
90
+ behind: members.filter((m) => m.behind).length,
91
+ // The zero-behind verdict scope (INV-C): checked = a freshness probe RAN and concluded;
92
+ // unknown = it ran but could not conclude (INV-B — blocks the all-current verdict).
93
+ checked: members.filter((m) => m.freshness === 'current' || m.freshness === 'behind').length,
94
+ unknown: members.filter((m) => m.freshness === 'unknown').length,
95
+ },
86
96
  bridges: envelope.bridges ? envelope.bridges.map(bridgeVm) : null,
87
97
  project: projectVm(envelope.project),
88
98
  };