@event4u/agent-config 16.1.0 → 16.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.
Files changed (217) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +237 -248
  5. package/dist/agent-src/commands/roadmap/process-full.md +1 -1
  6. package/dist/agent-src/commands/work.md +4 -0
  7. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +4 -8
  8. package/dist/agent-src/rules/ui-audit-gate.md +1 -2
  9. package/dist/agent-src/skills/accessibility-auditor/data/aria-patterns.csv +1 -1
  10. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +2 -2
  11. package/dist/agent-src/skills/design-review/SKILL.md +30 -4
  12. package/dist/agent-src/skills/design-review/references/verification-automation.md +32 -0
  13. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +16 -1
  14. package/dist/agent-src/skills/existing-ui-audit/references/component-taxonomy-detection.md +56 -0
  15. package/dist/agent-src/skills/markitdown/SKILL.md +4 -0
  16. package/dist/agent-src/skills/playwright-testing/SKILL.md +30 -6
  17. package/dist/agent-src/skills/ui-component-architect/SKILL.md +23 -0
  18. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  19. package/dist/agent-src/templates/roadmaps.md +30 -0
  20. package/dist/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +10 -4
  21. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +260 -21
  22. package/dist/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +22 -3
  23. package/dist/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +6 -1
  24. package/dist/agent-src/templates/scripts/work_engine/state.ts +57 -7
  25. package/dist/agent-src/templates/scripts/work_engine/taxonomy/detect.ts +500 -0
  26. package/dist/agent-src/templates/scripts/work_engine/taxonomy/place.ts +183 -0
  27. package/dist/cli/python/knowledge_ingest.js +1 -1
  28. package/dist/cli/python/workspace_drive.js +1 -1
  29. package/dist/cli-delegate/{chunk-S3VFXMTM.js → chunk-4XFP2Y3E.js} +1 -1
  30. package/dist/cli-delegate/{chunk-HDVVGPQW.js → chunk-7XGBX6ZH.js} +471 -387
  31. package/dist/cli-delegate/{chunk-GJCBB2UG.js → chunk-FP36SWJD.js} +432 -144
  32. package/dist/cli-delegate/{chunk-UNCRJBT3.js → chunk-IGD6ERX6.js} +31 -0
  33. package/dist/cli-delegate/{chunk-FXYUYU4N.js → chunk-JNMVLWLR.js} +42 -1
  34. package/dist/cli-delegate/{chunk-QJWAUMJW.js → chunk-YJ72UIAK.js} +730 -15
  35. package/dist/cli-delegate/cmd_conformance.js +902 -782
  36. package/dist/cli-delegate/cmd_doctor.js +2 -2
  37. package/dist/cli-delegate/cmd_export.js +2 -2
  38. package/dist/cli-delegate/cmd_handoff.js +4 -4
  39. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  40. package/dist/cli-delegate/cmd_prune.js +2 -2
  41. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  42. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  43. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  44. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  45. package/dist/cli-delegate/cmd_sync.js +2 -2
  46. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  47. package/dist/cli-delegate/cmd_validate.js +2 -2
  48. package/dist/discovery/deprecation-report.md +1 -1
  49. package/dist/discovery/discovery-manifest.json +10 -10
  50. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  51. package/dist/discovery/discovery-manifest.summary.md +1 -1
  52. package/dist/discovery/dormancy-report.md +1 -1
  53. package/dist/discovery/orphan-report.md +1 -1
  54. package/dist/discovery/packs.json +2 -2
  55. package/dist/discovery/trust-report.md +1 -1
  56. package/dist/discovery/workspaces.json +2 -2
  57. package/dist/hooks/dispatch.js +6666 -2581
  58. package/dist/hooks/dispatch.sha256 +1 -0
  59. package/dist/install/install.mjs +848 -733
  60. package/dist/mcp/registry-manifest.json +2 -2
  61. package/dist/mcp/server.json +2 -2
  62. package/dist/mcp/server.mjs +2747 -1510
  63. package/dist/server/schemas/settings.js +23 -0
  64. package/dist/ui/assets/index-Cwq4d0hX.js +23 -0
  65. package/dist/ui/assets/index-Cyjy8bWO.css +1 -0
  66. package/dist/ui/index.html +2 -2
  67. package/docs/contracts/settings-classes.md +6 -4
  68. package/package.json +4 -4
  69. package/src/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +10 -4
  70. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +260 -21
  71. package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +22 -3
  72. package/src/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +6 -1
  73. package/src/agent-src/templates/scripts/work_engine/state.ts +57 -7
  74. package/src/agent-src/templates/scripts/work_engine/taxonomy/detect.ts +500 -0
  75. package/src/agent-src/templates/scripts/work_engine/taxonomy/place.ts +183 -0
  76. package/src/config/agent-settings.template.yml +29 -0
  77. package/src/config/ci-local-parity.yml +18 -0
  78. package/src/config/continuity-surface.json +2 -2
  79. package/src/config/gate-coverage.yml +234 -3
  80. package/src/config/gate-violation-baselines.json +7 -3
  81. package/src/config/hook-latency-budget.json +21 -0
  82. package/src/config/hook-token-budget.json +2 -2
  83. package/src/config/metric-registry.yml +114 -0
  84. package/src/config/placeholder-drift-budget.json +10 -0
  85. package/src/config/routing-coverage-seed.json +18 -4
  86. package/src/scripts/_cli/cmd_doctor.ts +5 -19
  87. package/src/scripts/_cli/doctor_execution.ts +100 -17
  88. package/src/scripts/_cli/doctor_network_posture.ts +226 -0
  89. package/src/scripts/_lib/body_portable.ts +102 -0
  90. package/src/scripts/_lib/cc_transcript.ts +132 -1
  91. package/src/scripts/_lib/concern_sla_window.ts +200 -0
  92. package/src/scripts/_lib/forge_protection.ts +114 -0
  93. package/src/scripts/_lib/forge_reader.ts +558 -0
  94. package/src/scripts/_lib/host_env_write.ts +111 -0
  95. package/src/scripts/_lib/llm_proposer_transport.ts +14 -4
  96. package/src/scripts/_lib/obligations.ts +119 -8
  97. package/src/scripts/_lib/probe_inputs.ts +271 -0
  98. package/src/scripts/_lib/read_surface_scan.ts +322 -0
  99. package/src/scripts/_lib/release_highlights.ts +2 -1
  100. package/src/scripts/_lib/retrieval_sanitize.ts +77 -16
  101. package/src/scripts/_lib/shell_write_shapes.ts +108 -0
  102. package/src/scripts/_lib/structural_hiding.ts +622 -0
  103. package/src/scripts/_lib/turn_end_refusals.ts +525 -4
  104. package/src/scripts/_lib/turn_end_transcript.ts +216 -0
  105. package/src/scripts/_lib/verification_command.ts +324 -0
  106. package/src/scripts/_lib/verification_evidence.ts +647 -0
  107. package/src/scripts/_lib/verify_clause.ts +280 -0
  108. package/src/scripts/ai_team/team_dispatch.ts +4 -0
  109. package/src/scripts/before_complete_hook.ts +340 -23
  110. package/src/scripts/bench_hook_latency.ts +412 -5
  111. package/src/scripts/cache_realization_report.ts +61 -1
  112. package/src/scripts/check_argument_hint.ts +287 -0
  113. package/src/scripts/check_claims.ts +125 -1
  114. package/src/scripts/check_enforcement_coverage.ts +38 -9
  115. package/src/scripts/check_enforcement_matrix.ts +245 -3
  116. package/src/scripts/check_finding_dispositions.ts +22 -3
  117. package/src/scripts/check_generator_sync.ts +71 -0
  118. package/src/scripts/check_host_docs_digest.ts +786 -0
  119. package/src/scripts/check_host_format_column.ts +559 -0
  120. package/src/scripts/check_input_references.ts +228 -0
  121. package/src/scripts/check_inputs_parity.ts +235 -0
  122. package/src/scripts/check_kernel_edit_ratified.ts +47 -5
  123. package/src/scripts/check_kill_switch_table.ts +294 -0
  124. package/src/scripts/check_placeholder_drift.ts +221 -0
  125. package/src/scripts/check_read_surface_coverage.ts +515 -0
  126. package/src/scripts/check_release_holds.ts +2 -1
  127. package/src/scripts/check_routing_coverage.ts +378 -11
  128. package/src/scripts/check_static_parity.ts +17 -0
  129. package/src/scripts/ci_settle.ts +145 -19
  130. package/src/scripts/closure_scan.ts +115 -1
  131. package/src/scripts/compile_hook_manifest.ts +60 -16
  132. package/src/scripts/external_sources_denylist.json +29 -4
  133. package/src/scripts/hook_manifest.json +1 -1
  134. package/src/scripts/hook_manifest.yaml +42 -5
  135. package/src/scripts/hooks/block_config_weakening.ts +229 -4
  136. package/src/scripts/hooks/block_kernel_rule_writes.ts +9 -78
  137. package/src/scripts/hooks/block_plumbing_writes.ts +314 -0
  138. package/src/scripts/hooks/bundle_integrity.ts +291 -0
  139. package/src/scripts/hooks/chain_nudge_hook.ts +1 -2
  140. package/src/scripts/hooks/code_graph_context_hook.ts +1 -2
  141. package/src/scripts/hooks/comment_discipline_hook.ts +1 -3
  142. package/src/scripts/hooks/concern_registry.ts +2 -0
  143. package/src/scripts/hooks/concern_timings.ts +94 -0
  144. package/src/scripts/hooks/delegation_nudge_hook.ts +1 -2
  145. package/src/scripts/hooks/design_pass_hook.ts +100 -9
  146. package/src/scripts/hooks/design_slop_hook.ts +1 -2
  147. package/src/scripts/hooks/dispatch_hook.ts +25 -19
  148. package/src/scripts/hooks/edit_shape_hook.ts +1 -2
  149. package/src/scripts/hooks/end_review_nudge_hook.ts +1 -1
  150. package/src/scripts/hooks/evidence_independence.ts +1 -2
  151. package/src/scripts/hooks/exit_codes.ts +128 -0
  152. package/src/scripts/hooks/host_lowering.json +1 -0
  153. package/src/scripts/hooks/host_lowering.ts +128 -3
  154. package/src/scripts/hooks/host_lowering.yaml +262 -46
  155. package/src/scripts/hooks/host_semantics.ts +30 -2
  156. package/src/scripts/hooks/injection_budget.ts +9 -2
  157. package/src/scripts/hooks/interruption_ledger_hook.ts +1 -2
  158. package/src/scripts/hooks/journal_record_hook.ts +1 -2
  159. package/src/scripts/hooks/memory_recall_hook.ts +1 -2
  160. package/src/scripts/hooks/obligation_settle_hook.ts +151 -5
  161. package/src/scripts/hooks/one_question_per_ask_hook.ts +25 -4
  162. package/src/scripts/hooks/orchestration_record_hook.ts +1 -2
  163. package/src/scripts/hooks/permission_gate.ts +1 -2
  164. package/src/scripts/hooks/push_settle_hook.ts +1 -3
  165. package/src/scripts/hooks/replay_hook.ts +2 -1
  166. package/src/scripts/hooks/reread_guard_hook.ts +1 -2
  167. package/src/scripts/hooks/review_baseline_hook.ts +1 -2
  168. package/src/scripts/hooks/rtk_wrap_hook.ts +1 -2
  169. package/src/scripts/hooks/rule_inject_hook.ts +8 -5
  170. package/src/scripts/hooks/run_continuation_hook.ts +13 -15
  171. package/src/scripts/hooks/skill_route_hook.ts +1 -2
  172. package/src/scripts/hooks/source_first_gate_hook.ts +1 -2
  173. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +1 -2
  174. package/src/scripts/hooks/subagent_ledger_hook.ts +1 -2
  175. package/src/scripts/hooks/suggestion_capture_hook.ts +1 -1
  176. package/src/scripts/hooks/table_fingerprint.ts +49 -0
  177. package/src/scripts/hooks/telemetry_flush_hook.ts +1 -2
  178. package/src/scripts/hooks/telemetry_usage_hook.ts +1 -2
  179. package/src/scripts/hooks/tool_result_bytes_hook.ts +93 -9
  180. package/src/scripts/hooks/turn_end_gate_hook.ts +337 -324
  181. package/src/scripts/hooks/ui_route_nudge_hook.ts +1 -2
  182. package/src/scripts/install.ts +3 -4
  183. package/src/scripts/lint_deny_text.ts +419 -0
  184. package/src/scripts/lint_exit_codes.ts +262 -0
  185. package/src/scripts/lint_hook_manifest.ts +55 -0
  186. package/src/scripts/lint_roadmap_blockers.ts +78 -12
  187. package/src/scripts/lint_skill_descriptions.ts +1 -1
  188. package/src/scripts/lint_skill_scripts_readonly.ts +1 -1
  189. package/src/scripts/mcp_server/prompts.ts +142 -1
  190. package/src/scripts/measure_skill_ranker_baseline.ts +305 -51
  191. package/src/scripts/measure_turn_end_gate.ts +193 -0
  192. package/src/scripts/memory_lookup.ts +1 -1
  193. package/src/scripts/prepack-check.mjs +53 -0
  194. package/src/scripts/release_publication.ts +13 -1
  195. package/src/scripts/report_invocation_surface.ts +383 -0
  196. package/src/scripts/report_obligation_carriers.ts +699 -0
  197. package/src/scripts/report_obligation_writer_split.ts +236 -0
  198. package/src/scripts/report_skill_menu_census.ts +208 -0
  199. package/src/scripts/report_standing_payload_by_host.ts +1 -1
  200. package/src/scripts/roadmap_verify_share.ts +213 -0
  201. package/src/scripts/schemas/command.schema.json +44 -0
  202. package/src/scripts/schemas/review-findings.schema.json +4 -0
  203. package/src/scripts/schemas/skill.schema.json +44 -0
  204. package/src/scripts/self_review_gate.ts +359 -10
  205. package/src/scripts/semantic_parity_census.ts +314 -0
  206. package/src/scripts/session_eol_report.ts +54 -1
  207. package/src/scripts/sweep_dead_scan_roots.ts +1 -1
  208. package/src/scripts/trigger_eval_grandfather.json +0 -2
  209. package/src/scripts/trigger_eval_rotation.ts +122 -27
  210. package/src/scripts/ui_conformance_probe.ts +156 -4
  211. package/src/scripts/update_prices.ts +85 -3
  212. package/src/scripts/write_bundle_digest.mjs +74 -0
  213. package/src/server/schemas/settings.ts +31 -0
  214. package/src/templates/consumer-settings/ONBOARDING.md +1 -0
  215. package/dist/ui/assets/index-DH1xc62v.js +0 -29
  216. package/dist/ui/assets/index-jSfMW8_t.css +0 -1
  217. /package/dist/cli-delegate/{chunk-YUM5KURG.js → chunk-7RQP4UDH.js} +0 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `body-portable` — does a skill's declared frontmatter survive the MCP-lite
