@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +42 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/hooks/dispatch.js +87 -10
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/package.json +1 -1
- package/src/config/hook-token-budget.json +6 -0
- package/src/scripts/_lib/skill_catalogue_series.ts +91 -0
- package/src/scripts/dispatch_r2_reviewer.ts +169 -15
- package/src/scripts/hooks/skill_route_hook.ts +172 -11
- package/src/scripts/install-hooks.sh +26 -0
|
@@ -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.
|
|
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.
|
|
42
|
+
agent_config_version: "14.2.0"
|
|
43
43
|
|
|
44
44
|
# --- Project identity ---
|
|
45
45
|
project:
|
|
@@ -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-
|
|
14581
|
+
"generated_at": "2026-08-18T16:49:04Z",
|
|
14582
14582
|
"packs": [
|
|
14583
14583
|
{
|
|
14584
14584
|
"artefact_count": 118,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
68f3e14e4cfaf98bb9cbfee5bf5da3cf62a28c74bfb9d0450e6f01cd3c0c4bc0 discovery-manifest.json
|
package/dist/hooks/dispatch.js
CHANGED
|
@@ -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
|
|
27982
|
-
if (
|
|
28026
|
+
function knownBareForHost(workspaceRoot2, host) {
|
|
28027
|
+
if (host === null || host === "") return null;
|
|
27983
28028
|
try {
|
|
27984
|
-
|
|
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
|
|
27995
|
-
|
|
27996
|
-
|
|
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
|
|
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
|
`
|
package/dist/mcp/server.json
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
"transport": {
|
|
10
10
|
"type": "stdio"
|
|
11
11
|
},
|
|
12
|
-
"version": "14.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
435
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
|
130
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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:
|
|
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
|