@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,314 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse guard: refuse a hand edit to a hook BUILD OUTPUT.
4
+ *
5
+ * road-to-a-kernel-that-guards-its-plumbing 1.2 — the other half of a
6
+ * deliberate split, and the split is the whole design:
7
+ *
8
+ * · plumbing SOURCES (`hook_manifest.yaml`, `host_lowering.yaml`, the
9
+ * `*-dispatcher.sh` trampolines, both hook budget files) are edited
10
+ * legitimately all the time. They carry a RECORD — the ADR-268 § 4
11
+ * ratification gate, path set extended by step 1.1 — not a deny.
12
+ * · plumbing BUILD OUTPUTS have no legitimate hand edit at all. They carry a
13
+ * DENY, which is this file.
14
+ *
15
+ * One mechanism per file class. A record for a file whose every hand edit is a
16
+ * mistake would be a record of a mistake; a deny on a file people edit on
17
+ * purpose would be a wedge.
18
+ *
19
+ * WHAT IS REFUSED.
20
+ *
21
+ * - `dist/hooks/dispatch.js` — the esbuild bundle (`npm run build:hooks`).
22
+ * Every concern the dispatcher runs is inlined here. A hand edit survives
23
+ * until the next build, reaches every dispatch in the meantime, and is
24
+ * invisible in a source review.
25
+ * - `hooks/hooks.json` — the host binding file, written by `condense.ts` via
26
+ * `task sync`. A hand edit here silently unbinds a guard.
27
+ *
28
+ * Both are matched under any parent directory, because a consumer install
29
+ * carries them under its own project root rather than under this repository's.
30
+ *
31
+ * WHAT STILL PASSES, and why that is not a hole.
32
+ *
33
+ * `npm run build:hooks` and `task sync` are the legitimate writers, and both
34
+ * reach these files through a build tool rather than through a shell write
35
+ * shape: the command string carries no redirect into the path, no in-place
36
+ * `sed`, no `tee`/`mv`/`cp` naming it. `shellWriteTarget` matches shapes, not
37
+ * effects, so the builds pass and a hand edit does not. Stated here because it
38
+ * is the property that makes this guard safe to bind `fail_closed`.
39
+ *
40
+ * KNOWN OPEN, stated rather than implied.
41
+ *
42
+ * A write whose target never appears as a path-shaped token — built inside an
43
+ * interpreter's own argument, as in `python3 -c "open('hooks/hooks.json','w')…"`
44
+ * — carries none of the recognised shapes and is NOT detected. This is the same
45
+ * residual `block_kernel_rule_writes.ts` declares for itself and the same
46
+ * boundary: a guard, not write mediation. The honest claim is that the cheap
47
+ * and the accidental paths are closed, not that the determined one is.
48
+ *
49
+ * "Through an interpreter" does not by itself mean undetected, and the tests
50
+ * pin both directions: `sh -c 'printf x > hooks/hooks.json'` IS refused,
51
+ * because the redirect survives the tokenizer inside the quoted argument.
52
+ *
53
+ * MANIFEST DECLARATION.
54
+ *
55
+ * `fail_closed: true`, `severity: blocking`. The fail-closed guarantee is
56
+ * about a DETECTED write: a malformed envelope with no target at all allows,
57
+ * exactly as the kernel guard does, because a guard that blocks what it cannot
58
+ * read turns a shape defect into a wedged session.
59
+ *
60
+ * No agent-accessible kill switch — deliberately, and it is also what
61
+ * `lint_deny_text` (step 1.3) requires of every blocking concern's deny text.
62
+ * The legitimate change is to rebuild the file from its source.
63
+ *
64
+ * Exit codes (docs/contracts/hook-architecture-v1.md):
65
+ * 0 — allow
66
+ * 1 — block
67
+ */
68
+
69
+ import * as fs from 'node:fs';
70
+ import * as path from 'node:path';
71
+ import { fileURLToPath, pathToFileURL } from 'node:url';
72
+
73
+ import { shellWriteTarget } from '../_lib/shell_write_shapes.js';
74
+ import { EDIT_TOOLS } from '../minimal_safe_diff_hook.js';
75
+ import { COMMAND_TOOLS } from '../before_complete_hook.js';
76
+ import { readHookStdin } from './hook_stdin.js';
77
+
78
+ const _HERE = fileURLToPath(import.meta.url);
79
+
80
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
81
+ type JsonObject = { [k: string]: JsonValue };
82
+
83
+ function _isObject(v: JsonValue | undefined): v is JsonObject {
84
+ return v !== null && v !== undefined && typeof v === 'object' && !Array.isArray(v);
85
+ }
86
+
87
+ /**
88
+ * The generated plumbing artifacts, as trailing path segments.
89
+ *
90
+ * A trailing match rather than a repo-relative one because the same two files
91
+ * exist under every consumer project root and under this repository's, and a
92
+ * guard anchored to one tree would be silent in the other.
93
+ */
94
+ export const PLUMBING_BUILD_OUTPUTS: readonly string[] = [
95
+ 'dist/hooks/dispatch.js',
96
+ 'hooks/hooks.json',
97
+ ];
98
+
99
+ /**
100
+ * Where each output comes from, printed in the deny so the reader is told what
101
+ * to run instead of only what not to do.
102
+ */
103
+ const REGENERATED_BY: Readonly<Record<string, string>> = {
104
+ 'dist/hooks/dispatch.js': 'npm run build:hooks',
105
+ 'hooks/hooks.json': 'task sync',
106
+ };
107
+
108
+ // Keys across platforms that carry the tool's target file path.
109
+ const _PATH_KEYS: readonly string[] = [
110
+ 'file_path',
111
+ 'path',
112
+ 'target_file',
113
+ 'filename',
114
+ 'filePath',
115
+ 'notebook_path',
116
+ ];
117
+
118
+ /** Normalize separators; strip a leading "./" run so segment checks are robust. */
119
+ function _normalize(p: string): string {
120
+ return p.replace(/\\/g, '/').replace(/^(\.\/)+/, '');
121
+ }
122
+
123
+ /**
124
+ * The build output `filePath` names, or `null`.
125
+ *
126
+ * Two matching rules, because the guard has two kinds of input and only one of
127
+ * them carries enough information to be exact:
128
+ *
129
+ * · a RELATIVE path is matched EXACTLY, after a leading `./` or `../` run is
130
+ * stripped. `dist/hooks/dispatch.js` and `./hooks/hooks.json` are the
131
+ * governed files; `docs/hooks/hooks.json` is a different file that happens
132
+ * to end the same way, and refusing it would be an over-block on a file
133
+ * nothing generates.
134
+ * · an ABSOLUTE path is matched on its trailing segments, because the guard
135
+ * does not know where the project root is and a host that sends absolute
136
+ * paths — Claude Code sends every `file_path` absolute — would otherwise
137
+ * escape it entirely. A fail-open on the main path is the worse of the two
138
+ * errors.
139
+ *
140
+ * The residual is named rather than hidden: an absolute path ending in
141
+ * `…/hooks/hooks.json` under an unrelated parent is refused too. That is the
142
+ * conservative direction for a two-file guard, and the fix if it ever bites is
143
+ * to relativise against the envelope's `workspace_root`.
144
+ *
145
+ * A bare basename (`dispatch.js`) never matches under either rule — it is not
146
+ * evidence that the governed file is the target.
147
+ */
148
+ export function targets_plumbing_output(filePath: string): string | null {
149
+ if (!filePath) {
150
+ return null;
151
+ }
152
+ const normalized = _normalize(filePath);
153
+ const absolute = normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized);
154
+ const segments = normalized.split('/').filter((s) => s.length > 0 && s !== '.');
155
+ const relevant = absolute ? segments : segments.filter((s) => s !== '..');
156
+
157
+ for (const output of PLUMBING_BUILD_OUTPUTS) {
158
+ const want = output.split('/');
159
+ if (absolute) {
160
+ if (relevant.length < want.length) {
161
+ continue;
162
+ }
163
+ const tail = relevant.slice(relevant.length - want.length);
164
+ if (tail.every((s, i) => s === want[i])) {
165
+ return output;
166
+ }
167
+ continue;
168
+ }
169
+ if (relevant.length === want.length && relevant.every((s, i) => s === want[i])) {
170
+ return output;
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+
176
+ /** Best-effort (toolName, candidate target paths) read off a PreToolUse envelope. */
177
+ function _extract(envelope: JsonObject): { tool: string; paths: string[] } {
178
+ const payload = _isObject(envelope['payload']) ? envelope['payload'] : envelope;
179
+ const nameVal =
180
+ payload['tool_name'] ??
181
+ payload['toolName'] ??
182
+ payload['tool'] ??
183
+ envelope['tool_name'] ??
184
+ envelope['tool'];
185
+ const tool = typeof nameVal === 'string' ? nameVal : '';
186
+
187
+ const ti = _isObject(payload['tool_input'])
188
+ ? payload['tool_input']
189
+ : _isObject(envelope['tool_input'])
190
+ ? envelope['tool_input']
191
+ : null;
192
+
193
+ const paths: string[] = [];
194
+ if (ti !== null) {
195
+ for (const key of _PATH_KEYS) {
196
+ const v = ti[key];
197
+ if (typeof v === 'string' && v) {
198
+ paths.push(v);
199
+ }
200
+ }
201
+ }
202
+ return { tool, paths };
203
+ }
204
+
205
+ /** Best-effort read of a shell command off a PreToolUse envelope. */
206
+ function _extract_command(envelope: JsonObject): string | null {
207
+ const payload = _isObject(envelope['payload']) ? envelope['payload'] : envelope;
208
+ const ti = _isObject(payload['tool_input'])
209
+ ? payload['tool_input']
210
+ : _isObject(envelope['tool_input'])
211
+ ? envelope['tool_input']
212
+ : null;
213
+ for (const src of [ti, payload]) {
214
+ if (src === null) {
215
+ continue;
216
+ }
217
+ const v = src['command'];
218
+ if (typeof v === 'string' && v) {
219
+ return v;
220
+ }
221
+ }
222
+ return null;
223
+ }
224
+
225
+ /** Return (blocked, reason) for one PreToolUse envelope. Pure — no I/O. */
226
+ export function check_envelope(envelope: JsonObject): [boolean, string] {
227
+ const { tool, paths } = _extract(envelope);
228
+ if (!tool) {
229
+ return [false, ''];
230
+ }
231
+ const deny = (output: string): [boolean, string] => [
232
+ true,
233
+ `${output} is a build output, not a source — regenerate it with ` +
234
+ `\`${REGENERATED_BY[output] ?? 'the build that writes it'}\`. ` +
235
+ 'See docs/contracts/hook-architecture-v1.md § Plumbing.',
236
+ ];
237
+ if (EDIT_TOOLS.has(tool)) {
238
+ for (const p of paths) {
239
+ const output = targets_plumbing_output(p);
240
+ if (output !== null) {
241
+ return deny(output);
242
+ }
243
+ }
244
+ return [false, ''];
245
+ }
246
+ if (COMMAND_TOOLS.has(tool)) {
247
+ const cmd = _extract_command(envelope);
248
+ if (cmd !== null) {
249
+ const output = shellWriteTarget(cmd, targets_plumbing_output);
250
+ if (output !== null) {
251
+ return deny(output);
252
+ }
253
+ }
254
+ }
255
+ return [false, ''];
256
+ }
257
+
258
+ function _asObject(v: JsonValue | undefined): JsonObject | null {
259
+ return _isObject(v) ? v : null;
260
+ }
261
+
262
+ export function main(): number {
263
+ const raw = readHookStdin();
264
+ let envelope: JsonObject = {};
265
+ if (raw.trim()) {
266
+ try {
267
+ const obj = JSON.parse(raw) as JsonValue;
268
+ envelope = _asObject(obj) ?? {};
269
+ } catch {
270
+ // Malformed envelope — no target to decide about; allow. The
271
+ // fail-closed guarantee applies to a DETECTED write.
272
+ return 0;
273
+ }
274
+ }
275
+
276
+ const [blocked, reason] = check_envelope(envelope);
277
+ if (blocked) {
278
+ process.stderr.write(
279
+ `block-plumbing-writes: BLOCKED — ${reason}\n` +
280
+ ' This file is generated. Edit the source it is built from, then rebuild.\n' +
281
+ ' Contract: docs/contracts/hook-architecture-v1.md\n',
282
+ );
283
+ return 1; // EXIT_BLOCK
284
+ }
285
+
286
+ return 0; // EXIT_ALLOW
287
+ }
288
+
289
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
290
+ // every module shares the bundle's `import.meta.url`.
291
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
292
+ function _isCliEntry(): boolean {
293
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
294
+ return false;
295
+ }
296
+ if (process.argv[1] === undefined) {
297
+ return false;
298
+ }
299
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
300
+ if (import.meta.url === argvUrl) {
301
+ return true;
302
+ }
303
+ try {
304
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
305
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
306
+ return here === argv;
307
+ } catch {
308
+ return false;
309
+ }
310
+ }
311
+
312
+ if (_isCliEntry() || (typeof __AGENT_CONFIG_BUNDLE__ === 'undefined' && process.argv[1] === _HERE)) {
313
+ process.exitCode = main();
314
+ }
@@ -0,0 +1,291 @@
1
+ import { createHash } from 'node:crypto';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+
6
+ import { fix_hint, log_dispatch_issue } from './dispatch_issues.js';
7
+ import { EXIT_BLOCK } from './exit_codes.js';
8
+ import { emitFor } from './host_semantics.js';
9
+
10
+ /**
11
+ * bundle_integrity — does the dispatcher's own bundle still match what the
12
+ * build produced?
13
+ *
14
+ * Step 3.1 of `road-to-a-kernel-that-guards-its-plumbing`. `dist/hooks/
15
+ * dispatch.js` is a generated file with no legitimate hand edit, and until
16
+ * this landed an edit to it survived until the next build, reached every
17
+ * dispatch meanwhile, and was invisible in a source review.
18
+ * `check_hook_bundle_content` catches that in CI and `block_plumbing_writes`
19
+ * refuses the edit at tool-call time; neither of them is looking at the bundle
20
+ * the process is EXECUTING at the moment it runs a blocking guard.
21
+ *
22
+ * Three verdicts, and the gap between two of them is the whole design:
23
+ *
24
+ * - `ok` — the bundle hashes to the sidecar.
25
+ * - `mismatch` — it does not. The caller refuses on a slot carrying a blocking
26
+ * concern and warns otherwise.
27
+ * - `unverifiable` — there is no sidecar, or it cannot be read or parsed.
28
+ *
29
+ * `unverifiable` NEVER refuses, and that is a decision rather than an
30
+ * oversight. A consumer install from a package built before the sidecar
31
+ * existed has no sidecar; so does any tree where `dist/` was produced by
32
+ * something other than `npm run build:hooks`. Refusing there would convert a
33
+ * missing optional artifact into a total outage of the agent's tooling, which
34
+ * is the failure `hook-architecture-v1.md` already rules on one level up when
35
+ * it fixes a MISSING dispatcher as a silent allow even for a `fail_closed`
36
+ * concern. An absent control is not a tripped control.
37
+ *
38
+ * This is not a signature. Anyone who can rewrite the bundle can rewrite the
39
+ * sidecar beside it, and saying otherwise would be the coverage inflation this
40
+ * roadmap exists to remove. What it catches is the hand edit and the partial
41
+ * write — the cases where somebody changed one file and not the other.
42
+ */
43
+
44
+ /** What a verification produced. */
45
+ export type IntegrityVerdict =
46
+ | { state: 'ok'; sha: string }
47
+ | { state: 'mismatch'; expected: string; actual: string }
48
+ | { state: 'unverifiable'; reason: string };
49
+
50
+ /**
51
+ * The cheap identity of a file — what a later dispatch compares instead of
52
+ * re-hashing 1.5 MB.
53
+ *
54
+ * Size AND mtime, never mtime alone: a rebuild that produces a byte-identical
55
+ * bundle bumps mtime and would force a needless re-hash (harmless), while a
56
+ * same-size edit at the same mtime is the one pair this cannot see. That pair
57
+ * needs a deliberate timestamp forge, which is a strictly harder act than the
58
+ * hand edit this defends against, and the honest statement is that the cache
59
+ * trades that case for 0.0101 ms per dispatch against 0.470 ms.
60
+ *
61
+ * 0.0101 ms is the WHOLE cached path measured — reading the stamp back plus
62
+ * the stat — not the stat alone, which is 0.0010 ms. The first version of this
63
+ * comment quoted the stat, which described one of the two syscalls the
64
+ * dispatcher actually pays.
65
+ */
66
+ export interface BundleStamp {
67
+ sha: string;
68
+ size: number;
69
+ mtimeMs: number;
70
+ }
71
+
72
+ /** Read the expected digest out of a `shasum -a 256` style sidecar. */
73
+ export function parseSidecar(text: string): string | null {
74
+ // `<64 hex> <filename>` — the two-space form `shasum` emits. Tolerant of
75
+ // any run of whitespace and of a trailing newline, because an operator who
76
+ // regenerates the file by hand should not be punished for one space.
77
+ const m = /^([0-9a-f]{64})\s/u.exec(text.trim());
78
+ return m === null ? null : (m[1] as string);
79
+ }
80
+
81
+ /** Where the sidecar for a given bundle lives. */
82
+ export function sidecarPathFor(bundlePath: string): string {
83
+ return bundlePath.replace(/\.js$/u, '') + '.sha256';
84
+ }
85
+
86
+ /** SHA-256 of a file's bytes, or null when it cannot be read. */
87
+ export function hashFile(p: string): string | null {
88
+ try {
89
+ return createHash('sha256').update(fs.readFileSync(p)).digest('hex');
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Compare a bundle against its sidecar.
97
+ *
98
+ * Pure apart from the two reads, so the caller's caching and the verdict logic
99
+ * can be tested without a dispatch.
100
+ */
101
+ export function verifyBundle(bundlePath: string): IntegrityVerdict {
102
+ const sidecar = sidecarPathFor(bundlePath);
103
+ let expected: string | null;
104
+ try {
105
+ expected = parseSidecar(fs.readFileSync(sidecar, 'utf-8'));
106
+ } catch {
107
+ return { state: 'unverifiable', reason: `no sidecar at ${path.basename(sidecar)}` };
108
+ }
109
+ if (expected === null) {
110
+ return { state: 'unverifiable', reason: `sidecar at ${path.basename(sidecar)} is malformed` };
111
+ }
112
+ const actual = hashFile(bundlePath);
113
+ if (actual === null) {
114
+ return { state: 'unverifiable', reason: `bundle at ${path.basename(bundlePath)} is unreadable` };
115
+ }
116
+ return actual === expected ? { state: 'ok', sha: actual } : { state: 'mismatch', expected, actual };
117
+ }
118
+
119
+ /** The cheap identity of the bundle right now, or null when it is not there. */
120
+ export function stampOf(bundlePath: string, sha: string): BundleStamp | null {
121
+ try {
122
+ const st = fs.statSync(bundlePath);
123
+ return { sha, size: st.size, mtimeMs: Math.floor(st.mtimeMs) };
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Is a cached stamp still describing the file on disk?
131
+ *
132
+ * Returns false whenever the answer is not a confident yes — an unreadable
133
+ * file re-verifies rather than reusing a stamp that may describe something
134
+ * else.
135
+ */
136
+ export function stampMatches(bundlePath: string, cached: BundleStamp | null | undefined): boolean {
137
+ if (cached === null || cached === undefined) return false;
138
+ try {
139
+ const st = fs.statSync(bundlePath);
140
+ return st.size === cached.size && Math.floor(st.mtimeMs) === cached.mtimeMs;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Where the once-per-bundle stamp is cached.
148
+ *
149
+ * The step wrote "caches `{sha, size, mtime}` in session state"; this uses the
150
+ * OS temp dir instead, and the deviation is deliberate. Session state under
151
+ * `agents/runtime/state/` is not written in replay mode — which is the mode
152
+ * `bench_hook_latency` runs in — so a session-state cache would be skipped in
153
+ * exactly the harness that has to measure the cached path, and the step's own
154
+ * verify line asks for `bench_hook_latency --gate` green with a per-dispatch
155
+ * cost of a stat call. It also means a host that never emits `session_start`
156
+ * would get no cache and no check at all.
157
+ *
158
+ * A temp-dir cache is content-addressed by the bundle's own path, so it needs
159
+ * no session identity, self-heals on the first dispatch of any session, works
160
+ * in a consumer install, and degrades to "verify every time" when the temp dir
161
+ * is unwritable — slower, never wrong.
162
+ */
163
+ export function stampCachePath(bundlePath: string, tmpDir: string): string {
164
+ const key = createHash('sha256').update(bundlePath).digest('hex').slice(0, 16);
165
+ return path.join(tmpDir, `agent-config-bundle-integrity-${key}.json`);
166
+ }
167
+
168
+ /** Read a cached stamp, or null when there is not a usable one. */
169
+ export function readStamp(cachePath: string): BundleStamp | null {
170
+ try {
171
+ const raw = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as Partial<BundleStamp>;
172
+ if (
173
+ typeof raw.sha === 'string' &&
174
+ typeof raw.size === 'number' &&
175
+ typeof raw.mtimeMs === 'number'
176
+ ) {
177
+ return { sha: raw.sha, size: raw.size, mtimeMs: raw.mtimeMs };
178
+ }
179
+ } catch {
180
+ // Absent, unreadable or malformed — all three mean "no cache", which
181
+ // costs a re-verification and never a wrong answer.
182
+ }
183
+ return null;
184
+ }
185
+
186
+ /** Persist a stamp. Failure is silent: a cache that cannot be written is a cache miss. */
187
+ export function writeStamp(cachePath: string, stamp: BundleStamp): void {
188
+ try {
189
+ fs.writeFileSync(cachePath, JSON.stringify(stamp), 'utf-8');
190
+ } catch {
191
+ /* a cache is an optimisation, never a control */
192
+ }
193
+ }
194
+
195
+ /**
196
+ * The whole check, cache included — hash once per bundle identity, stat after.
197
+ *
198
+ * Returns the verdict plus whether this call had to do the expensive part, so
199
+ * a caller (and a test) can assert the cache actually caches rather than
200
+ * trusting that it does.
201
+ */
202
+ export function verifyBundleCached(
203
+ bundlePath: string,
204
+ tmpDir: string,
205
+ ): { verdict: IntegrityVerdict; hashed: boolean } {
206
+ const cachePath = stampCachePath(bundlePath, tmpDir);
207
+ const cached = readStamp(cachePath);
208
+ if (stampMatches(bundlePath, cached) && cached !== null) {
209
+ return { verdict: { state: 'ok', sha: cached.sha }, hashed: false };
210
+ }
211
+ const verdict = verifyBundle(bundlePath);
212
+ if (verdict.state === 'ok') {
213
+ const stamp = stampOf(bundlePath, verdict.sha);
214
+ if (stamp !== null) writeStamp(cachePath, stamp);
215
+ }
216
+ // A `mismatch` is deliberately NOT cached. Caching it would be caching a
217
+ // refusal, and the next legitimate rebuild has to be able to clear it
218
+ // without anyone knowing a cache existed.
219
+ return { verdict, hashed: true };
220
+ }
221
+
222
+ /** What the dispatcher should do about the verdict it just got. */
223
+ export type IntegrityAction =
224
+ | { action: 'allow' }
225
+ | { action: 'warn'; detail: string }
226
+ | { action: 'block'; detail: string };
227
+
228
+ /**
229
+ * Turn a verdict into a dispatch decision.
230
+ *
231
+ * Separated from the dispatcher so the SEVERITY half of the rule is testable
232
+ * without a dispatch — and because `dispatch_hook.ts` sits against the
233
+ * 1,500-line source budget, which refused the inline version.
234
+ *
235
+ * `hasBlockingConcern` is the discriminator rather than the slot name: a slot
236
+ * carrying only advisory concerns could not have refused anything anyway, so
237
+ * blocking there would let a stale digest wedge a chain with no authority to
238
+ * begin with.
239
+ */
240
+ export function integrityAction(
241
+ verdict: IntegrityVerdict,
242
+ hasBlockingConcern: boolean,
243
+ ): IntegrityAction {
244
+ if (verdict.state !== 'mismatch') {
245
+ // `ok` and `unverifiable` are both allow, and they are both SILENT.
246
+ // See the header for why an absent sidecar is not a tripped control.
247
+ return { action: 'allow' };
248
+ }
249
+ const detail =
250
+ `dist/hooks/dispatch.js does not match its build digest ` +
251
+ `(expected ${verdict.expected.slice(0, 12)}…, got ${verdict.actual.slice(0, 12)}…). ` +
252
+ `The bundle is generated by \`npm run build:hooks\` and has no legitimate ` +
253
+ `hand edit; rebuild to clear this.`;
254
+ return hasBlockingConcern ? { action: 'block', detail } : { action: 'warn', detail };
255
+ }
256
+
257
+ /**
258
+ * The whole gate, side effects included — verify, log, and decide the exit.
259
+ *
260
+ * Returns an exit code when the dispatch must stop, `null` when it continues.
261
+ * It lives here rather than inline in `dispatch_hook.ts` for two reasons, and
262
+ * the second is the load-bearing one: that file sits against the 1,500-line
263
+ * source budget and `check_source_size_budget` refused the inline version, and
264
+ * a gate whose logging and emission are inside the dispatcher can only be
265
+ * tested by running a dispatch.
266
+ */
267
+ export function runBundleIntegrityGate(
268
+ bundlePath: string,
269
+ platform: string,
270
+ event: string,
271
+ hasBlockingConcern: boolean,
272
+ tmpDir: string = os.tmpdir(),
273
+ ): number | null {
274
+ const { verdict } = verifyBundleCached(bundlePath, tmpDir);
275
+ const act = integrityAction(verdict, hasBlockingConcern);
276
+ if (act.action === 'allow') return null;
277
+ const line = `plumbing-integrity: ${act.detail}`;
278
+ try {
279
+ log_dispatch_issue(process.cwd(), 'dispatcher', 'execution_failed', line, fix_hint());
280
+ } catch {
281
+ /* observability never breaks the hook */
282
+ }
283
+ if (act.action === 'warn') {
284
+ process.stderr.write(`dispatch_hook: ${line}\n`);
285
+ return null;
286
+ }
287
+ const em = emitFor(platform, event, 'block', [line], EXIT_BLOCK);
288
+ if (em.stdout) process.stdout.write(em.stdout);
289
+ if (em.stderr) process.stderr.write(em.stderr);
290
+ return em.exit;
291
+ }
@@ -84,10 +84,9 @@ import path from 'node:path';
84
84
  import { fileURLToPath, pathToFileURL } from 'node:url';
85
85
 
86
86
  import { readHookStdin } from './hook_stdin.js';
87
+ import { EXIT_ALLOW, EXIT_WARN } from './exit_codes.js';
87
88
 
88
89
  const SETTINGS_FILE = '.agent-settings.yml';
89
- const EXIT_ALLOW = 0;
90
- const EXIT_WARN = 2;
91
90
 
92
91
  type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
93
92
  type JsonObject = { [k: string]: JsonValue };
@@ -41,9 +41,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
41
41
 
42
42
  import { type GraphState, graphState } from '../code_graph/detect.js';
43
43
  import { readHookStdin } from './hook_stdin.js';
44
+ import { EXIT_ALLOW, EXIT_WARN } from './exit_codes.js';
44
45
 
45
- const EXIT_ALLOW = 0;
46
- const EXIT_WARN = 2;
47
46
  const CODE_EXT = /\.(php|ts|tsx|mts|cts|js|jsx|mjs|cjs)$/i;
48
47
 
49
48
  type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
@@ -47,9 +47,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
47
47
  import { isScannable, scanText, type CommentFinding } from '../lint_code_comments.js';
48
48
  import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
49
49
  import { readHookStdin } from './hook_stdin.js';
50
-
51
- const EXIT_ALLOW = 0;
52
- const EXIT_WARN = 2;
50
+ import { EXIT_ALLOW, EXIT_WARN } from './exit_codes.js';
53
51
 
54
52
  /** Findings named in the advisory line. The rest are counted, never listed. */
55
53
  export const MAX_REPORTED = 3;
@@ -52,6 +52,7 @@ import { main as gitAuthorizationMain } from '../git_authorization_hook.js';
52
52
  import { main as evidenceIndependenceMain } from './evidence_independence.js';
53
53
  import { main as blockNoVerifyMain } from './block_no_verify.js';
54
54
  import { main as blockKernelRuleWritesMain } from './block_kernel_rule_writes.js';
55
+ import { main as blockPlumbingWritesMain } from './block_plumbing_writes.js';
55
56
  import { main as blockConfigWeakeningMain } from './block_config_weakening.js';
56
57
  import { main as blockSpeakingInboxDirMain } from './block_speaking_inbox_dir.js';
57
58
  import { main as oneQuestionPerAskMain } from './one_question_per_ask_hook.js';
@@ -118,6 +119,7 @@ export const CONCERN_REGISTRY: Readonly<Record<string, ConcernMain>> = {
118
119
  'src/scripts/hooks/evidence_independence.ts': evidenceIndependenceMain as ConcernMain,
119
120
  'src/scripts/hooks/block_no_verify.ts': blockNoVerifyMain as ConcernMain,
120
121
  'src/scripts/hooks/block_kernel_rule_writes.ts': blockKernelRuleWritesMain as ConcernMain,
122
+ 'src/scripts/hooks/block_plumbing_writes.ts': blockPlumbingWritesMain as ConcernMain,
121
123
  'src/scripts/hooks/block_config_weakening.ts': blockConfigWeakeningMain as ConcernMain,
122
124
  'src/scripts/hooks/block_speaking_inbox_dir.ts': blockSpeakingInboxDirMain as ConcernMain,
123
125
  'src/scripts/hooks/one_question_per_ask_hook.ts': oneQuestionPerAskMain as ConcernMain,