3
+ * carrier whole?
4
+ *
5
+ * road-to-semantic-parity-before-off-menu, Phase 2.1.
6
+ *
7
+ * WHAT THIS DECIDES, AND WHAT IT DOES NOT
8
+ *
9
+ * It decides ELIGIBILITY only. It marks nothing, excludes nothing, and is not a
10
+ * verdict about which skills matter. A committed list of skills this returns
11
+ * false for is one step from a menu-exclusion discharged by citing it, which is
12
+ * precisely the laundering the parked menu-economy roadmap's Risk 1 names — a
13
+ * measurement of carrier coverage reused as a judgement about worth. Nothing in
14
+ * the tree consumes this as an exclusion trigger, and building the half that
15
+ * could is a separate decision that has not been taken.
16
+ *
17
+ * THE RULE
18
+ *
19
+ * A skill is body-portable when every key it declares is one the carrier
20
+ * transports whole. Three ways to fail, and they are deliberately not
21
+ * distinguished in the boolean:
22
+ *
23
+ * - a DROPPED key — the carrier never reads it, so the semantic stays on disk.
24
+ * - a PARTIAL key — the carrier reads it and transports part of the value.
25
+ * `triggers` is the only one: `keyword` and `phrase` travel, while
26
+ * `file_pattern`, `path_prefix`, `command` and `reason` do not. Counting it
27
+ * as portable would make a path-triggered skill look complete when the very
28
+ * routing that selects it never leaves the disk.
29
+ * - an UNKNOWN key — one the schema does not declare at all. It fails CLOSED.
30
+ * The alternative is to ignore what you cannot classify, and an unknown key
31
+ * is exactly the case where the carrier's behaviour is unestablished, so
32
+ * returning true there would assert portability from ignorance.
33
+ *
34
+ * SCOPE: one carrier, `ContentEntry` in `src/cli/mcp/content.ts`. A second
35
+ * delivery path drops a different set and needs its own predicate rather than a
36
+ * widened reading of this one.
37
+ */
38
+
39
+ import { INDEXED_TRIGGER_KEYS } from '../../shared/skillRanking.js';
40
+
41
+ /**
42
+ * Frontmatter keys `buildEntry` reads and transports whole
43
+ * (`src/cli/mcp/content.ts` — `fm.name`, `fm.description`, `fm.source`,
44
+ * `fm.personas`).
45
+ *
46
+ * Derived from the carrier source, never from the schema: a schema property the
47
+ * carrier does not read is dropped no matter what the schema says about it.
48
+ */
49
+ export const CARRIED_KEYS: readonly string[] = ['name', 'description', 'source', 'personas'];
50
+
51
+ /**
52
+ * Keys the carrier reads but truncates, with the sub-keys that actually travel.
53
+ *
54
+ * The sub-key list is IMPORTED from `INDEXED_TRIGGER_KEYS` rather than restated.
55
+ * Restating it was the first version of this file and it is a silent-lie shape:
56
+ * add a third indexed key and a hand-copied literal keeps reporting the old two
57
+ * while the carrier transports three, so the census `travels` column would be
58
+ * wrong with every test still green.
59
+ */
60
+ export const PARTIAL_KEYS: Readonly<Record<string, readonly string[]>> = {
61
+ triggers: INDEXED_TRIGGER_KEYS,
62
+ };
63
+
64
+ /** Why a skill is not body-portable. Empty when it is. */
65
+ export interface PortabilityReasons {
66
+ /** Declared keys the carrier transports only in part. */
67
+ readonly partial: readonly string[];
68
+ /** Declared keys the carrier does not read, including keys the schema does not declare. */
69
+ readonly dropped: readonly string[];
70
+ }
71
+
72
+ export interface PortabilityVerdict extends PortabilityReasons {
73
+ readonly portable: boolean;
74
+ }
75
+
76
+ /**
77
+ * Classify one skill's declared frontmatter keys.
78
+ *
79
+ * Pure: it reads nothing and depends only on its argument and the two constants
80
+ * above.
81
+ */
82
+ export function classifyPortability(frontmatter: Readonly<Record<string, unknown>>): PortabilityVerdict {
83
+ const partial: string[] = [];
84
+ const dropped: string[] = [];
85
+
86
+ for (const key of Object.keys(frontmatter).sort()) {
87
+ if (CARRIED_KEYS.includes(key)) continue;
88
+ if (Object.hasOwn(PARTIAL_KEYS, key)) {
89
+ partial.push(key);
90
+ continue;
91
+ }
92
+ // Dropped, and an unknown key lands here too — failing closed.
93
+ dropped.push(key);
94
+ }
95
+
96
+ return { portable: partial.length === 0 && dropped.length === 0, partial, dropped };
97
+ }
98
+
99
+ /** The predicate itself. `true` only when nothing the skill declares is lost. */
100
+ export function isBodyPortable(frontmatter: Readonly<Record<string, unknown>>): boolean {
101
+ return classifyPortability(frontmatter).portable;
102
+ }
@@ -82,6 +82,16 @@ export interface TranscriptRecord {
82
82
  /** Raw `timestamp` field from the record, or null when absent. */
83
83
  timestamp: string | null;
84
84
  usage: TokenCounts;
85
+ /**
86
+ * UTF-8 byte length of this record's own `message.content`, or `null` when
87
+ * the scan did not ask for it ({@link ScanOptions.measureBytes}) or the
88
+ * record carries no serialisable content.
89
+ *
90
+ * `null` is NOT zero. A record whose content was never measured and a
91
+ * record whose content is empty are different facts, and a ratio built
92
+ * over the first would divide by a denominator it never observed.
93
+ */
94
+ content_bytes: number | null;
85
95
  }
