@event4u/agent-config 14.0.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.
@@ -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