@event4u/agent-config 14.1.0 → 14.2.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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Bootstrap shim for the event4u agent-config suite. This plugin ships ONLY the dispatcher hooks plus one install-pointer skill — ALL content (skills, rules, commands) is installed via `npx -y @event4u/agent-config init`, which writes the file projection AND registers the same hooks in ~/.claude/settings.json. DEPRECATED as a content channel; kept so the marketplace listing stays discoverable and existing installs keep working hooks.",
9
- "version": "14.1.0",
9
+ "version": "14.2.0",
10
10
  "keywords": [
11
11
  "agent-config",
12
12
  "skills",
package/CHANGELOG.md CHANGED
@@ -366,6 +366,48 @@ Entry-shape contract: [`docs/contracts/CHANGELOG-conventions.md`](docs/contracts
366
366
  > that forces a new era split (`# Era: 14.1.x`, etc.) — see
367
367
  > [`docs/contracts/CHANGELOG-conventions.md § Era splits`](docs/contracts/CHANGELOG-conventions.md).
368
368
 
369
+ ## [14.2.0](https://github.com/event4u-app/agent-config/compare/14.1.0...14.2.0) (2026-08-18)
370
+
371
+ ### Release highlights
372
+
373
+ <!-- Curated head: fill before merge, keep it under 10 lines, and leave `_none_` where it is genuinely the answer. The generated log below is unchanged. -->
374
+ - **Behaviour changes:** _none_
375
+ - **Default changes + migration:** _none_
376
+ - **Security and correctness:** _auto-derived, rewrite before merge:_ security-scoped commits or fixes to executable surface in 61fdf7d, 604632b, c41b89a, 42e8955, cc19737, bbb60a8.
377
+ - **Honest nulls:** _none_
378
+ - **Known limitations:** _none_
379
+
380
+ ### Features
381
+
382
+ * **hooks:** stop skill-route pointing at skills the host delivered bare ([508bba9](https://github.com/event4u-app/agent-config/commit/508bba908e074c1c69dc1b36ef2dd679b9e9f1ba))
383
+ * **catalogue:** add the delivery reducer the runtime router was missing ([1b65432](https://github.com/event4u-app/agent-config/commit/1b65432d719094b148bc4e30cf328d3e80b3c597))
384
+
385
+ ### Bug Fixes
386
+
387
+ * **r2:** report the extraction, not the roadmap, and stop truncating criteria ([61fdf7d](https://github.com/event4u-app/agent-config/commit/61fdf7d26f1d9eb634c02451621c573784216bbe))
388
+ * **r2:** read inline acceptance criteria, stop faking an empty extraction ([604632b](https://github.com/event4u-app/agent-config/commit/604632b3c991933fb354e3afb50423697489d692))
389
+ * **metrics:** name the suppression denominator and register the invisible case ([03fe473](https://github.com/event4u-app/agent-config/commit/03fe4732dabab6b267a8dc1706b4da2a72a74a6d))
390
+ * **hooks:** count suppression over the pointer window and pin the envelope wiring ([c41b89a](https://github.com/event4u-app/agent-config/commit/c41b89a1cb21eac3f5e651c4271bd91811a3d66c))
391
+ * **catalogue:** let the LATEST record decide the bare set, not the mode filter ([42e8955](https://github.com/event4u-app/agent-config/commit/42e8955148a78314adc3f13cf695ed82e2fe0086))
392
+ * **hooks:** let a delete-only push skip the content gates ([cc19737](https://github.com/event4u-app/agent-config/commit/cc197371a66a7b1ee7ddb05ea1a505193434d2b8))
393
+ * **ci:** serialize the two publish triggers on the version, not the ref ([bbb60a8](https://github.com/event4u-app/agent-config/commit/bbb60a895b650b1d1e7ce91b49f5ddd025cf00a9))
394
+
395
+ ### Documentation
396
+
397
+ * **review:** close all six R2 findings and re-bind the artefact ([fe639e5](https://github.com/event4u-app/agent-config/commit/fe639e548109892724b15f4f299132d77658d745))
398
+ * **review:** record the R2 findings for the inline-AC fix, all rows open ([5dc1298](https://github.com/event4u-app/agent-config/commit/5dc1298bbc82d3fc7bcd9fe776c56b0be3ce4cd6))
399
+ * **review:** re-bind the R2 artefact after the fix pass ([a4b320c](https://github.com/event4u-app/agent-config/commit/a4b320cf371b0828d49799948b0bed94eee6e970))
400
+ * **roadmap:** un-checkbox AC-3, make its verify runnable, record what R2 found ([0720e1d](https://github.com/event4u-app/agent-config/commit/0720e1d434bf658dd668bef64ca742897f72594d))
401
+ * **review:** bind the R2 completion review for catalogue-host-fit Phase 3 ([1e7b2fd](https://github.com/event4u-app/agent-config/commit/1e7b2fde7ff752f45f907187b61f8f83902a0e75))
402
+ * **roadmap:** close catalogue-host-fit Phase 3 and record what its fixture found ([852a8c4](https://github.com/event4u-app/agent-config/commit/852a8c4dbe3a8c9d0b52e7a229460a4d2752d0d3))
403
+ * **release:** stop promising an approval checkpoint that does not fire ([7327d2e](https://github.com/event4u-app/agent-config/commit/7327d2ea4aec42e290f951572f972b0f441e563e))
404
+
405
+ ### Chores
406
+
407
+ * **metrics:** register the bare-suppression rate beside the pointer rate ([707f332](https://github.com/event4u-app/agent-config/commit/707f332a0af8f5d81ff7bf6b73aa46006776422c))
408
+
409
+ Tests: 14629 (+37 since 14.1.0)
410
+
369
411
  ## [14.1.0](https://github.com/event4u-app/agent-config/compare/14.0.0...14.1.0) (2026-08-18)
370
412
 
371
413
  ### Release highlights
@@ -39,7 +39,7 @@ schema_version: 1
39
39
  # CI guard: a release bump of `package.json` must update this value
40
40
  # in lockstep — see scripts/check_template_pin_drift.ts (road-to-
41
41
  # portable-runtime-and-update-check P3.3).
42
- agent_config_version: "14.1.0"
42
+ agent_config_version: "14.2.0"
43
43
 
44
44
  # --- Project identity ---
45
45
  project:
@@ -1,6 +1,6 @@
1
1
  # Discovery — Deprecation Report
2
2
 
3
- - Generated: `2026-08-18T12:03:29Z`
3
+ - Generated: `2026-08-18T16:49:04Z`
4
4
  - Deprecated artefacts: **0**
5
5
 
6
6
  _None. Tree is clean._
@@ -14578,7 +14578,7 @@
14578
14578
  "reason": "scaffold template for ticket-bundle tickets (ADR-101); carries no runtime frontmatter — emit-tickets fills it in per ticket"
14579
14579
  }
14580
14580
  ],
14581
- "generated_at": "2026-08-18T12:03:29Z",
14581
+ "generated_at": "2026-08-18T16:49:04Z",
14582
14582
  "packs": [
14583
14583
  {
14584
14584
  "artefact_count": 118,
@@ -1 +1 @@
1
- c20518e62eeec14688645cf600074ae569563d32a4188ce8ce00d9d022a4631d discovery-manifest.json
1
+ 68f3e14e4cfaf98bb9cbfee5bf5da3cf62a28c74bfb9d0450e6f01cd3c0c4bc0 discovery-manifest.json
@@ -1,6 +1,6 @@
1
1
  # Discovery Manifest — Summary
2
2
 
3
- - Generated: `2026-08-18T12:03:29Z`
3
+ - Generated: `2026-08-18T16:49:04Z`
4
4
  - Scanner: `de0a22b4ac4c`
5
5
  - Artefacts: **609**
6
6
  - Unassigned: **0**
@@ -1,6 +1,6 @@
1
1
  # Discovery — Dormancy Report
2
2
 
3
- - Generated: `2026-08-18T12:03:29Z`
3
+ - Generated: `2026-08-18T16:49:04Z`
4
4
  - Window: **6 months** without a commit
5
5
 
6
6
  > Dormancy triggers **review, never auto-deprecation**
@@ -1,6 +1,6 @@
1
1
  # Discovery — Orphan Report
2
2
 
3
- - Generated: `2026-08-18T12:03:29Z`
3
+ - Generated: `2026-08-18T16:49:04Z`
4
4
  - Orphan artefacts: **0**
5
5
 
6
6
  > An orphan is an artefact whose declared pack has no other members.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "checksum": "sha256:3d01d440e67f335028a06d9a4609a7e6f885f829cec1eff42914a3e1690cfdeb",
3
- "generated_at": "2026-08-18T12:03:29Z",
3
+ "generated_at": "2026-08-18T16:49:04Z",
4
4
  "packs": [
5
5
  {
6
6
  "always_on": false,
@@ -1,6 +1,6 @@
1
1
  # Discovery — Trust Report
2
2
 
3
- - Generated: `2026-08-18T12:03:29Z`
3
+ - Generated: `2026-08-18T16:49:04Z`
4
4
  - Workspaces tracked: **10**
5
5
  - Human-review-required artefacts: **3**
6
6
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "checksum": "sha256:3d01d440e67f335028a06d9a4609a7e6f885f829cec1eff42914a3e1690cfdeb",
3
- "generated_at": "2026-08-18T12:03:29Z",
3
+ "generated_at": "2026-08-18T16:49:04Z",
4
4
  "scanner_version": "de0a22b4ac4c",
5
5
  "workspaces": [
6
6
  {
@@ -27491,6 +27491,51 @@ var OBSERVATION_LOG = path66.join(
27491
27491
  "metrics",
27492
27492
  "skill-catalogue.jsonl"
27493
27493
  );
27494
+ function truncationModeOf(record) {
27495
+ return record.truncation_mode ?? "per-entry";
27496
+ }
27497
+ function _supersedes(candidate, incumbent) {
27498
+ if (candidate.observed_at !== incumbent.observed_at) {
27499
+ return candidate.observed_at > incumbent.observed_at;
27500
+ }
27501
+ return (candidate.dropped_count ?? 0) > (incumbent.dropped_count ?? 0);
27502
+ }
27503
+ function headlineRecordPerHost(records) {
27504
+ const out = /* @__PURE__ */ new Map();
27505
+ for (const record of records) {
27506
+ const previous = out.get(record.host);
27507
+ if (previous === void 0 || _supersedes(record, previous)) {
27508
+ out.set(record.host, record);
27509
+ }
27510
+ }
27511
+ return out;
27512
+ }
27513
+ function readObservationLog(logPath) {
27514
+ if (!fs65.existsSync(logPath)) return [];
27515
+ const out = [];
27516
+ for (const line of fs65.readFileSync(logPath, "utf-8").split("\n")) {
27517
+ const trimmed = line.trim();
27518
+ if (trimmed.length === 0) continue;
27519
+ try {
27520
+ out.push(JSON.parse(trimmed));
27521
+ } catch {
27522
+ continue;
27523
+ }
27524
+ }
27525
+ return out;
27526
+ }
27527
+
27528
+ // src/scripts/_lib/skill_catalogue_series.ts
27529
+ function knownBareNames(records, host) {
27530
+ const dated = records.filter(
27531
+ (record) => record.host === host && typeof record.observed_at === "string"
27532
+ );
27533
+ const latest = headlineRecordPerHost(dated).get(host);
27534
+ if (latest === void 0) return null;
27535
+ if (truncationModeOf(latest) !== "per-entry") return null;
27536
+ if (!Array.isArray(latest.bare_names)) return null;
27537
+ return new Set(latest.bare_names.filter((name) => typeof name === "string"));
27538
+ }
27494
27539
 
27495
27540
  // src/scripts/skill_tools/score_skill_relevance.ts
27496
27541
  import * as fs66 from "node:fs";
@@ -27978,22 +28023,45 @@ var TOP_K = 3;
27978
28023
  function _isObject15(v) {
27979
28024
  return typeof v === "object" && v !== null && !Array.isArray(v);
27980
28025
  }
27981
- function routePointers(prompt, skillsDir) {
27982
- if (skillsDir === null) return [];
28026
+ function knownBareForHost(workspaceRoot2, host) {
28027
+ if (host === null || host === "") return null;
27983
28028
  try {
27984
- if (_tokenize(prompt).size < MIN_TASK_TERMS) return [];
28029
+ return knownBareNames(readObservationLog(path68.join(workspaceRoot2, OBSERVATION_LOG)), host);
27985
28030
  } catch {
27986
- return [];
28031
+ return null;
28032
+ }
28033
+ }
28034
+ function filterKnownBare(rows, bare) {
28035
+ if (bare === null || bare.size === 0) return [...rows];
28036
+ return rows.filter(([name]) => !bare.has(name));
28037
+ }
28038
+ function routeDecision(prompt, skillsDir, bareProvider) {
28039
+ const silent = { rows: [], suppressed: 0 };
28040
+ if (skillsDir === null) return silent;
28041
+ try {
28042
+ if (_tokenize(prompt).size < MIN_TASK_TERMS) return silent;
28043
+ } catch {
28044
+ return silent;
27987
28045
  }
27988
28046
  let rows;
27989
28047
  try {
27990
28048
  rows = rank(prompt, skillsDir);
27991
28049
  } catch {
27992
- return [];
28050
+ return silent;
28051
+ }
28052
+ let bare = null;
28053
+ if (bareProvider !== void 0) {
28054
+ try {
28055
+ bare = bareProvider();
28056
+ } catch {
28057
+ bare = null;
28058
+ }
27993
28059
  }
27994
- const top = rows[0];
27995
- if (top === void 0 || top[1] < MIN_TOP_SCORE) return [];
27996
- return rows.slice(0, TOP_K);
28060
+ const deliverable = filterKnownBare(rows, bare);
28061
+ const suppressed = bare === null ? 0 : rows.slice(0, TOP_K).filter(([name]) => bare.has(name)).length;
28062
+ const top = deliverable[0];
28063
+ if (top === void 0 || top[1] < MIN_TOP_SCORE) return { rows: [], suppressed };
28064
+ return { rows: deliverable.slice(0, TOP_K), suppressed };
27997
28065
  }
27998
28066
  function buildRouteLine(rows) {
27999
28067
  const pointers = rows.map(([name, score]) => `${name} (${score})`).join(", ");
@@ -28026,12 +28094,21 @@ function main43() {
28026
28094
  const prompt = _extractPrompt3(payload);
28027
28095
  if (!prompt) return EXIT_ALLOW18;
28028
28096
  if (isSyntheticPrompt(prompt)) return EXIT_ALLOW18;
28029
- const rows = routePointers(prompt, resolveSkillsRoot(_workspaceRoot4(env)));
28097
+ const root = _workspaceRoot4(env);
28098
+ const platform = env["platform"];
28099
+ const host = typeof platform === "string" && platform ? platform : null;
28100
+ const { rows, suppressed } = routeDecision(
28101
+ prompt,
28102
+ resolveSkillsRoot(root),
28103
+ () => knownBareForHost(root, host)
28104
+ );
28030
28105
  if (rows.length === 0) return EXIT_ALLOW18;
28031
28106
  process.stdout.write(
28032
28107
  `${JSON.stringify({
28033
28108
  decision: "warn",
28034
- reason: `skill-route: ${rows.length} pointer(s), top ${rows[0][1]}/100`,
28109
+ reason: `skill-route: ${rows.length} pointer(s), top ${rows[0][1]}/100` + // The suppression metric's numerator. Named only when non-zero, so the
28110
+ // common line keeps its shape and its registered byte budget.
28111
+ (suppressed > 0 ? `, ${suppressed} suppressed as host-bare` : ""),
28035
28112
  additional_context: buildRouteLine(rows)
28036
28113
  })}
28037
28114
  `
@@ -9,7 +9,7 @@
9
9
  "homepage": "https://event4u-app.github.io/agent-config",
10
10
  "name": "@event4u/agent-config",
11
11
  "repository": "https://github.com/event4u-app/agent-config",
12
- "version": "14.1.0"
12
+ "version": "14.2.0"
13
13
  },
14
14
  "registries": [
15
15
  {
@@ -9,12 +9,12 @@
9
9
  "transport": {
10
10
  "type": "stdio"
11
11
  },
12
- "version": "14.1.0"
12
+ "version": "14.2.0"
13
13
  }
14
14
  ],
15
15
  "repository": {
16
16
  "source": "github",
17
17
  "url": "https://github.com/event4u-app/agent-config"
18
18
  },
19
- "version": "14.1.0"
19
+ "version": "14.2.0"
20
20
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
3
  "mcpName": "io.github.event4u-app/agent-config",
4
- "version": "14.1.0",
4
+ "version": "14.2.0",
5
5
  "description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including \"zero runtime daemon\".",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -56,6 +56,12 @@
56
56
  "instrument": "rule-trips.json warn counters for the skill-route concern (fires) + conformance audits for whether the pointed-at skill was opened (adoption); HONEST GAP: no deterministic counter exists for 'the agent acted on the pointer' - the numerator is audit-carried and registered as such rather than implied to be automatic",
57
57
  "threshold": "none committed before data - baseline first, threshold at review date (2026-11-10)"
58
58
  },
59
+ "skill_route_bare_suppression_rate": {
60
+ "definition": "road-to-catalogue-host-fit Phase 3 step 3.2: of the prompts where skill-route FIRES, the share whose warn reason carries `N suppressed as host-bare` - i.e. how often a skill the ranker wanted to POINT AT is one the current host is KNOWN to have delivered without its description. `N` counts the pointer window (TOP_K of the unfiltered ranking), never the whole ranked list: `rank` returns every non-zero-scoring skill, so counting the tail would let a bare name at rank 40 - never pointable, never a loss - bump the count on nearly every fire (R2 finding 1). Registered beside skill_route_pointer_rate because it measures the same concern's behaviour under the same owner and review date. NO THRESHOLD IS COMMITTED, and the refusal is inherited from the sibling rather than restated: the FILTER is derived from a measurement (all 16 bare entries in the 2026-08-12 claude observation are in the ranker's catalogue, `capture_skill_catalogue --pointable-bare`), the OUTCOME - whether suppressing them changes what the agent does - is not.",
61
+ "instrument": "the dispatcher's warn reason string for the skill-route concern (`, N suppressed as host-bare`, appended only when non-zero) + agents/evidence/metrics/skill-catalogue.jsonl for the population; TWO HONEST GAPS, both registered as gaps rather than implied to be automatic. (1) rule-trips.json counts fires per concern and does NOT retain the reason, so the numerator is log-carried, not counter-carried - the same posture the sibling metric takes for its own audit-carried numerator. (2) TOTAL suppression is INVISIBLE here by construction (R2 finding 4): when the filter empties the pointer set the concern returns before writing stdout, so no reason line exists for the one case the roadmap calls load-bearing. `RouteDecision.suppressed` carries it at the API boundary and nothing reads that in production.",
62
+ "threshold": "none committed before data - baseline first, threshold at the shared review date (2026-11-10)",
63
+ "falsifiers": "For the FILTER half only, never for the outcome. UPPER: a live rate far above the share of the pointer window the host degrades means the ranker preferentially ranks exactly what the host drops - a finding about the projection, which belongs to Phase 2. Name the populations rather than a bare ratio, because three different denominators are in play and an earlier draft of this row conflated them (R2 finding 11): the 2026-08-12 claude observation carries `entries_total` 336, of which only 35 were enumerated at all (16 bare + 19 described), against a ranker catalogue of 338 entries at probe time. The comparable share is 16 of the 35 ENUMERATED entries; 16/336 and 16/338 describe different things and neither is this metric's denominator. LOWER: a rate of 0 does NOT establish that the ranker and the host's bare set fail to intersect - gap (2) above makes complete suppression indistinguishable from no suppression in this instrument. `capture_skill_catalogue --pointable-bare` is the instrument that separates them, and it must be read before any zero here is interpreted."
64
+ },
59
65
  "full_rewrite_small_diff_rate": {
60
66
  "definition": "edit-shape fires / Write calls per session (the full-rewrite-with-small-diff rate). Kill criterion per the shipped nudge standard: a nudge whose verdicts are measurably ignored (adoption near zero over the review window, from conformance audits) gets its trigger tightened or the injected line removed.",
61
67
  "instrument": "rule-trips.json warn counters (the dispatcher already counts every warn per concern) + conformance audits for adoption"
@@ -409,3 +409,94 @@ export function formatPointableBare(
409
409
  }
410
410
  return lines.join('\n');
411
411
  }
412
+
413
+ /* ------------------------------------------------------------------ *
414
+ * The delivery filter — what the runtime router must not name.
415
+ * ------------------------------------------------------------------ */
416
+
417
+ /**
418
+ * The bare names one host is KNOWN to have delivered, or `null` when nothing
419
+ * about that host's per-entry delivery was ever enumerated.
420
+ *
421
+ * `joinPointableBare` above answers "how large is the D-4 divergence" for an
422
+ * operator report. This answers the runtime question the same log can settle:
423
+ * given this host, which skill names must a pointer line not name? It is the
424
+ * reducer `skill_route_hook` consumes, and it lives here rather than in that
425
+ * hook for one reason — every guard below is already argued in this module's
426
+ * prose, and a second reducer over the same log is how two readers start
427
+ * breaking the same tie in opposite directions (`_supersedes`, in the parent
428
+ * module, exists because that happened once and printed two drop counts for one
429
+ * host on one day).
430
+ *
431
+ * THE THREE-WAY DISTINCTION IS THE WHOLE POINT, and collapsing any two of them
432
+ * is the failure:
433
+ *
434
+ * - **`null` — nothing was enumerated.** No record for this host, or none of
435
+ * its records is a `per-entry` observation, or the latest one carries no
436
+ * `bare_names` array. The consumer must fail OPEN: filtering on this would
437
+ * be a zero inferred from silence, and a filter that quietly narrows on
438
+ * missing data is worse than the divergence it treats.
439
+ * - **An EMPTY set — enumerated, and nothing was bare.** A legitimate answer,
440
+ * and behaviourally identical to `null` at today's only consumer. It is
441
+ * still returned distinctly, because "measured clean" and "never measured"
442
+ * are different facts and this module refuses to let them look alike.
443
+ * - **A NON-EMPTY set — these names went dark.** The pointer must not name
444
+ * them.
445
+ *
446
+ * A `budget-strip-and-drop` host is therefore always `null` here, never an
447
+ * empty set: it publishes no per-entry list at all, so its empty `bare_names`
448
+ * records that nothing was counted. That is the same skip `joinPointableBare`
449
+ * makes, for the same reason, and it is why a codex install gets no filtering
450
+ * rather than a filter that believes nothing was degraded.
451
+ *
452
+ * LATEST WINS, per host, by `observed_at` — the `latestPointableBarePerHost`
453
+ * tie-break rather than a union across the series. A union would let a
454
+ * superseded observation keep suppressing a skill the host now delivers fine,
455
+ * which is a filter that only ever narrows; the whole series is available to an
456
+ * operator report, and a runtime filter wants the current reading.
457
+ *
458
+ * `bare_names` is guarded with `Array.isArray`, and `observed_at` with a
459
+ * `typeof` check, because `readObservationLog` produces records by an unchecked
460
+ * `JSON.parse … as` over an append-only log with more than one producer — the
461
+ * same guard, for the same recorded reason, that `joinPointableBare` and
462
+ * `knownHostLimits` carry. Both guards resolve to `null`, never to an older
463
+ * reading: a malformed LATEST record means the current state is unknown, and
464
+ * falling back to a superseded one would be the stale-suppression failure this
465
+ * function's own tie-break exists to prevent.
466
+ */
467
+ export function knownBareNames(
468
+ records: readonly ObservationRecord[],
469
+ host: string,
470
+ ): Set<string> | null {
471
+ // The latest record for the host decides, and the mode is read OFF that
472
+ // record rather than used to select it. R2 finding 2: filtering `per-entry`
473
+ // first meant a NEWER `budget-strip-and-drop` record could never supersede
474
+ // an older `per-entry` one, so a host that changed truncation mode kept
475
+ // being filtered against a bare set from before the change — the precise
476
+ // failure the LATEST-WINS paragraph above claims to prevent.
477
+ //
478
+ // `headlineRecordPerHost` rather than a fourth inline tie-break (R2 finding
479
+ // 7): this module already imports it, already defines
480
+ // `latestPointableBarePerHost`, and its own comment cites `_supersedes` as
481
+ // the reason duplicate reducers are dangerous. Restating the rule here would
482
+ // have meant the runtime filter silently not inheriting any precedence the
483
+ // parent adds later.
484
+ //
485
+ // The `observed_at` pre-filter is R2 finding 6, and it is a pre-filter
486
+ // rather than a change to `_supersedes` because that reducer is shared.
487
+ // `_supersedes` compares dates with `!==` then `>`, and BOTH
488
+ // `undefined > "2026-08-12"` and `"2026-08-12" > undefined` are false — so a
489
+ // record with a missing or non-string date, once it becomes the incumbent,
490
+ // can never be displaced by a well-formed later one. One malformed line in
491
+ // an append-only log with two producers would pin the filter to it forever.
492
+ // Feeding the shared reducer only well-dated records avoids that without
493
+ // changing behaviour for its other callers.
494
+ const dated = records.filter(
495
+ (record) => record.host === host && typeof record.observed_at === 'string',
496
+ );
497
+ const latest = headlineRecordPerHost(dated).get(host);
498
+ if (latest === undefined) return null;
499
+ if (truncationModeOf(latest) !== 'per-entry') return null;
500
+ if (!Array.isArray(latest.bare_names)) return null;
501
+ return new Set(latest.bare_names.filter((name): name is string => typeof name === 'string'));
502
+ }
@@ -426,35 +426,170 @@ export function expectedHashes(args: {
426
426
  return {
427
427
  scope_hash: sha256(args.scopeDiffText),
428
428
  roadmap_hash: args.roadmapText == null ? 'none' : sha256(args.roadmapText),
429
- ac_hash: args.acText == null ? 'none' : sha256(args.acText),
429
+ // ONE predicate, shared with the prompt builder. An empty extraction
430
+ // used to hash to e3b0c44298fc…b855 — the SHA-256 of the empty string,
431
+ // which looks like a real hash in the manifest and re-derives
432
+ // identically on `--verify-current`, so the gate confirmed a criteria
433
+ // set that was never there. `'none'` is the value that already means
434
+ // "no input" for `roadmap_hash`; an absent AC block gets the same word.
435
+ ac_hash: hasAcceptanceCriteria(args.acText) ? sha256(args.acText as string) : 'none',
430
436
  };
431
437
  }
432
438
 
433
439
  /**
434
- * Extract the `## Acceptance Criteria` section — from that heading (inclusive)
435
- * to the next `## ` heading or EOF. No section → empty string.
440
+ * Does an extraction carry acceptance criteria?
441
+ *
442
+ * The single predicate behind BOTH the manifest's `ac_hash` and the reviewer
443
+ * prompt's roadmap line. It exists because the first version of this fix encoded
444
+ * the same question twice — truthiness in {@link expectedHashes}, an explicit
445
+ * `!== null && !== ''` at the call site — and the two agreed only for as long as
446
+ * `''` stayed the sole reachable falsy value. Refining one alone (this `.trim()`,
447
+ * for instance) would then have made the manifest record a hash while the prompt
448
+ * told the reviewer there were no criteria: the same two-places-disagree failure
449
+ * this module was being repaired for, one level up. Caught by the R2 review of
450
+ * that very fix.
451
+ *
452
+ * Whitespace-only counts as absent. The inline collector can emit blank lines
453
+ * between a criterion and its loose-list continuation, so a result that is
454
+ * structurally non-empty and semantically empty is reachable.
455
+ */
456
+ export function hasAcceptanceCriteria(acText: string | null | undefined): boolean {
457
+ return typeof acText === 'string' && acText.trim() !== '';
458
+ }
459
+
460
+ /**
461
+ * Extract a roadmap's acceptance criteria in either form the tree uses.
462
+ *
463
+ * Two forms, because roadmap authors write both and the reviewer needs whichever
464
+ * one is present:
465
+ *
466
+ * 1. A `## Acceptance Criteria` section — from that heading (inclusive) to the
467
+ * next `## ` heading or EOF.
468
+ * 2. Inline `- **AC-n:**` bullets, declared per phase with no section heading —
469
+ * collected with their indented continuation lines.
470
+ *
471
+ * Neither form → empty string, and every caller must treat that as "no criteria"
472
+ * rather than as an extracted-and-empty block: see {@link expectedHashes} and the
473
+ * prompt's roadmap line.
474
+ *
475
+ * The heading form is tried first and wins outright when found. Measured
476
+ * 2026-08-18 over the 44 active roadmaps: 21 heading-only, 7 inline-only, 0
477
+ * carrying both, 16 carrying neither — so the two forms do not currently
478
+ * co-occur and a precedence rule is a guard against a future file rather than a
479
+ * choice between live populations.
480
+ *
481
+ * WHY THE INLINE FORM WAS MISSING, stated because this is the second instance of
482
+ * the same defect class in this one function. The first was case sensitivity
483
+ * (`## Acceptance criteria` extracted nothing, found by the zcs-close R2 review
484
+ * 2026-08-09). The second is this: an inline-only roadmap yielded `''`, nothing
485
+ * downstream noticed, and the reviewer was handed a 0-byte
486
+ * `acceptance-criteria.md` under a prompt that said the criteria had been
487
+ * extracted. The R2 reviewer is the independent check on AC conformance, so
488
+ * blinding it while the artefact reads as though it checked is the
489
+ * gate-that-scans-nothing-exits-green shape one layer up.
436
490
  */
437
491
  export function extractAcceptanceCriteria(roadmapText: string): string {
438
492
  const lines = roadmapText.split('\n');
439
493
  let start = -1;
440
494
  for (let i = 0; i < lines.length; i++) {
441
- // Case-insensitive: the tree carries both `## Acceptance Criteria` and
495
+ // Case-insensitive, and NOT anchored at the end of the line.
496
+ //
497
+ // Case first: the tree carries both `## Acceptance Criteria` and
442
498
  // `## Acceptance criteria`; a case-sensitive match silently extracted
443
499
  // nothing for the latter (found by the zcs-close R2 review, 2026-08-09).
444
- if (/^## acceptance criteria\s*$/i.test(lines[i] as string)) {
500
+ //
501
+ // The end-anchor was the same defect wearing a different mask, and it was
502
+ // LIVE: `\s*$` rejected a heading carrying a qualifier, so
503
+ // `## Acceptance criteria (per phase, on promotion to ready)` and
504
+ // `## Acceptance criteria (anti-dump — the review-s own rule)` extracted
505
+ // nothing. Two roadmaps, measured 2026-08-18 — and they had been counted
506
+ // as "declares no criteria" by the very census that motivated this fix,
507
+ // which is how a matcher tuned to its own measurement hides its misses.
508
+ if (/^##\s+acceptance criteria\b/i.test(lines[i] as string)) {
445
509
  start = i;
446
510
  break;
447
511
  }
448
512
  }
449
- if (start === -1) return '';
450
- let end = lines.length;
451
- for (let i = start + 1; i < lines.length; i++) {
452
- if (/^## /.test(lines[i] as string)) {
453
- end = i;
454
- break;
513
+ if (start !== -1) {
514
+ let end = lines.length;
515
+ for (let i = start + 1; i < lines.length; i++) {
516
+ if (/^## /.test(lines[i] as string)) {
517
+ end = i;
518
+ break;
519
+ }
520
+ }
521
+ return lines.slice(start, end).join('\n');
522
+ }
523
+ return extractInlineAcceptanceCriteria(lines);
524
+ }
525
+
526
+ /**
527
+ * An inline criterion bullet, at any indent and with either bullet marker.
528
+ *
529
+ * Indent-tolerant so a nested criterion is emitted as its own row rather than
530
+ * folded into its parent as a continuation line — folding made the extraction
531
+ * shape, and therefore `ac_hash`, a function of indentation depth. Neither the
532
+ * `*` marker nor a nested criterion occurs in the tree today (measured
533
+ * 2026-08-18 across all 44 active roadmaps: zero of each), so this is a guard
534
+ * against a future file, not a fix for a live population.
535
+ */
536
+ const AC_BULLET_RE = /^\s*[-*]\s+\*\*AC-/;
537
+
538
+ /**
539
+ * Collect inline `- **AC-n:**` bullets and their continuation lines.
540
+ *
541
+ * A continuation line is indented and non-blank. Bullets are emitted in file
542
+ * order and separated by nothing but their own newline: each one is
543
+ * self-labelling (`AC-0`, `AC-1`, …), so a synthetic section heading would be
544
+ * text this function invented rather than text the roadmap declared.
545
+ *
546
+ * Two stopping rules, and the second is the subtle one:
547
+ *
548
+ * - another criterion bullet ends the current one, at any indent — see
549
+ * {@link AC_BULLET_RE};
550
+ * - a blank line ends it ONLY when the next non-blank line is not an indented
551
+ * continuation. Markdown loose lists separate a bullet from its second
552
+ * paragraph with exactly that blank line, so breaking on the blank
553
+ * unconditionally truncated the criterion and emitted a PARTIAL extraction —
554
+ * which then hashed to a real value and read as complete. That is the
555
+ * looks-like-success shape this whole function exists to remove, relocated
556
+ * from "empty but claimed" to "half but claimed"; caught by the R2 review of
557
+ * the first version. No roadmap uses a loose continuation today (measured
558
+ * 2026-08-18: zero across all 44), so this is latent-defect removal.
559
+ */
560
+ function extractInlineAcceptanceCriteria(lines: readonly string[]): string {
561
+ const out: string[] = [];
562
+ for (let i = 0; i < lines.length; i++) {
563
+ if (!AC_BULLET_RE.test(lines[i] as string)) continue;
564
+ out.push(lines[i] as string);
565
+ let j = i + 1;
566
+ while (j < lines.length) {
567
+ const line = lines[j] as string;
568
+ // A sibling or nested criterion owns itself; hand it back to the
569
+ // outer loop rather than absorbing it.
570
+ if (AC_BULLET_RE.test(line)) break;
571
+ if (/^\s+\S/.test(line)) {
572
+ out.push(line);
573
+ j++;
574
+ continue;
575
+ }
576
+ if (line.trim() !== '') break;
577
+ // Blank: look past the run of blanks for an indented continuation.
578
+ let k = j;
579
+ while (k < lines.length && (lines[k] as string).trim() === '') k++;
580
+ const resumes =
581
+ k < lines.length &&
582
+ /^\s+\S/.test(lines[k] as string) &&
583
+ !AC_BULLET_RE.test(lines[k] as string);
584
+ if (!resumes) break;
585
+ for (let b = j; b < k; b++) out.push(lines[b] as string);
586
+ j = k;
455
587
  }
588
+ // Resume the outer scan AT the line that stopped the walk, so a
589
+ // criterion bullet that ended this one is still matched.
590
+ i = j - 1;
456
591
  }
457
- return lines.slice(start, end).join('\n');
592
+ return out.join('\n');
458
593
  }
459
594
 
460
595
  /**
@@ -576,11 +711,29 @@ function reviewerPrompt(args: {
576
711
  headSha: string;
577
712
  scopeHash: string;
578
713
  roadmapGiven: boolean;
714
+ acGiven: boolean;
579
715
  changedFiles: readonly string[];
580
716
  }): string {
581
- const roadmapLine = args.roadmapGiven
582
- ? '- roadmap under review: `roadmap.md` (Acceptance Criteria extracted to `acceptance-criteria.md`)'
583
- : '- roadmap under review: none (`acceptance-criteria.md` is empty)';
717
+ // Three states, not two. Branching on `roadmapGiven` alone told the reviewer
718
+ // "Acceptance Criteria extracted" whenever a roadmap was supplied — including
719
+ // when the extraction returned nothing and the file beside it was 0 bytes. A
720
+ // reviewer told the criteria are in a file it finds empty has no way to tell
721
+ // an extraction failure from a roadmap that genuinely declares none, so it
722
+ // cannot report the gap.
723
+ //
724
+ // The empty branch reports the EXTRACTION, never the roadmap. Its first
725
+ // version said "it declares NO acceptance criteria" — a claim the extractor
726
+ // cannot establish, since an unrecognised shape produces the identical empty
727
+ // result. That turned a silent failure into an affirmative false statement
728
+ // handed to the one independent check on AC conformance, which is strictly
729
+ // worse: silence invites a look, a false assertion forecloses it. Caught by
730
+ // the R2 review of the first version, and it was not hypothetical — the
731
+ // trailing-text heading it names was live in two roadmaps.
732
+ const roadmapLine = !args.roadmapGiven
733
+ ? '- roadmap under review: none (`acceptance-criteria.md` is empty)'
734
+ : args.acGiven
735
+ ? '- roadmap under review: `roadmap.md` (Acceptance Criteria extracted to `acceptance-criteria.md`)'
736
+ : '- roadmap under review: `roadmap.md` — NO acceptance criteria could be EXTRACTED from it, in either recognised form (an `## Acceptance criteria` heading, or inline `- **AC-n:**` bullets), so `acceptance-criteria.md` is empty. Two different things produce that result and the dispatcher cannot tell them apart: the roadmap declares none, or it declares them in a shape the extractor does not recognise. Open `roadmap.md`, decide which, and report a finding if the criteria are there.';
584
737
  return [
585
738
  `# R2 completion review — ${args.slug}`,
586
739
  '',
@@ -1037,6 +1190,7 @@ function runDispatch(args: Args): number {
1037
1190
  headSha,
1038
1191
  scopeHash: hashes.scope_hash,
1039
1192
  roadmapGiven: roadmapText !== null,
1193
+ acGiven: hasAcceptanceCriteria(acText),
1040
1194
  changedFiles,
1041
1195
  });
1042
1196
  const manifest = deriveManifest({
@@ -43,6 +43,66 @@
43
43
  * need an invalidation story for a tree that changes under the session, and
44
44
  * 12.3 ms does not buy one.
45
45
  *
46
+ * HOST-HONEST, AND FAIL-OPEN BY CONSTRUCTION (road-to-catalogue-host-fit Phase
47
+ * 3). The ranker reads the on-disk tree; the host does not necessarily deliver
48
+ * it. A skill the host truncated is still rankable and still pointable, and the
49
+ * pointer then names a skill whose description the model never received — worse
50
+ * than silence, because a pointer reads as a delivered capability. That is the
51
+ * roadmap's D-4, and it is MEASURED rather than assumed: 16 of 16 bare entries
52
+ * in the 2026-08-12 claude observation are still in this ranker's catalogue
53
+ * (`capture_skill_catalogue --pointable-bare`), among them `design-review`,
54
+ * `design-intelligence` and `fe-design` — skills this estate's own rules route
55
+ * to. So the pointer set is filtered against the latest per-entry observation
56
+ * for the CURRENT host.
57
+ *
58
+ * The filter only ever narrows on a positive reading, and every uncertain state
59
+ * resolves to no filtering at all: no observation log, no record for this host,
60
+ * a host that publishes no per-entry list, a malformed line, an unknown host, or
61
+ * any throw. `knownBareNames` returns `null` for each of them and
62
+ * `filterKnownBare` treats `null` as a pass-through, so behaviour is
63
+ * byte-identical to the unfiltered line. A filter that quietly hides skills on
64
+ * missing data is worse than the divergence it treats — the roadmap's own words,
65
+ * and the reason fail-open is a construction here rather than a convention.
66
+ *
67
+ * IT COSTS ZERO ON THE PATH THE HEADER ABOVE PROMISES IS FREE, AND 0.015 ms ON
68
+ * THE ONE THAT PAYS. The log read is passed in as a thunk, not a value, and
69
+ * `routePointers` calls it only after the term floor AND the rank both pass. A
70
+ * prompt below `MIN_TASK_TERMS` therefore still reads no files at all, which is
71
+ * the claim the 0 ms paragraph makes and an eagerly-read log would have quietly
72
+ * falsified.
73
+ *
74
+ * The FIRING path was measured rather than assumed, because R2 finding 10 is
75
+ * right that the paragraph above describes a path this branch changed. Measured
76
+ * 2026-08-18 on this tree, same method as the 12.3 ms figure: `knownBareForHost`
77
+ * alone is **0.015 ms median / 0.022 ms p95** (n=2000, warm) against a ranked
78
+ * pass of 8.3 ms median — three orders of magnitude below the ranker it rides
79
+ * on, and under 0.01 % of the slot's 250 ms p95 budget. The log is seven lines;
80
+ * a cache would need an invalidation story and 0.015 ms does not buy one, which
81
+ * is the same reasoning the ranker's own no-cache decision records.
82
+ *
83
+ * Note what the existing bench does NOT cover, so nobody reads it as coverage:
84
+ * `bench_hook_latency`'s synthetic payload carries neither `prompt` nor
85
+ * `platform`, so this concern returns before the ranker on every bench
86
+ * iteration. The figures above come from a direct probe, not from that harness.
87
+ *
88
+ * THE FLOOR APPLIES TO WHAT IS DELIVERED, NOT TO WHAT WAS RANKED. Filtering
89
+ * happens BEFORE `MIN_TOP_SCORE` and before the `TOP_K` slice, so the confidence
90
+ * question is asked of the best pointer the model can actually use. One rule, in
91
+ * one order: rank → drop the undeliverable → apply the floor → take the top
92
+ * three.
93
+ *
94
+ * That ordering makes silence a real outcome, and the number says so rather than
95
+ * the prose guessing: on `"review the authorization policy and tenant scope for
96
+ * this endpoint"` the ranker returns `authz-review` at 47 and the next entry at
97
+ * 23, against a floor of 31. Suppress the top-1 and NOTHING clears the floor —
98
+ * the line goes silent instead of naming a 23/100 pointer because the 47 one was
99
+ * undeliverable. That is the intended reading of a floor calibrated for
100
+ * confidence, and promoting a sub-floor pointer to fill the gap would be the
101
+ * "advisory worse than silence" failure this file's risk paragraph ranks first.
102
+ * `suppressed` is what distinguishes that silence from an unranked prompt, and
103
+ * `tests/hooks/skill_route_hook.test.ts` pins both cases against the real
104
+ * corpus rather than a constructed one.
105
+ *
46
106
  * NO ADOPTION THRESHOLD IS COMMITTED HERE. Whether the line changes behaviour
47
107
  * is a question for data, not for this header: `skill_route_pointer_rate` is
48
108
  * registered in `hook-token-budget.json` under the same owner/review discipline
@@ -51,6 +111,13 @@
51
111
  * removed. The score floor above is a TRIGGER calibration and is measured; the
52
112
  * adoption threshold is an OUTCOME claim and is deliberately absent.
53
113
  *
114
+ * `skill_route_bare_suppression_rate` is registered beside it under the same
115
+ * owner, review date and kill discipline, and inherits the same refusal: how
116
+ * often the filter fires is a number to collect, not one to promise. Its
117
+ * numerator is carried in the warn `reason` below — `N suppressed` — because no
118
+ * dedicated counter exists, and that gap is registered as a gap rather than
119
+ * implied to be automatic.
120
+ *
54
121
  * DELIVERY PATH — the verified one, copied rather than re-derived. A
55
122
  * `user_prompt_submit` concern returning `{decision:"allow", context:…}` at
56
123
  * exit 0 produces zero stdout: `host_semantics.emitFor` drops the field for
@@ -73,7 +140,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
73
140
 
74
141
  import { readHookStdin } from "./hook_stdin.js";
75
142
  import { isSyntheticPrompt } from "../_lib/prompt_shape.js";
76
- import { resolveSkillsRoot } from "../_lib/skill_catalogue.js";
143
+ import { OBSERVATION_LOG, readObservationLog, resolveSkillsRoot } from "../_lib/skill_catalogue.js";
144
+ import { knownBareNames } from "../_lib/skill_catalogue_series.js";
77
145
  import { _tokenize, rank, type RankRow } from "../skill_tools/score_skill_relevance.js";
78
146
 
79
147
  const EXIT_ALLOW = 0;
@@ -119,31 +187,115 @@ function _isObject(v: unknown): v is JsonObject {
119
187
  return typeof v === "object" && v !== null && !Array.isArray(v);
120
188
  }
121
189
 
190
+ /**
191
+ * Names this host is known to have delivered bare, or `null` for no filtering.
192
+ *
193
+ * Every failure mode collapses to `null` — an unknown host, a missing log, an
194
+ * unreadable one, a host that enumerates nothing, a throw from anywhere in the
195
+ * read. The three-way distinction `knownBareNames` draws is preserved: an empty
196
+ * set means *measured clean*, `null` means *never measured*, and only the second
197
+ * one is produced here by accident.
198
+ */
199
+ export function knownBareForHost(workspaceRoot: string, host: string | null): Set<string> | null {
200
+ if (host === null || host === "") return null;
201
+ try {
202
+ return knownBareNames(readObservationLog(path.join(workspaceRoot, OBSERVATION_LOG)), host);
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Drop the ranked rows this host is known to have delivered bare.
210
+ *
211
+ * `null` is a pass-through and returns the input order untouched, which is the
212
+ * fail-open half of the header's contract and what makes an absent observation
213
+ * byte-identical to today.
214
+ */
215
+ export function filterKnownBare(
216
+ rows: readonly RankRow[],
217
+ bare: Set<string> | null,
218
+ ): RankRow[] {
219
+ if (bare === null || bare.size === 0) return [...rows];
220
+ return rows.filter(([name]) => !bare.has(name));
221
+ }
222
+
223
+ /** What one routing pass decided, including what it refused to name. */
224
+ export interface RouteDecision {
225
+ rows: RankRow[];
226
+ /**
227
+ * Would-be POINTERS dropped as known-bare — the suppression metric's numerator.
228
+ *
229
+ * Scoped to the pointer window (`TOP_K` of the unfiltered ranking), not to the
230
+ * whole ranked list. R2 finding 1: `rank` returns every non-zero-scoring skill
231
+ * and this catalogue has hundreds, so counting drops across all of them would
232
+ * let a bare name at rank 40 — never pointable, never a loss — bump the count
233
+ * on almost every fire. The registered metric defines this as skills the
234
+ * ranker wanted to POINT AT, and its upper falsifier would otherwise fire on a
235
+ * perfectly healthy join.
236
+ */
237
+ suppressed: number;
238
+ }
239
+
122
240
  /**
123
241
  * Rank a prompt and return the pointers worth injecting, or `[]` for silence.
124
242
  *
125
243
  * Total by construction: a missing root, an unreadable catalogue, or a ranker
126
244
  * error all resolve to `[]`. A routing advisory that can crash a turn is worse
127
245
  * than one that says nothing.
246
+ *
247
+ * `bareProvider` is a THUNK, deliberately — see the header's zero-cost
248
+ * paragraph. It is called only once both the term floor and the rank have
249
+ * passed, so the short-prompt path still touches no files. Omitting it disables
250
+ * filtering entirely, which is what keeps every caller that predates the filter
251
+ * behaviourally unchanged.
128
252
  */
129
- export function routePointers(prompt: string, skillsDir: string | null): RankRow[] {
130
- if (skillsDir === null) return [];
253
+ export function routeDecision(
254
+ prompt: string,
255
+ skillsDir: string | null,
256
+ bareProvider?: () => Set<string> | null,
257
+ ): RouteDecision {
258
+ const silent: RouteDecision = { rows: [], suppressed: 0 };
259
+ if (skillsDir === null) return silent;
131
260
  // Denominator floor BEFORE the catalogue read: a prompt too short to score
132
261
  // meaningfully cannot produce a pointer worth 337 file reads either.
133
262
  try {
134
- if (_tokenize(prompt).size < MIN_TASK_TERMS) return [];
263
+ if (_tokenize(prompt).size < MIN_TASK_TERMS) return silent;
135
264
  } catch {
136
- return [];
265
+ return silent;
137
266
  }
138
267
  let rows: RankRow[];
139
268
  try {
140
269
  rows = rank(prompt, skillsDir);
141
270
  } catch {
142
- return [];
271
+ return silent;
272
+ }
273
+ let bare: Set<string> | null = null;
274
+ if (bareProvider !== undefined) {
275
+ try {
276
+ bare = bareProvider();
277
+ } catch {
278
+ bare = null; // fail open, never silent-narrow
279
+ }
143
280
  }
144
- const top = rows[0];
145
- if (top === undefined || top[1] < MIN_TOP_SCORE) return [];
146
- return rows.slice(0, TOP_K);
281
+ const deliverable = filterKnownBare(rows, bare);
282
+ // The pointer WINDOW, not the ranked list — see `RouteDecision.suppressed`.
283
+ const suppressed =
284
+ bare === null ? 0 : rows.slice(0, TOP_K).filter(([name]) => bare.has(name)).length;
285
+ // The floor asks its question of the best DELIVERABLE pointer, not of a
286
+ // pointer the model cannot use. See the header's one-rule-one-order note.
287
+ const top = deliverable[0];
288
+ if (top === undefined || top[1] < MIN_TOP_SCORE) return { rows: [], suppressed };
289
+ return { rows: deliverable.slice(0, TOP_K), suppressed };
290
+ }
291
+
292
+ /** The rows alone. Kept because most callers and fixtures want only these. */
293
+ export function routePointers(
294
+ prompt: string,
295
+ skillsDir: string | null,
296
+ bareProvider?: () => Set<string> | null,
297
+ ): RankRow[] {
298
+ return routeDecision(prompt, skillsDir, bareProvider).rows;
147
299
  }
148
300
 
149
301
  /**
@@ -191,13 +343,22 @@ export function main(): number {
191
343
  if (!prompt) return EXIT_ALLOW;
192
344
  if (isSyntheticPrompt(prompt)) return EXIT_ALLOW; // harness turn, not a task
193
345
 
194
- const rows = routePointers(prompt, resolveSkillsRoot(_workspaceRoot(env)));
346
+ const root = _workspaceRoot(env);
347
+ const platform = env["platform"];
348
+ const host = typeof platform === "string" && platform ? platform : null;
349
+ const { rows, suppressed } = routeDecision(prompt, resolveSkillsRoot(root), () =>
350
+ knownBareForHost(root, host),
351
+ );
195
352
  if (rows.length === 0) return EXIT_ALLOW; // silence is the default
196
353
 
197
354
  process.stdout.write(
198
355
  `${JSON.stringify({
199
356
  decision: "warn",
200
- reason: `skill-route: ${rows.length} pointer(s), top ${rows[0]![1]}/100`,
357
+ reason:
358
+ `skill-route: ${rows.length} pointer(s), top ${rows[0]![1]}/100` +
359
+ // The suppression metric's numerator. Named only when non-zero, so the
360
+ // common line keeps its shape and its registered byte budget.
361
+ (suppressed > 0 ? `, ${suppressed} suppressed as host-bare` : ""),
201
362
  additional_context: buildRouteLine(rows),
202
363
  })}\n`,
203
364
  );
@@ -35,6 +35,32 @@ cat > "$HOOKS_DIR/pre-push" << 'EOF'
35
35
  # so any derived-output drift blocks the push here. Runtime ~15-40s (it
36
36
  # regenerates the tool trees) — cheaper than a red CI run and a fixup re-push.
37
37
 
38
+ # A delete-only push has no content to gate, so every check below is answering
39
+ # a question nobody asked. Measured 2026-08-18 during the 14.0.0 release:
40
+ # `git push origin --delete release/14.0.0` was refused by the branch-freshness
41
+ # gate because the CHECKED-OUT branch was behind main — a fact with no bearing
42
+ # on removing a remote ref. The deletion had to go through the GitHub API to
43
+ # happen at all, which routes around the whole hook rather than around one gate.
44
+ #
45
+ # Git feeds pre-push one line per ref on stdin:
46
+ # <local ref> <local sha> <remote ref> <remote sha>
47
+ # and a deletion carries the all-zero local sha. Reading stdin is safe here:
48
+ # nothing below consumes it.
49
+ delete_only=1
50
+ saw_ref=0
51
+ while read -r _local_ref local_sha _remote_ref _remote_sha; do
52
+ [ -z "${local_sha:-}" ] && continue
53
+ saw_ref=1
54
+ # Any non-zero character means a real object is being pushed.
55
+ case "$local_sha" in
56
+ *[!0]*) delete_only=0 ;;
57
+ esac
58
+ done
59
+ if [ "$saw_ref" = "1" ] && [ "$delete_only" = "1" ]; then
60
+ echo "🗑️ Delete-only push — no ref advances, so the content gates have nothing to check. Skipping."
61
+ exit 0
62
+ fi
63
+
38
64
  fail=0
39
65
 
40
66
  if ! command -v task >/dev/null 2>&1; then