86
96
 
87
97
  export interface ScanOptions {
@@ -98,6 +108,17 @@ export interface ScanOptions {
98
108
  maxAgeDays?: number;
99
109
  /** Clock used to evaluate `maxAgeDays` — injectable for tests. */
100
110
  now?: Date;
111
+ /**
112
+ * Also measure each record's `message.content` byte length
113
+ * ({@link TranscriptRecord.content_bytes}).
114
+ *
115
+ * OPT-IN, because it costs a second serialisation of content the scan
116
+ * otherwise never touches, and the existing callers — `cost_summary`,
117
+ * `preamble_byte_census`, the bench harness — want token sums only. A
118
+ * caller that does not ask gets `content_bytes: null` on every record and
119
+ * pays nothing.
120
+ */
121
+ measureBytes?: boolean;
101
122
  }
102
123
 
103
124
  export interface ScanResult {
@@ -161,7 +182,7 @@ interface RawUsage {
161
182
 
162
183
  interface RawAssistantRecord {
163
184
  type?: unknown;
164
- message?: { id?: unknown; model?: unknown; usage?: RawUsage };
185
+ message?: { id?: unknown; model?: unknown; usage?: RawUsage; content?: unknown };
165
186
  requestId?: unknown;
166
187
  agentId?: unknown;
167
188
  isSidechain?: unknown;
@@ -180,6 +201,29 @@ function toTokenCounts(raw: RawUsage | undefined): TokenCounts {
180
201
  };
181
202
  }
182
203
 
204
+ /**
205
+ * UTF-8 byte length of one record's `message.content`, or `null` when there is
206
+ * nothing serialisable to measure.
207
+ *
208
+ * Bytes, never characters: a multibyte block costs what it costs, and
209
+ * `String.length` would under-report it. Content is what this reader can
210
+ * actually SEE — the request's system prompt, tool definitions and prior turns
211
+ * are not in the transcript at all (`preamble_byte_census` records the same
212
+ * limitation), so a ratio built from this is a ratio over assistant output and
213
+ * is labelled as such wherever it surfaces.
214
+ */
215
+ export function measureContentBytes(raw: RawAssistantRecord): number | null {
216
+ const content = raw.message?.content;
217
+ if (content === undefined || content === null) return null;
218
+ if (typeof content === 'string') return Buffer.byteLength(content, 'utf8');
219
+ try {
220
+ return Buffer.byteLength(JSON.stringify(content), 'utf8');
221
+ } catch {
222
+ // Circular or otherwise unserialisable — unmeasurable, not zero.
223
+ return null;
224
+ }
225
+ }
226
+
183
227
  /** Subagent iff a non-empty top-level `agentId` OR `isSidechain === true` — see module doc comment. */
184
228
  function classifyBucket(raw: RawAssistantRecord): TranscriptBucket {
185
229
  const hasAgentId = typeof raw.agentId === 'string' && raw.agentId.length > 0;
@@ -285,6 +329,7 @@ export function scanTranscripts(opts: ScanOptions = {}): ScanResult {
285
329
  model: typeof raw.message.model === 'string' ? raw.message.model : 'unknown',
286
330
  timestamp: typeof raw.timestamp === 'string' ? raw.timestamp : null,
287
331
  usage: toTokenCounts(raw.message.usage),
332
+ content_bytes: opts.measureBytes === true ? measureContentBytes(raw) : null,
288
333
  });
289
334
  }
290
335
  }
@@ -298,6 +343,92 @@ export function scanTranscripts(opts: ScanOptions = {}): ScanResult {
298
343
  };
299
344
  }
300
345
 
346
+ /* -------------------------------------------------------------------------- */
347
+ /* Bytes — measured on the corpus, never assumed */
348
+ /* -------------------------------------------------------------------------- */
349
+
350
+ /**
351
+ * A bytes-per-token ratio, together with the two sums it was divided from and
352
+ * the population it was measured over.
353
+ *
354
+ * The sums travel WITH the ratio on purpose. A bare ratio is the shape that
355
+ * becomes a constant: somebody copies the number, it outlives the corpus that
356
+ * produced it, and the next reader cannot tell what it measured. Carrying
357
+ * `content_bytes` and `output_tokens` means the division is re-derivable from
358
+ * the record itself.
359
+ */
360
+ export interface BytesPerToken {
361
+ /** Always `'measured'` — this value is a division of two counted sums. */
362
+ basis: 'measured';
363
+ /** Summed `content_bytes` over the records that carried one. */
364
+ content_bytes: number;
365
+ /** Summed `output_tokens` over those same records. */
366
+ output_tokens: number;
367
+ /** `content_bytes / output_tokens`. */
368
+ bytes_per_token: number;
369
+ /** Records that contributed — `content_bytes !== null` and `output_tokens > 0`. */
370
+ records: number;
371
+ }
372
+
373
+ /**
374
+ * Measure a bytes-per-token ratio ON THE GIVEN RECORDS. Returns `null` when
375
+ * nothing in the corpus can supply one.
376
+ *
377
+ * `null` is the honest outcome and callers must render it as such rather than
378
+ * substituting a default: a ratio invented where none was measured is exactly
379
+ * the constant this function exists to make unnecessary. There is deliberately
380
+ * no fallback value anywhere in this module — the factor is a property of the
381
+ * corpus, and a corpus that cannot supply one has not supplied one.
382
+ */
383
+ export function measureBytesPerToken(records: readonly TranscriptRecord[]): BytesPerToken | null {
384
+ let bytes = 0;
385
+ let tokens = 0;
386
+ let n = 0;
387
+ for (const rec of records) {
388
+ if (rec.content_bytes === null || rec.usage.output_tokens <= 0) continue;
389
+ bytes += rec.content_bytes;
390
+ tokens += rec.usage.output_tokens;
391
+ n += 1;
392
+ }
393
+ if (n === 0 || tokens <= 0) return null;
394
+ return {
395
+ basis: 'measured',
396
+ content_bytes: bytes,
397
+ output_tokens: tokens,
398
+ bytes_per_token: bytes / tokens,
399
+ records: n,
400
+ };
401
+ }
402
+
403
+ /** A byte figure obtained by multiplying a token count by a measured ratio. */
404
+ export interface DerivedBytes {
405
+ /** Always `'derived'`. Never `'measured'` — nothing counted these bytes. */
406
+ basis: 'derived';
407
+ /** The token count that was multiplied. */
408
+ tokens: number;
409
+ /** The ratio used, carried so the arithmetic sits beside its result. */
410
+ factor: BytesPerToken;
411
+ /** `tokens * factor.bytes_per_token`, rounded to a whole byte. */
412
+ bytes: number;
413
+ }
414
+
415
+ /**
416
+ * Multiply a token count by a ratio measured on the same corpus.
417
+ *
418
+ * The result carries `basis: 'derived'` and the factor that produced it, so a
419
+ * derived figure and a counted one can never be read as the same number — the
420
+ * failure this whole path is shaped to prevent. `null` factor in, `null` out.
421
+ */
422
+ export function derivedBytes(tokens: number, factor: BytesPerToken | null): DerivedBytes | null {
423
+ if (factor === null) return null;
424
+ return {
425
+ basis: 'derived',
426
+ tokens,
427
+ factor,
428
+ bytes: Math.round(tokens * factor.bytes_per_token),
429
+ };
430
+ }
431
+
301
432
  function emptyAggregate(bucket: TranscriptBucket): BucketAggregate {
302
433
  return {
303
434
  bucket,
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Per-concern SLA measurement and the warn-only window's reporting.
3
+ *
4
+ * Extracted from `bench_hook_latency.ts` on 2026-10-01, not for tidiness: the
5
+ * additions that made `one-question-per-ask` measurable and gave the warn-only
6
+ * window a reader pushed that file from 1,361 lines to 1,583, i.e. 83 lines
7
+ * past the 1,500-line source ceiling, and `check_source_size_budget`'s ratchet
8
+ * asked for exactly this by name. Raising the baseline was the other option
9
+ * and is the one the ratchet exists to make visible rather than easy — a MOVE
10
+ * gives the budget back instead of spending it, which is the doctrine every
11
+ * entry in `gate-violation-baselines.json` records.
12
+ *
13
+ * It is also the better boundary on its own terms. Everything here answers one
14
+ * question — which blocking concern is bounded, by how much, and did this run
15
+ * cross it — and none of it is about measuring a hook SLOT, which is what the
16
+ * bench itself does.
17
+ *
18
+ * NOTHING HERE GATES. The bound these functions read is not armed anywhere:
19
+ * step 3.3 of `road-to-a-kernel-that-guards-its-plumbing` is its only consumer
20
+ * and is blocked on the window these functions report. A measurement that can
21
+ * red a build before its bar is validated is Risk 1 of that roadmap.
22
+ */
23
+ import * as fs from 'node:fs';
24
+
25
+ /**
26
+ * The subset of a per-concern row the window needs: a measurement and a bound.
27
+ *
28
+ * Declared here rather than imported so the dependency runs one way only —
29
+ * `bench_hook_latency.ts` imports this module and this module imports nothing
30
+ * back. `ConcernLatencyRow` satisfies it structurally, so the caller passes its
31
+ * own rows unchanged and no adapter exists to drift.
32
+ */
33
+ export interface BoundedConcern {
34
+ readonly concern: string;
35
+ /** p95 of the concern's own durations in microseconds, or null when untimed. */
36
+ readonly p95_us: number | null;
37
+ /** The registered SLA in milliseconds, or null when none is registered. */
38
+ readonly sla_ms: number | null;
39
+ /** The budget carried a value for this concern that is not a number. */
40
+ readonly sla_malformed: boolean;
41
+ }
42
+
43
+ /**
44
+ * An alternate tool shape for the synthetic payload.
45
+ *
46
+ * The default payload names `Read`, which is the right default for every gated
47
+ * number: it is the common non-matching case consumers pay on every tool call.
48
+ * But a concern filtered by the manifest's per-concern `tools:` key is SKIPPED
49
+ * in-process on a payload naming a tool it does not claim, so it never produces
50
+ * a sample and the per-concern report prints `not_measured` for it — correctly,
51
+ * and uselessly for a step that needs a bound.
52
+ *
53
+ * `one-question-per-ask` is the live instance: `severity: blocking`, filtered to
54
+ * `AskUserQuestion` and five aliases. It is the only blocking concern the
55
+ * default payload cannot reach, and the owning roadmap's Phase-3 blocker names
56
+ * the choice explicitly — "either a payload shaped to trigger it or an explicit
57
+ * decision that an unmeasured blocking concern keeps the current 30 s timeout".
58
+ * This is the first branch: measure it, so the decision is not forced by the
59
+ * harness.
60
+ *
61
+ * Measurement-only, like `--bundle` and `--payload-bytes`. It is reachable only
62
+ * from `concernSlaPass`, which gates nothing and writes no budget row — a probe
63
+ * payload must never produce a gated slot number, because the slot caps were
64
+ * derived against the `Read` shape and a different shape runs a different
65
+ * concern chain.
66
+ */
67
+ export interface ToolShape {
68
+ readonly tool_name: string;
69
+ readonly tool_input: Record<string, unknown>;
70
+ }
71
+
72
+ /**
73
+ * The probe that reaches `one-question-per-ask`.
74
+ *
75
+ * ONE question, not two: a single question is the ALLOW path, and the allow
76
+ * path is what an ordinary call costs. Two questions would measure the deny
77
+ * path — the rarer branch, and the one that builds a message — so a bound taken
78
+ * from it would describe the exception rather than the rule. Both run
79
+ * `countStructuredAskQuestions`, which is the concern's actual work.
80
+ */
81
+ export const ASK_PROBE: ToolShape = {
82
+ tool_name: 'AskUserQuestion',
83
+ tool_input: { questions: [{ question: 'bench probe' }] },
84
+ };
85
+
86
+ /**
87
+ * The one concern the ask probe exists to measure.
88
+ *
89
+ * Named rather than inferred from the `tools:` filter: a future concern added
90
+ * with an overlapping filter would be measured under a payload chosen for THIS
91
+ * one, and silently adopting it is the contamination above wearing a different
92
+ * shape.
93
+ */
94
+ export const PROBE_TARGET = 'one-question-per-ask';
95
+
96
+ /**
97
+ * Append only `target`'s samples from the probe sink onto the main sink.
98
+ *
99
+ * The probe sink is read and then left in place; it is a temp-dir file the
100
+ * caller owns. A read failure is not fatal — the probe is an observation, and
101
+ * a bench that dies because one auxiliary sink was unreadable converts a
102
+ * measurement into an outage, which is the posture `readConcernTimings`
103
+ * already takes one level down.
104
+ */
105
+ export function mergeProbeSamples(probeSink: string, sinkPath: string, target: string): void {
106
+ let text: string;
107
+ try {
108
+ text = fs.readFileSync(probeSink, 'utf-8');
109
+ } catch {
110
+ return;
111
+ }
112
+ const keep: string[] = [];
113
+ for (const line of text.split('\n')) {
114
+ const trimmed = line.trim();
115
+ if (trimmed === '') continue;
116
+ let row: { concern?: unknown };
117
+ try {
118
+ row = JSON.parse(trimmed) as typeof row;
119
+ } catch {
120
+ continue;
121
+ }
122
+ if (row.concern === target) keep.push(trimmed);
123
+ }
124
+ if (keep.length === 0) return;
125
+ try {
126
+ fs.appendFileSync(sinkPath, `${keep.join('\n')}\n`, 'utf-8');
127
+ } catch {
128
+ /* the sink is an observation aid; losing it must not fail the bench */
129
+ }
130
+ }
131
+
132
+ /**
133
+ * The warn-only window, made legible — observe-only, gating nothing.
134
+ *
135
+ * Step 3.3 derives a per-concern timeout of `sla_ms x 3` and its own text says
136
+ * "warn-only for the first measured window, then deny". A window nothing
137
+ * observes is not a window: with `sla_ms` registered and no reader, the flip
138
+ * would be taken on the absence of a complaint rather than on a reading.
139
+ *
140
+ * So this is the instrument. Every bench run — local or CI, gated or not —
141
+ * prints each blocking concern's measured p95 beside its registered bound and
142
+ * names any concern that crossed it. The window is then a span of runs a human
143
+ * can read, and the flip has an evidence base that exists before it is taken.
144
+ *
145
+ * It lives in the measurement harness and NOT in the dispatcher deliberately.
146
+ * A runtime warn would be a dispatcher change shipped as part of a step that is
147
+ * blocked on the very readings it would produce, and Risk 1 of the owning
148
+ * roadmap is precisely about acting on a bound before it is validated.
149
+ *
150
+ * A concern with no registered SLA, or no measurement, is NOT an overrun — it
151
+ * is an unknown, and the two must not collapse. Returning it as "fine" would
152
+ * let an unregistered concern read as a clean window.
153
+ */
154
+ export interface SlaOverrun {
155
+ concern: string;
156
+ p95_us: number;
157
+ /** `sla_ms x 3`, in microseconds — the bound step 3.3 would apply. */
158
+ bound_us: number;
159
+ }
160
+
161
+ /**
162
+ * What a run can actually say about the window — three counts, never one.
163
+ *
164
+ * `registered` is how many concerns carry a bound. `usable` is how many of
165
+ * those this run could compare against it. The gap is what an earlier version
166
+ * of the summary line silently dropped: it printed "none over" off `registered`,
167
+ * so a run that measured none of its bounded concerns read exactly like a run
168
+ * that measured all of them and found nothing. That is the same
169
+ * unknown-collapsed-into-fine failure `slaOverruns` refuses per row, one level
170
+ * up at the summary.
171
+ */
172
+ export interface WindowState {
173
+ registered: number;
174
+ usable: number;
175
+ /** Registered but not comparable this run — unmeasured or malformed. */
176
+ unusable: number;
177
+ }
178
+
179
+ export function windowState(rows: readonly BoundedConcern[]): WindowState {
180
+ let registered = 0;
181
+ let usable = 0;
182
+ for (const row of rows) {
183
+ if (row.sla_ms === null && !row.sla_malformed) continue;
184
+ registered += 1;
185
+ if (!row.sla_malformed && row.sla_ms !== null && row.p95_us !== null) usable += 1;
186
+ }
187
+ return { registered, usable, unusable: registered - usable };
188
+ }
189
+
190
+ export function slaOverruns(rows: readonly BoundedConcern[]): SlaOverrun[] {
191
+ const out: SlaOverrun[] = [];
192
+ for (const row of rows) {
193
+ if (row.sla_ms === null || row.sla_malformed || row.p95_us === null) continue;
194
+ const bound_us = row.sla_ms * 3 * 1000;
195
+ if (row.p95_us > bound_us) {
196
+ out.push({ concern: row.concern, p95_us: row.p95_us, bound_us });
197
+ }
198
+ }
199
+ return out;
200
+ }
@@ -73,6 +73,120 @@ export interface ForgeReading {
73
73
  readonly deployRestricted: boolean | null;
74
74
  }
75
75
 
76
+ /**
77
+ * The all-null reading: nothing was queried.
78
+ *
79
+ * Lives here, beside {@link ForgeReading}, rather than in the `doctor` caller
80
+ * that first needed it — `_lib/forge_reader.ts` returns it on every failure
81
+ * path, and a second hand-written all-null literal is exactly the drift that
82
+ * makes "offline output is unchanged" an assertion instead of a fact.
83
+ *
84
+ * It MOVED from `_cli/doctor_execution.ts`, which no longer exports it in any
85
+ * form. Nothing broke, because nothing outside that file imported it — but an
86
+ * earlier draft of this comment claimed a re-export kept every caller in place,
87
+ * and no such shim was ever written. Corrected rather than quietly deleted: a
88
+ * comment describing a compatibility layer that does not exist is the next
89
+ * reader's false premise.
90
+ */
91
+ export const UNREAD_FORGE: ForgeReading = {
92
+ rulesets: null,
93
+ defaultBranch: null,
94
+ allowAutoMerge: null,
95
+ deployRestricted: null,
96
+ };
97
+
98
+ /**
99
+ * The two fields of an environment this row reads, as
100
+ * `GET repos/{owner}/{repo}/environments` returns them.
101
+ *
102
+ * A NARROW PROJECTION, not the full payload: the real response also carries
103
+ * `id`, `protection_rules`, `can_admins_bypass`, timestamps and more. Declared
104
+ * this way on purpose — a caller passes the real object and structural typing
105
+ * accepts the superset, while the interface states exactly which fields this
106
+ * decision depends on. Calling it "the shape" would overstate it.
107
+ *
108
+ * `null` is GitHub's encoding for *no restriction at all* — every branch may
109
+ * deploy. It is therefore the one shape that refutes the row.
110
+ */
111
+ export interface EnvironmentPolicy {
112
+ readonly name: string;
113
+ readonly deployment_branch_policy: {
114
+ readonly protected_branches?: boolean;
115
+ readonly custom_branch_policies?: boolean;
116
+ } | null;
117
+ }
118
+
119
+ /**
120
+ * Derive `deployRestricted` from the environments payload.
121
+ *
122
+ * **Both restriction mechanisms count, and reading only one is the defect this
123
+ * function exists to close.** GitHub restricts deployment branches two ways:
124
+ * `protected_branches` (whatever the forge currently treats as protected) and
125
+ * `custom_branch_policies` (an explicit named list). A checker that reads only
126
+ * the first reports "accepts a deployment from any branch" for an environment
127
+ * pinned to exactly one named branch — which is the opposite of what the
128
+ * payload says, and is precisely the misreading recorded against this
129
+ * repository on 2026-09-13: `github-pages` carried
130
+ * `custom_branch_policies: true` with a single policy, `main`, and was written
131
+ * up as unrestricted because only `protected_branches` was consulted.
132
+ *
133
+ * The named-list mechanism is also the STRICTER of the two here, which is why
134
+ * the row is satisfied without changing the forge: `protected_branches` widens
135
+ * with the protected set, while a custom list names one branch and stays there.
136
+ * On a ruleset-protected repository it is additionally the safer mechanism —
137
+ * `protected_branches` resolves against a notion of protection whose classic
138
+ * endpoint 404s here (see the module header), so switching to it would trade a
139
+ * precise restriction for one whose evaluation is exactly the ambiguity the
140
+ * `forge-protection-settings` re-scope forbids relying on.
141
+ *
142
+ * **An empty environment set returns `null`, not `true`.** The first draft
143
+ * returned `true` on the reasoning that with no environment there is no
144
+ * deployment surface to leave open. That is sound about a *confirmed* empty
145
+ * set and wrong about the value actually reaching this function: an empty
146
+ * array is what a failed fetch, a truncated page, or a caller that never
147
+ * queried also produces, and collapsing those into `satisfied` is exactly the
148
+ * quiet false-positive the three-state row exists to prevent. `null` maps to
149
+ * `unread`, which is the state this module already reserves for a value nobody
150
+ * established. A caller that has genuinely confirmed zero environments can say
151
+ * so by passing `true` itself; it cannot be inferred from the array alone.
152
+ *
153
+ * **Wildcard patterns.** A custom policy's NAMES live behind a second call
154
+ * (`GET .../environments/{name}/deployment-branch-policies`) and accept globs,
155
+ * so `custom_branch_policies: true` alone cannot distinguish a policy pinned to
156
+ * `main` from one whose pattern is `*`. Pass `patternsByEnv` when those names
157
+ * have been read: an environment whose patterns admit everything is reported
158
+ * unrestricted. Omit it and the flag is trusted — the narrower guarantee was
159
+ * not checked, which is stated here rather than left for a reader to discover.
160
+ *
161
+ * **A confirmed-EMPTY pattern list also yields `false`, and it is NOT the same
162
+ * case as a wildcard.** With `custom_branch_policies: true` and zero named
163
+ * patterns nothing matches, so nothing can deploy — strictly the most
164
+ * restrictive posture there is, and the exact opposite of `*`. Both land on
165
+ * `false` deliberately, because this row feeds an ACTION line rather than a
166
+ * permission: an environment configured to accept no deployment at all is a
167
+ * misconfiguration worth a human look, and `false` surfaces it where `true`
168
+ * would silently bless it. The direction is the safe one — it can under-report
169
+ * satisfaction, never over-report it. Written down because the two states are
170
+ * genuinely opposite, and a reader deriving the behaviour from the expression
171
+ * alone would reasonably take it for a bug.
172
+ */
173
+ export function deployRestrictedFrom(
174
+ environments: readonly EnvironmentPolicy[],
175
+ patternsByEnv?: Readonly<Record<string, readonly string[]>>,
176
+ ): boolean | null {
177
+ if (environments.length === 0) return null;
178
+ return environments.every((env) => {
179
+ const policy = env.deployment_branch_policy;
180
+ if (policy === null || policy === undefined) return false;
181
+ if (policy.protected_branches === true) return true;
182
+ if (policy.custom_branch_policies !== true) return false;
183
+ const patterns = patternsByEnv?.[env.name];
184
+ if (patterns === undefined) return true;
185
+ // A policy admitting every ref restricts nothing, whatever the flag says.
186
+ return patterns.length > 0 && !patterns.some((p) => p === '*' || p === '**');
187
+ });
188
+ }
189
+
76
190
  const SOURCE = {
77
191
  rulesets: 'GET repos/{owner}/{repo}/rulesets + GET .../rulesets/{id}',
78
192
  repo: 'GET repos/{owner}/{repo} (allow_auto_merge)',