@event4u/agent-config 17.2.1 → 17.4.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 (225) 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/.claude-plugin/skills/install-agent-config/SKILL.md +9 -9
  5. package/CHANGELOG.md +167 -193
  6. package/CREDITS.md +2 -2
  7. package/README.md +1 -1
  8. package/dist/agent-src/skills/code-intelligence/SKILL.md +16 -12
  9. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  10. package/dist/agent-src/templates/scripts/check_memory.ts +1 -1
  11. package/dist/cli/commands/recordTriggerEval.js +1 -1
  12. package/dist/cli/registry.js +2 -2
  13. package/dist/cli-delegate/{chunk-KR5ZIFPO.js → chunk-5AYJGUA3.js} +1 -3
  14. package/dist/cli-delegate/chunk-DIFZBP4G.js +1336 -0
  15. package/dist/cli-delegate/chunk-E7ACTJTJ.js +6792 -0
  16. package/dist/cli-delegate/{chunk-VYZGJLCA.js → chunk-RKNT4LIK.js} +22 -1286
  17. package/dist/cli-delegate/{chunk-BZJ4Y3CI.js → chunk-WY5ZZPGL.js} +2 -2
  18. package/dist/cli-delegate/{chunk-CMZEKSNN.js → chunk-X6LZ7W5A.js} +73 -10
  19. package/dist/cli-delegate/cmd_conformance.js +190 -192
  20. package/dist/cli-delegate/cmd_doctor.js +5 -5
  21. package/dist/cli-delegate/cmd_doctor_neighbours.js +3 -3
  22. package/dist/cli-delegate/cmd_explain.js +1 -1
  23. package/dist/cli-delegate/cmd_export.js +2 -2
  24. package/dist/cli-delegate/cmd_handoff.js +2 -2
  25. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  26. package/dist/cli-delegate/cmd_prune.js +2 -2
  27. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  28. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  29. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  30. package/dist/cli-delegate/cmd_settings_set.js +8 -4
  31. package/dist/cli-delegate/cmd_sync.js +2 -2
  32. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  33. package/dist/cli-delegate/cmd_update.js +24 -13
  34. package/dist/cli-delegate/cmd_upgrade.js +2 -2
  35. package/dist/cli-delegate/cmd_validate.js +2 -2
  36. package/dist/cli-delegate/dist-25AZAHOP.js +3240 -0
  37. package/dist/cli-delegate/dist-P7MFPMF3.js +411 -0
  38. package/dist/cli-delegate/dist-XAS5ZLQB.js +6 -0
  39. package/dist/cli-delegate/{upgrade_unattended-VVL6JLSX.js → upgrade_unattended-QCWOKTUN.js} +178 -163
  40. package/dist/discovery/deprecation-report.md +1 -1
  41. package/dist/discovery/discovery-manifest.json +3 -3
  42. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  43. package/dist/discovery/discovery-manifest.summary.md +1 -1
  44. package/dist/discovery/dormancy-report.md +1 -1
  45. package/dist/discovery/orphan-report.md +1 -1
  46. package/dist/discovery/packs.json +2 -2
  47. package/dist/discovery/trust-report.md +1 -1
  48. package/dist/discovery/workspaces.json +2 -2
  49. package/dist/hooks/code_graph_refresh.chunk.cjs +443 -0
  50. package/dist/hooks/code_graph_refresh.chunk.cjs.sha256 +1 -0
  51. package/dist/hooks/dispatch.js +1001 -938
  52. package/dist/hooks/dispatch.sha256 +1 -1
  53. package/dist/hooks/egress_engine.chunk.cjs +57 -7
  54. package/dist/hooks/egress_engine.chunk.cjs.sha256 +1 -1
  55. package/dist/hooks/update_notice.chunk.cjs +914 -0
  56. package/dist/hooks/update_notice.chunk.cjs.sha256 +1 -0
  57. package/dist/install/install.mjs +6974 -6037
  58. package/dist/mcp/mcp-cloudflare-catalogue.json +1 -0
  59. package/dist/mcp/registry-manifest.json +3 -2
  60. package/dist/mcp/server.json +13 -4
  61. package/dist/mcp/server.mjs +71202 -63282
  62. package/dist/scripts/_lib/egress_rules.js +8 -2
  63. package/dist/scripts/condense.js +18 -15
  64. package/dist/server/routes/discovery.js +1 -1
  65. package/dist/server/routes/install.js +1 -1
  66. package/dist/server/routes/ping.js +1 -1
  67. package/dist/server/routes/schema.js +4 -4
  68. package/dist/server/routes/settings.js +2 -6
  69. package/dist/server/routes/wizard.js +6 -6
  70. package/dist/server/schemas/settings.js +42 -42
  71. package/dist/shared/settingsSurface.js +1 -1
  72. package/dist/shared/userMd/schema.js +1 -1
  73. package/dist/shared/zodJsonSchema.js +47 -0
  74. package/dist/ui/assets/index-B0bcIgi0.js +77 -0
  75. package/dist/ui/index.html +1 -1
  76. package/package.json +6 -3
  77. package/src/agent-src/templates/scripts/check_memory.ts +1 -1
  78. package/src/config/ci-local-parity.yml +11 -0
  79. package/src/config/continuity-surface.json +9 -2
  80. package/src/config/evaluator-budgets.json +9 -8
  81. package/src/config/gate-violation-baselines.json +3 -3
  82. package/src/config/gitignore-block.txt +6 -5
  83. package/src/config/hook-bundle-budget.json +46 -6
  84. package/src/config/pack-size-budget.json +12 -1
  85. package/src/config/packed-binary-manifest.json +18 -0
  86. package/src/config/sigstore/npm_keys.json +50 -0
  87. package/src/config/sigstore/trust-material.json +9 -0
  88. package/src/config/sigstore/trusted_root.json +126 -0
  89. package/src/config/skill-body-budget.json +7 -1
  90. package/src/scripts/_cli/cmd_settings_set.ts +3 -2
  91. package/src/scripts/_cli/cmd_update.ts +48 -20
  92. package/src/scripts/_cli/cmd_upgrade.ts +7 -1
  93. package/src/scripts/_cli/upgrade_unattended.ts +90 -76
  94. package/src/scripts/_dispatch.bash +4 -3
  95. package/src/scripts/_lib/confusables.ts +0 -19
  96. package/src/scripts/_lib/egress_normalize.ts +53 -23
  97. package/src/scripts/_lib/egress_rules.ts +8 -2
  98. package/src/scripts/_lib/egress_text.ts +5 -3
  99. package/src/scripts/_lib/egress_token.ts +7 -5
  100. package/src/scripts/_lib/gate_collector_experimental.ts +633 -0
  101. package/src/scripts/_lib/git_common_dir.ts +10 -0
  102. package/src/scripts/_lib/graph_feeder_record.ts +3 -2
  103. package/src/scripts/_lib/install_reach_checks.ts +32 -2
  104. package/src/scripts/_lib/lineage_backfill.ts +136 -0
  105. package/src/scripts/_lib/lineage_ledger.ts +211 -0
  106. package/src/scripts/_lib/lineage_map.ts +55 -0
  107. package/src/scripts/_lib/lineage_query.ts +140 -0
  108. package/src/scripts/_lib/lineage_seed.ts +116 -0
  109. package/src/scripts/_lib/lineage_watch.ts +249 -0
  110. package/src/scripts/_lib/link_crypto.ts +8 -4
  111. package/src/scripts/_lib/listing_manifests.ts +85 -0
  112. package/src/scripts/_lib/neighbour_census.ts +1 -0
  113. package/src/scripts/_lib/production_reach.ts +3 -3
  114. package/src/scripts/_lib/project_registry.ts +227 -0
  115. package/src/scripts/_lib/provenance_verify.ts +14 -242
  116. package/src/scripts/_lib/release_settings_surface.ts +2 -6
  117. package/src/scripts/_lib/sigstore_provenance.ts +177 -0
  118. package/src/scripts/_lib/source_id.ts +48 -0
  119. package/src/scripts/_lib/source_shape.ts +4 -0
  120. package/src/scripts/_lib/unattended_gates.ts +6 -1
  121. package/src/scripts/adoption_status.ts +231 -27
  122. package/src/scripts/advisories.ts +26 -10
  123. package/src/scripts/ai_council/council_egress.ts +5 -6
  124. package/src/scripts/bench_graph_feeder_latency.ts +10 -3
  125. package/src/scripts/build_mcp_registry_manifest.ts +23 -12
  126. package/src/scripts/check_dependency_floors.ts +14 -0
  127. package/src/scripts/check_egress_mutations.ts +1 -0
  128. package/src/scripts/check_release_manifest_content.ts +17 -2
  129. package/src/scripts/check_release_pr_shape.ts +5 -0
  130. package/src/scripts/code_graph/build.ts +145 -45
  131. package/src/scripts/code_graph/cli.ts +382 -35
  132. package/src/scripts/code_graph/conventions_default.ts +247 -0
  133. package/src/scripts/code_graph/detect.ts +5 -5
  134. package/src/scripts/code_graph/estate_cli.ts +135 -0
  135. package/src/scripts/code_graph/estate_index.ts +394 -0
  136. package/src/scripts/code_graph/estate_manifests.ts +223 -0
  137. package/src/scripts/code_graph/estate_map.ts +141 -0
  138. package/src/scripts/code_graph/extract.ts +8 -2
  139. package/src/scripts/code_graph/extract_more.ts +241 -2
  140. package/src/scripts/code_graph/graph_budget.ts +158 -0
  141. package/src/scripts/code_graph/graph_home.ts +80 -0
  142. package/src/scripts/code_graph/lineage_cli.ts +315 -0
  143. package/src/scripts/code_graph/loader.ts +10 -2
  144. package/src/scripts/code_graph/map.ts +195 -53
  145. package/src/scripts/code_graph/map_model.ts +113 -20
  146. package/src/scripts/code_graph/map_verify.ts +80 -5
  147. package/src/scripts/code_graph/map_viewer/viewer.css +18 -0
  148. package/src/scripts/code_graph/map_viewer/viewer.js +146 -15
  149. package/src/scripts/code_graph/passes.ts +173 -0
  150. package/src/scripts/code_graph/project_ref.ts +25 -0
  151. package/src/scripts/code_graph/projects_cli.ts +254 -0
  152. package/src/scripts/code_graph/providers/angular.ts +55 -0
  153. package/src/scripts/code_graph/providers/aspnetcore.ts +182 -0
  154. package/src/scripts/code_graph/providers/catalog.ts +299 -0
  155. package/src/scripts/code_graph/providers/django.ts +206 -0
  156. package/src/scripts/code_graph/providers/express.ts +25 -0
  157. package/src/scripts/code_graph/providers/fastapi.ts +49 -0
  158. package/src/scripts/code_graph/providers/flask.ts +99 -0
  159. package/src/scripts/code_graph/providers/go_http.ts +121 -0
  160. package/src/scripts/code_graph/providers/index.ts +103 -0
  161. package/src/scripts/code_graph/{laravel_overlay.ts → providers/laravel.ts} +113 -7
  162. package/src/scripts/code_graph/providers/nestjs.ts +34 -0
  163. package/src/scripts/code_graph/providers/nextjs.ts +91 -0
  164. package/src/scripts/code_graph/providers/nuxt.ts +55 -0
  165. package/src/scripts/code_graph/providers/rails.ts +204 -0
  166. package/src/scripts/code_graph/providers/react-router.ts +58 -0
  167. package/src/scripts/code_graph/providers/shared/detect.ts +78 -0
  168. package/src/scripts/code_graph/providers/shared/extractors.ts +249 -0
  169. package/src/scripts/code_graph/providers/shared/js.ts +252 -0
  170. package/src/scripts/code_graph/providers/shared/kit.ts +210 -0
  171. package/src/scripts/code_graph/providers/shared/php.ts +64 -0
  172. package/src/scripts/code_graph/providers/shared/python.ts +223 -0
  173. package/src/scripts/code_graph/providers/spring.ts +175 -0
  174. package/src/scripts/code_graph/providers/sqlalchemy.ts +62 -0
  175. package/src/scripts/code_graph/providers/sveltekit.ts +59 -0
  176. package/src/scripts/code_graph/providers/symfony.ts +142 -0
  177. package/src/scripts/code_graph/providers/typeorm.ts +58 -0
  178. package/src/scripts/code_graph/providers/types.ts +56 -0
  179. package/src/scripts/code_graph/providers/vue-router.ts +21 -0
  180. package/src/scripts/code_graph/providers/wordpress.ts +128 -0
  181. package/src/scripts/code_graph/query.ts +9 -9
  182. package/src/scripts/code_graph/refresh_cli.ts +81 -0
  183. package/src/scripts/code_graph/refresh_lock.ts +197 -0
  184. package/src/scripts/code_graph/repo_identity.ts +169 -0
  185. package/src/scripts/code_graph/resolution_tiers.ts +19 -7
  186. package/src/scripts/code_graph/scip_reader.ts +2 -2
  187. package/src/scripts/code_graph/sqlite_store.ts +31 -28
  188. package/src/scripts/code_graph/types.ts +79 -41
  189. package/src/scripts/code_graph/validate.ts +6 -8
  190. package/src/scripts/condense.ts +15 -15
  191. package/src/scripts/generate_settings_reference.ts +2 -6
  192. package/src/scripts/hook_chunks.mjs +11 -0
  193. package/src/scripts/hook_manifest.json +1 -1
  194. package/src/scripts/hook_manifest.yaml +43 -23
  195. package/src/scripts/hooks/code_graph_refresh_hook.ts +163 -0
  196. package/src/scripts/hooks/dispatch_hook.ts +6 -7
  197. package/src/scripts/hooks/host_semantics.ts +72 -0
  198. package/src/scripts/hooks/lazy_concern.ts +8 -0
  199. package/src/scripts/hooks/user_notice.ts +105 -0
  200. package/src/scripts/install-hooks.sh +54 -33
  201. package/src/scripts/install.ts +2 -6
  202. package/src/scripts/lint_harvest_provenance.ts +137 -11
  203. package/src/scripts/lint_hook_manifest.ts +2 -0
  204. package/src/scripts/lint_marketplace.ts +33 -0
  205. package/src/scripts/lint_mcp_registry_manifest.ts +36 -0
  206. package/src/scripts/mcp_server/consumer_tool_catalog.json +70 -15
  207. package/src/scripts/mcp_server/graph_tools.ts +165 -41
  208. package/src/scripts/network_sources.ts +24 -6
  209. package/src/scripts/release.ts +2 -5
  210. package/src/scripts/release_drill.ts +4 -1
  211. package/src/scripts/release_env.ts +17 -0
  212. package/src/scripts/release_mix_taxonomy.json +3 -0
  213. package/src/scripts/schemas/provenance-mechanism.schema.json +46 -0
  214. package/src/scripts/update_notice_hook.ts +75 -7
  215. package/src/server/schemas/settings.ts +42 -42
  216. package/src/shared/settingsSurface.ts +1 -1
  217. package/src/shared/userMd/schema.ts +1 -1
  218. package/src/shared/zodJsonSchema.ts +41 -0
  219. package/src/vendor/grammars/README.md +8 -6
  220. package/src/vendor/grammars/tree-sitter-c_sharp.wasm +0 -0
  221. package/src/vendor/grammars/tree-sitter-ruby.wasm +0 -0
  222. package/dist/cli-delegate/chunk-XIAPY44N.js +0 -4587
  223. package/dist/ui/assets/index-w70Ado09.js +0 -23
  224. /package/dist/cli-delegate/{chunk-FKYZOWVD.js → chunk-QBAFP67O.js} +0 -0
  225. /package/dist/cli-delegate/{chunk-BMGUGSY6.js → chunk-TQG6UMPC.js} +0 -0
@@ -35,6 +35,7 @@
35
35
  */
36
36
 
37
37
  import { blockExitFor, DEFAULT_SURFACE, loadHostLowering, surfaceRow, verifiedPlatforms } from "./host_lowering.js";
38
+ import { hostShowsUserNotice, writeNoticeReceipts, type NoticeBatch } from "./user_notice.js";
38
39
 
39
40
  /** Internal severity, mirroring dispatch_hook's EXIT_ALLOW / BLOCK / WARN. */
40
41
  export type Severity = "allow" | "warn" | "block";
@@ -133,6 +134,77 @@ export function claudeAdditionalContext(event: string, text: string): string {
133
134
  })}\n`;
134
135
  }
135
136
 
137
+ function isObject(v: unknown): v is Record<string, unknown> {
138
+ return typeof v === "object" && v !== null && !Array.isArray(v);
139
+ }
140
+
141
+ /**
142
+ * Compose the dispatcher's stdout from its three parts: plain `session_start`
143
+ * context, the native emission, and a user-addressed notice.
144
+ *
145
+ * Without a notice — or on a host with no verified user field — this is the
146
+ * old output byte for byte: context, then emission. With one, the host parses
147
+ * stdout as JSON only when the whole of it is one object, so the parts are
148
+ * MERGED as structures, never concatenated as text: the emission's object is
149
+ * kept, context joins its `hookSpecificOutput.additionalContext`, and the
150
+ * notice joins `systemMessage`. Plain context is accepted on `session_start`
151
+ * only, and an emission that is not one JSON object is never reinterpreted —
152
+ * in either case the notice is dropped and the old output stands.
153
+ */
154
+ export function withUserNotice(
155
+ platform: string,
156
+ event: string,
157
+ context: string,
158
+ emission: string,
159
+ notice: string,
160
+ ): string {
161
+ const legacy = context + emission;
162
+ const text = notice.trim();
163
+ if (!text || !hostShowsUserNotice(platform) || !usesNativeEmission(platform)) return legacy;
164
+ if (context.trim() && event !== "session_start") return legacy;
165
+ let reply: Record<string, unknown> = {};
166
+ if (emission.trim()) {
167
+ try {
168
+ reply = JSON.parse(emission) as Record<string, unknown>;
169
+ } catch {
170
+ return legacy;
171
+ }
172
+ if (!isObject(reply)) return legacy;
173
+ }
174
+ // A field this merge would extend must already have the type it extends.
175
+ const hsoIn = reply["hookSpecificOutput"];
176
+ if (hsoIn !== undefined && !isObject(hsoIn)) return legacy;
177
+ if (![reply["systemMessage"], hsoIn?.["additionalContext"]].every((v) => v === undefined || typeof v === "string")) return legacy;
178
+ const join = (a: unknown, b: string, sep: string): string => (typeof a === "string" && a ? `${a}${sep}${b}` : b);
179
+ if (context.trim()) {
180
+ const hso = hsoIn ?? { hookEventName: nativeEventName(platform, event) };
181
+ reply["hookSpecificOutput"] = { ...hso, additionalContext: join(hso["additionalContext"], context.trim(), "\n\n") };
182
+ }
183
+ reply["systemMessage"] = join(reply["systemMessage"], text, "\n");
184
+ return `${JSON.stringify(reply)}\n`;
185
+ }
186
+
187
+ /**
188
+ * Write the dispatcher's whole stdout — `withUserNotice` over its parts — and
189
+ * only then a receipt for each notice that is actually in what was written: a
190
+ * write that throws leaves no receipt, and a dropped notice leaves none, so its
191
+ * concern says it again instead of counting it as shown. The notice rides only
192
+ * on exit 0, because the host parses stdout only then.
193
+ */
194
+ export function writeStdout(
195
+ platform: string,
196
+ event: string,
197
+ context: string,
198
+ emission: Emission,
199
+ notices: NoticeBatch,
200
+ sessionId: string,
201
+ write: (text: string) => unknown,
202
+ ): void {
203
+ const out = withUserNotice(platform, event, context, emission.stdout, emission.exit === 0 ? notices.map((n) => n.text).join("\n") : "");
204
+ if (out) write(out);
205
+ if (notices.length > 0 && out !== context + emission.stdout) writeNoticeReceipts(notices, sessionId);
206
+ }
207
+
136
208
  /**
137
209
  * A host permission verdict for a `pre_tool_use` call.
138
210
  *
@@ -72,6 +72,14 @@ export const LAZY_CONCERNS: Readonly<Record<string, { readonly chunk: string; re
72
72
  chunk: 'instructions_observer.chunk.cjs',
73
73
  source: 'instructions_observer_hook',
74
74
  }),
75
+ 'src/scripts/hooks/code_graph_refresh_hook.ts': Object.freeze({
76
+ chunk: 'code_graph_refresh.chunk.cjs',
77
+ source: 'code_graph_refresh_hook',
78
+ }),
79
+ 'src/scripts/update_notice_hook.ts': Object.freeze({
80
+ chunk: 'update_notice.chunk.cjs',
81
+ source: path.join('..', 'update_notice_hook'),
82
+ }),
75
83
  });
76
84
 
77
85
  declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The user-addressed channel: text a concern hands the dispatcher for the HUMAN,
3
+ * which the host shows in its own UI and never adds to model context.
4
+ *
5
+ * WHY IT EXISTS. Every channel the dispatcher had addressed the model
6
+ * (`additional_context`, `context`) or went to stderr, and every host runs the
7
+ * dispatcher on pipes — so a concern with something to tell the user had no
8
+ * path that reached them. `update-notice` was refused registration for exactly
9
+ * that: bound, it would have said nothing anywhere.
10
+ *
11
+ * Claude Code documents a `systemMessage` field on every hook event, "Display a
12
+ * message to the user", read off the 2.1.310 binary on 2026-10-24. It is the
13
+ * only host this package has verified a user-addressed field on, so the set
14
+ * below is one host wide; an unlisted host gets nothing rather than a guess.
15
+ *
16
+ * loss_class: ephemeral-lossy
17
+ *
18
+ * A notice longer than USER_NOTICE_MAX_CHARS loses its tail, deliberately: the
19
+ * text is for the user's UI and never reaches the model, and a notice is a few
20
+ * lines by contract, so nothing recovers the cut part.
21
+ */
22
+
23
+ import * as fs from 'node:fs';
24
+
25
+ import { write_atomic } from '../_lib/fs_atomic.js';
26
+ import { write_target } from '../_lib/user_global_paths.js';
27
+
28
+ /** The concern reply key a notification concern puts its user-facing text under. */
29
+ export const USER_NOTICE_KEY = 'user_notice';
30
+
31
+ /** The reply key naming this one notice, echoed into its receipt and never shown. */
32
+ export const USER_NOTICE_ID_KEY = 'notice_id';
33
+
34
+ /** Longest notice the dispatcher forwards, in Unicode code points; a notice is a few lines, never a document. */
35
+ export const USER_NOTICE_MAX_CHARS = 1000;
36
+
37
+ /** Hosts with a verified field that shows text to the user and not to the model. */
38
+ export const USER_NOTICE_HOSTS: ReadonlySet<string> = new Set(['claude']);
39
+
40
+ export function hostShowsUserNotice(platform: unknown): boolean {
41
+ return typeof platform === 'string' && USER_NOTICE_HOSTS.has(platform);
42
+ }
43
+
44
+ /**
45
+ * The notices one dispatch kept, each with the concern it came from, in
46
+ * manifest order. One entry carries both, so the text that is emitted and the
47
+ * concern that gets a receipt can never come apart.
48
+ */
49
+ export type NoticeBatch = Array<{ readonly concern: string; readonly text: string; readonly id: string }>;
50
+
51
+ /**
52
+ * Only a `notification` concern may address the user: no other effect was
53
+ * reviewed for it. The text is cleaned, then capped by code point so a pair is
54
+ * never split, and kept only if something is left to show.
55
+ */
56
+ export function collectUserNotice(
57
+ batch: NoticeBatch,
58
+ concern: Record<string, unknown>,
59
+ reply: Record<string, unknown>,
60
+ clean: (text: string) => string,
61
+ ): void {
62
+ const notice = reply[USER_NOTICE_KEY];
63
+ if (concern['effect'] !== 'notification' || typeof notice !== 'string') return;
64
+ const text = Array.from(clean(notice.trim())).slice(0, USER_NOTICE_MAX_CHARS).join('').trim();
65
+ const id = reply[USER_NOTICE_ID_KEY];
66
+ if (text) batch.push({ concern: String(concern['name']), text, id: typeof id === 'string' ? id.slice(0, 64) : '' });
67
+ }
68
+
69
+ /**
70
+ * A delivery receipt: the dispatcher writes one per concern whose notice it
71
+ * actually put into host output, naming the session and the notice's own id.
72
+ * A concern that must not count a notice as shown until it was handed to the
73
+ * host reads it on its next run and matches the id it handed over; anything the
74
+ * dispatcher dropped leaves no receipt for that id, so the concern says it again.
75
+ */
76
+ export interface NoticeReceipt {
77
+ readonly session_id: string;
78
+ readonly notice_id: string;
79
+ readonly at: string;
80
+ }
81
+
82
+ function receiptPath(concern: string): string {
83
+ return write_target(`user-notice-${concern}.delivered`);
84
+ }
85
+
86
+ export function writeNoticeReceipts(batch: NoticeBatch, sessionId: string): void {
87
+ for (const { concern, id } of batch) {
88
+ try {
89
+ write_atomic(receiptPath(concern), JSON.stringify({ session_id: sessionId, notice_id: id, at: new Date().toISOString() }));
90
+ } catch {
91
+ // no receipt → the concern repeats its notice, which is the safe direction
92
+ }
93
+ }
94
+ }
95
+
96
+ export function readNoticeReceipt(concern: string): NoticeReceipt | null {
97
+ try {
98
+ const o = JSON.parse(fs.readFileSync(receiptPath(concern), 'utf-8')) as Record<string, unknown>;
99
+ return typeof o['session_id'] === 'string' && typeof o['notice_id'] === 'string' && typeof o['at'] === 'string'
100
+ ? { session_id: o['session_id'], notice_id: o['notice_id'], at: o['at'] }
101
+ : null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
@@ -1,6 +1,21 @@
1
1
  #!/usr/bin/env bash
2
2
  # Install git hooks for this repository.
3
3
  # Run once: bash src/scripts/install-hooks.sh
4
+ #
5
+ # TRUST MODEL. The hooks this writes execute code FROM THE CHECKOUT —
6
+ # `./agent-config`, `./scripts-run`, `task`, `npm run build:cli` — on commit,
7
+ # checkout, merge, rebase and push. That is acceptable in exactly one place:
8
+ # this package's own development tree, whose code the developer already runs
9
+ # (tests, `prepare`, the CLI itself). A globally installed `agent-config` would
10
+ # be an older build of the same code, so the checkout's own is the right one
11
+ # HERE. It is wrong anywhere else: run from a consumer's
12
+ # `node_modules/@event4u/agent-config/`, this script would resolve the
13
+ # CONSUMER's git directory and install these hooks into a repository whose
14
+ # code nobody vetted for them. The guard below enforces the boundary — the
15
+ # project root must be the git top-level AND its package.json must name
16
+ # @event4u/agent-config — and consumer freshness travels through the
17
+ # `code-graph-refresh` session concern instead, which never runs repository
18
+ # code (road-to-code-graph-refresh-follow-ups 3.1 / 3.2).
4
19
 
5
20
  set -euo pipefail
6
21
 
@@ -8,6 +23,15 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
23
  # This installer lives at src/scripts/install-hooks.sh, so the repo root
9
24
  # (which owns .git/) is two levels up.
10
25
  PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
26
+
27
+ TOPLEVEL="$(git -C "$PROJECT_ROOT" rev-parse --show-toplevel 2>/dev/null || true)"
28
+ if [ -z "$TOPLEVEL" ] \
29
+ || [ "$(cd "$TOPLEVEL" && pwd -P)" != "$(cd "$PROJECT_ROOT" && pwd -P)" ] \
30
+ || ! grep -Eq '"name"[[:space:]]*:[[:space:]]*"@event4u/agent-config"' "$PROJECT_ROOT/package.json" 2>/dev/null; then
31
+ echo "❌ refusing to install the development git hooks: $PROJECT_ROOT is not the top level of an" >&2
32
+ echo " @event4u/agent-config checkout. These hooks run code from the checkout (TRUST MODEL above)." >&2
33
+ exit 1
34
+ fi
11
35
  # Resolve the SHARED hooks dir via git so this also works from a linked worktree
12
36
  # (where $PROJECT_ROOT/.git is a file, not a dir, and a literal .git/hooks path
13
37
  # would break under `set -e`). --git-common-dir points at the main .git even from
@@ -619,18 +643,26 @@ echo "✅ Auto-sync block appended to post-merge / post-checkout hooks." >&2
619
643
  #
620
644
  # road-to-a-graph-that-is-shipped 1.3. A code graph goes stale the moment a
621
645
  # commit lands, and the only thing that knew were the reader — after it had
622
- # already answered from a stale index. These two hooks close that without a
646
+ # already answered from a stale index. These hooks close that without a
623
647
  # resident process: git tells us HEAD moved, and a detached child rebuilds.
624
648
  #
649
+ # Four hooks, because HEAD moves through four doors (measured with a probe
650
+ # repo): a commit fires post-commit, a branch switch post-checkout, a
651
+ # fast-forward `git pull` ONLY post-merge, and a rebase post-checkout at its
652
+ # START and post-rewrite at its end. Without the last two, `git pull` left the
653
+ # graph stale and a rebase refreshed against an intermediate HEAD.
654
+ #
625
655
  # Four properties, each load-bearing:
626
656
  #
627
657
  # · BACKGROUND. The refresh is `&`-detached with stdout/stderr closed, so a
628
658
  # commit never waits on a parse. Git's own runtime is unchanged.
629
- # · SINGLE-FLIGHT. `mkdir` is atomic on every POSIX filesystem, so of two
630
- # concurrent commits exactly one creates the lock and exactly one refresh
631
- # runs. This is Risk 3 in the roadmap's register — "git hooks pile up
632
- # rebuilds" — and a lock file written with `>` would not have closed it,
633
- # because two processes can both truncate-and-write the same path.
659
+ # · SINGLE-FLIGHT. `refresh --single-flight` takes the engine's own mkdir
660
+ # lock (code_graph/refresh_lock.ts), the one the session-start concern and
661
+ # a manual `map` take too, so of two concurrent commits exactly one
662
+ # rebuilds. This is Risk 3 in the roadmap's register — "git hooks pile up
663
+ # rebuilds". The hook used to hold its own shell lock; a ratification round
664
+ # of 2026-10-23 found two lock protocols racing on one directory, so there
665
+ # is now one, and its stale-lock reclaim is mutex-guarded and re-checked.
634
666
  # · BUDGETED. `--budget-seconds` is the engine's own wall-clock ceiling; a
635
667
  # child that exceeds it is SIGKILLed and the OLD cache is left untouched
636
668
  # (cli.ts's refresh contract). Kill register K7 — never block on a stale
@@ -639,9 +671,8 @@ echo "✅ Auto-sync block appended to post-merge / post-checkout hooks." >&2
639
671
  # exists. Building a graph for someone who never asked for one is the pure
640
672
  # cost Risk 1 names; refreshing one they built is the service.
641
673
  #
642
- # The lock is a directory under the gitignored runtime state, so a crashed
643
- # child leaves a stale lock that the next refresh clears by age rather than
644
- # blocking forever.
674
+ # `--map` redraws agents/map/ under the same lock, only for a project that has
675
+ # one (ADR-297).
645
676
 
646
677
  append_code_graph_refresh_block() {
647
678
  local name="$1"
@@ -657,30 +688,18 @@ if [ "${3:-1}" = "0" ]; then
657
688
  fi
658
689
  EOF
659
690
  fi
660
- cat >> "$HOOKS_DIR/$name" << 'EOF'
661
- cg_cache="agents/runtime/state/code-graph-v1.json"
662
- cg_lock="agents/runtime/state/code-graph-refresh.lock"
663
- if [ -f "$cg_cache" ] && [ -x ./agent-config ]; then
664
- # Clear a lock left by a killed child: older than the largest budget any
665
- # caller passes, so it cannot race a refresh that is legitimately running.
666
- if [ -d "$cg_lock" ]; then
667
- if [ -z "$(find "$cg_lock" -maxdepth 0 -mmin -5 2>/dev/null)" ]; then
668
- rmdir "$cg_lock" 2>/dev/null || true
669
- fi
670
- fi
671
- # Atomic: exactly one of N concurrent hooks wins this, and the losers do
672
- # nothing rather than queueing a second rebuild behind the first.
673
- if mkdir "$cg_lock" 2>/dev/null; then
674
- (
675
- trap 'rmdir "$cg_lock" 2>/dev/null || true' EXIT
676
- ./agent-config code-graph refresh --budget-seconds 60 >/dev/null 2>&1 || true
677
- # The map follows the index it is drawn from, but only for a
678
- # project that asked for one: no agents/map/, no map (ADR-297).
679
- if [ -d agents/map ]; then
680
- ./agent-config code-graph map --no-verify --no-build >/dev/null 2>&1 || true
681
- fi
682
- ) >/dev/null 2>&1 &
691
+ if [ "$name" = "post-rewrite" ]; then
692
+ cat >> "$HOOKS_DIR/$name" << 'EOF'
693
+ # An amend already fired post-commit; only a finished rebase is news here.
694
+ if [ "${1:-}" != "rebase" ]; then
695
+ exit 0
696
+ fi
697
+ EOF
683
698
  fi
699
+ cat >> "$HOOKS_DIR/$name" << 'EOF'
700
+ if [ -f agents/runtime/state/code-graph-v1.json ] && [ -x ./agent-config ]; then
701
+ ./agent-config code-graph refresh --single-flight --map --budget-seconds 60 \
702
+ </dev/null >/dev/null 2>&1 &
684
703
  fi
685
704
  exit 0
686
705
  EOF
@@ -688,4 +707,6 @@ EOF
688
707
 
689
708
  append_code_graph_refresh_block "post-commit"
690
709
  append_code_graph_refresh_block "post-checkout"
691
- echo "✅ Code-graph refresh block appended to post-commit / post-checkout hooks." >&2
710
+ append_code_graph_refresh_block "post-merge"
711
+ append_code_graph_refresh_block "post-rewrite"
712
+ echo "✅ Code-graph refresh block appended to post-commit / post-checkout / post-merge / post-rewrite hooks." >&2
@@ -125,7 +125,7 @@ import { installerThinsHost } from './_lib/lean_projection_mode.js';
125
125
  import { GLOBAL_DEPLOY_SOURCES, RULE_SOURCE_REL, dryRunTargetLines } from '../install/wizard-plan.js';
126
126
  import { flattenSurface, computeSurfaceDelta, type SettingsSurface } from '../shared/settingsSurface.js';
127
127
  import { settingsSchema } from '../server/schemas/settings.js';
128
- import { zodToJsonSchema } from 'zod-to-json-schema';
128
+ import { toJsonSchema7 } from '../shared/zodJsonSchema.js';
129
129
  import { detect_module_roots } from './_lib/module_detection.js';
130
130
  import {
131
131
  TIER_TO_CLAUDE_MODEL,
@@ -3671,11 +3671,7 @@ const SETTINGS_SURFACE_REL = path.join('state', 'settings-surface.json');
3671
3671
  const SETTINGS_DELTA_REL = path.join('state', 'settings-delta.json');
3672
3672
 
3673
3673
  function _current_settings_surface(version: string): SettingsSurface {
3674
- const jsonSchema = zodToJsonSchema(settingsSchema, {
3675
- name: 'AgentSettings',
3676
- $refStrategy: 'none',
3677
- target: 'jsonSchema7',
3678
- }) as Parameters<typeof flattenSurface>[0];
3674
+ const jsonSchema = toJsonSchema7(settingsSchema, 'AgentSettings') as Parameters<typeof flattenSurface>[0];
3679
3675
  return flattenSurface(jsonSchema, version);
3680
3676
  }
3681
3677
 
@@ -30,7 +30,13 @@
30
30
  * 4. Pinning — `source_ref` carries a revision or is explicitly opaque; a
31
31
  * bare URL is rejected because it cannot be re-verified later.
32
32
  * 5. Orphan citations — every `<!-- harvest:<id> -->` marker under the
33
- * scanned roots resolves to a ledger row.
33
+ * scanned roots resolves to a row in either ledger.
34
+ * 6. Mechanism decisions — `provenance/mechanisms.jsonl`, the source-free
35
+ * decision ledger: a closed shape with no source id, pin or locator
36
+ * (council O5), adopt|adapt carrying existing `realised_in` paths, reject
37
+ * carrying a reason, gap|deferred carrying a roadmap or `open`. A
38
+ * mechanism may repeat — the ledger is append-only and a later row
39
+ * supersedes an earlier one.
34
40
  *
35
41
  * An EMPTY ledger with zero markers is a legitimate pass and is reported as
36
42
  * such on stdout. A gate that scans nothing and exits green silently is
@@ -57,6 +63,7 @@ const _HERE = path.dirname(_FILE);
57
63
  // src/scripts/lint_harvest_provenance.ts → two levels up is the repo root.
58
64
  const REPO = path.resolve(_HERE, '..', '..');
59
65
  const LEDGER_REL = 'provenance/harvests.jsonl';
66
+ const MECHANISMS_REL = 'provenance/mechanisms.jsonl';
60
67
 
61
68
  /** Roots scanned for citation markers. Markdown-bearing authored surfaces. */
62
69
  export const CITATION_SCAN_ROOTS: readonly string[] = [
@@ -102,11 +109,23 @@ export interface HarvestRecord {
102
109
  readonly verdict: 'adopt' | 'adapt';
103
110
  }
104
111
 
112
+ export interface MechanismRecord {
113
+ readonly mechanism_id: string;
114
+ readonly verdict: 'adopt' | 'adapt' | 'reject' | 'already' | 'gap' | 'deferred';
115
+ readonly realised_in?: readonly string[];
116
+ readonly roadmap?: string;
117
+ readonly decided_at: string;
118
+ readonly reason?: string;
119
+ readonly reopen_if?: string;
120
+ }
121
+
105
122
  export interface Finding {
106
123
  /** Ledger line number, or 0 for findings that belong to no single row. */
107
124
  readonly line: number;
108
- readonly rule: 'schema' | 'uniqueness' | 'dead-row' | 'pinning' | 'orphan-citation';
125
+ readonly rule: 'schema' | 'uniqueness' | 'dead-row' | 'pinning' | 'orphan-citation' | 'verdict-requirement';
109
126
  readonly message: string;
127
+ /** The ledger the line number refers to; absent means the harvest ledger. */
128
+ readonly ledger?: string;
110
129
  }
111
130
 
112
131
  function isValidCalendarDate(s: string): boolean {
@@ -227,6 +246,100 @@ export function validateRecord(raw: unknown, line: number, repoRoot: string): Fi
227
246
  return findings;
228
247
  }
229
248
 
249
+ const MECHANISM_FIELDS = ['mechanism_id', 'verdict', 'realised_in', 'roadmap', 'decided_at', 'reason', 'reopen_if'] as const;
250
+ const MECHANISM_REQUIRED = ['mechanism_id', 'verdict', 'decided_at'] as const;
251
+ const MECHANISM_VERDICTS: ReadonlySet<string> = new Set(['adopt', 'adapt', 'reject', 'already', 'gap', 'deferred']);
252
+
253
+ /** `rel` (with an optional `:<line>`) is a repo-relative path that exists; else the finding. */
254
+ function repoPathFinding(rel: string, field: string, line: number, repoRoot: string): Finding | null {
255
+ const bare = stripLineSuffix(rel);
256
+ const resolved = path.resolve(repoRoot, bare);
257
+ if (bare.trim() === '' || path.isAbsolute(bare) || !(resolved === repoRoot || resolved.startsWith(repoRoot + path.sep))) {
258
+ return { line, rule: 'schema', message: `${field} '${rel}' must be a repo-relative path inside the repo`, ledger: MECHANISMS_REL };
259
+ }
260
+ if (!fs.existsSync(resolved)) {
261
+ return { line, rule: 'dead-row', message: `${field} '${bare}' does not exist — the decision points at nothing`, ledger: MECHANISMS_REL };
262
+ }
263
+ return null;
264
+ }
265
+
266
+ /** Validate one `mechanisms.jsonl` row: closed and source-free, then the per-verdict requirement. */
267
+ export function validateMechanismRecord(raw: unknown, line: number, repoRoot: string): Finding[] {
268
+ const at = (rule: Finding['rule'], message: string): Finding => ({ line, rule, message, ledger: MECHANISMS_REL });
269
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return [at('schema', 'record is not a JSON object')];
270
+ const rec = raw as Record<string, unknown>;
271
+ const findings: Finding[] = [];
272
+
273
+ for (const k of Object.keys(rec)) {
274
+ if ((MECHANISM_FIELDS as readonly string[]).includes(k)) continue;
275
+ findings.push(
276
+ at(
277
+ 'schema',
278
+ /source|pin|locator/.test(k)
279
+ ? `field '${k}' links a source to a mechanism — the tracked decision ledger is source-free (council O5); it belongs in the local lineage ledger`
280
+ : `unexpected field '${k}' — the ledger schema is closed to [${MECHANISM_FIELDS.join(', ')}]`,
281
+ ),
282
+ );
283
+ }
284
+ for (const k of MECHANISM_REQUIRED) if (!(k in rec)) findings.push(at('schema', `missing required field '${k}'`));
285
+
286
+ if ('mechanism_id' in rec && (typeof rec.mechanism_id !== 'string' || !HARVEST_ID_RE.test(rec.mechanism_id))) {
287
+ findings.push(at('schema', `mechanism_id must be a kebab-case slug, got ${JSON.stringify(rec.mechanism_id)}`));
288
+ }
289
+ if ('decided_at' in rec && !(typeof rec.decided_at === 'string' && isValidCalendarDate(rec.decided_at))) {
290
+ findings.push(at('schema', `decided_at must be an ISO-8601 date (YYYY-MM-DD), got ${JSON.stringify(rec.decided_at)}`));
291
+ }
292
+ for (const k of ['reason', 'reopen_if'] as const) {
293
+ if (k in rec && (typeof rec[k] !== 'string' || (rec[k] as string).trim().length < EVIDENCE_MIN_LENGTH)) {
294
+ findings.push(at('schema', `${k} must be at least ${EVIDENCE_MIN_LENGTH} characters`));
295
+ }
296
+ }
297
+ const realised = rec.realised_in;
298
+ if (realised !== undefined && (!Array.isArray(realised) || realised.length === 0 || realised.some((r) => typeof r !== 'string'))) {
299
+ findings.push(at('schema', 'realised_in must be a non-empty list of repo-relative paths'));
300
+ } else if (Array.isArray(realised)) {
301
+ for (const r of realised as string[]) {
302
+ const f = repoPathFinding(r, 'realised_in', line, repoRoot);
303
+ if (f) findings.push(f);
304
+ }
305
+ }
306
+ if ('roadmap' in rec) {
307
+ if (typeof rec.roadmap !== 'string') findings.push(at('schema', "roadmap must be a repo-relative path or 'open'"));
308
+ else if (rec.roadmap !== 'open') {
309
+ const f = repoPathFinding(rec.roadmap, 'roadmap', line, repoRoot);
310
+ if (f) findings.push(f);
311
+ }
312
+ }
313
+
314
+ const verdict = rec.verdict;
315
+ if ('verdict' in rec && (typeof verdict !== 'string' || !MECHANISM_VERDICTS.has(verdict))) {
316
+ findings.push(at('schema', `verdict must be one of [${[...MECHANISM_VERDICTS].join(', ')}], got ${JSON.stringify(verdict)}`));
317
+ } else if ((verdict === 'adopt' || verdict === 'adapt') && realised === undefined) {
318
+ findings.push(at('verdict-requirement', `verdict '${verdict}' needs realised_in — an adoption that lives nowhere is not one`));
319
+ } else if (verdict === 'reject' && !('reason' in rec)) {
320
+ findings.push(at('verdict-requirement', "verdict 'reject' needs a reason — a rejection without one is re-litigated on every round"));
321
+ } else if ((verdict === 'gap' || verdict === 'deferred') && !('roadmap' in rec)) {
322
+ findings.push(at('verdict-requirement', `verdict '${verdict}' needs a roadmap that owns it, or 'open'`));
323
+ }
324
+ return findings;
325
+ }
326
+
327
+ /** Parse + validate every mechanism row. Repeats are allowed: a later row supersedes. */
328
+ export function lintMechanismsText(
329
+ text: string,
330
+ repoRoot: string,
331
+ ): { readonly records: MechanismRecord[]; readonly findings: Finding[] } {
332
+ const { parsed, findings: parseFindings } = parseLedgerText(text);
333
+ const findings: Finding[] = parseFindings.map((f) => ({ ...f, ledger: MECHANISMS_REL }));
334
+ const records: MechanismRecord[] = [];
335
+ for (const { line, value } of parsed) {
336
+ const f = validateMechanismRecord(value, line, repoRoot);
337
+ findings.push(...f);
338
+ if (f.length === 0) records.push(value as MechanismRecord);
339
+ }
340
+ return { records, findings };
341
+ }
342
+
230
343
  // ─── ledger parsing ──────────────────────────────────────────────────────────
231
344
 
232
345
  export interface ParsedLine {
@@ -371,18 +484,19 @@ export function collectPersonaSources(repoRoot: string, personaRoot = 'src/agent
371
484
  return citations;
372
485
  }
373
486
 
374
- /** Every citation must resolve to a ledger row. */
487
+ /** Every citation must resolve to a row in either ledger. */
375
488
  export function findOrphanCitations(
376
489
  citations: readonly Citation[],
377
490
  records: readonly HarvestRecord[],
491
+ mechanisms: readonly MechanismRecord[] = [],
378
492
  ): Finding[] {
379
- const known = new Set(records.map((r) => r.harvest_id));
493
+ const known = new Set([...records.map((r) => r.harvest_id), ...mechanisms.map((m) => m.mechanism_id)]);
380
494
  return citations
381
495
  .filter((c) => !known.has(c.id))
382
496
  .map((c) => ({
383
497
  line: 0,
384
498
  rule: 'orphan-citation' as const,
385
- message: `${c.file} cites harvest '${c.id}', which has no row in ${LEDGER_REL}`,
499
+ message: `${c.file} cites harvest '${c.id}', which has no row in ${LEDGER_REL} or ${MECHANISMS_REL}`,
386
500
  }));
387
501
  }
388
502
 
@@ -422,6 +536,7 @@ export function main(argv: string[] = process.argv.slice(2)): number {
422
536
  // the run would report a clean green over a file that no longer exists.
423
537
  try {
424
538
  assertWatchlistResolves({ gate: 'lint_harvest_provenance', candidates: [LEDGER_REL], repoRoot: REPO });
539
+ assertWatchlistResolves({ gate: 'lint_harvest_provenance', candidates: [MECHANISMS_REL], repoRoot: REPO });
425
540
  } catch (exc) {
426
541
  if (exc instanceof DeadScopeError) {
427
542
  process.stderr.write(`❌ ${exc.message}\n`);
@@ -432,9 +547,11 @@ export function main(argv: string[] = process.argv.slice(2)): number {
432
547
 
433
548
  const text = fs.readFileSync(ledgerPath, 'utf-8');
434
549
  const { records, findings } = lintLedgerText(text, REPO);
550
+ const mechText = fs.readFileSync(path.join(REPO, MECHANISMS_REL), 'utf-8');
551
+ const { records: mechanisms, findings: mechFindings } = lintMechanismsText(mechText, REPO);
435
552
  const citations = [...collectCitations(REPO), ...collectPersonaSources(REPO)];
436
- const orphans = findOrphanCitations(citations, records);
437
- const allFindings = [...findings, ...orphans];
553
+ const orphans = findOrphanCitations(citations, records, mechanisms);
554
+ const allFindings = [...findings, ...mechFindings, ...orphans];
438
555
 
439
556
  // Per-target completeness accounting over the two populations this gate
440
557
  // reasons about: the ledger's own rows, and the citations that point at
@@ -444,7 +561,7 @@ export function main(argv: string[] = process.argv.slice(2)): number {
444
561
  // failure the success message above already tries to talk its way out of in
445
562
  // prose. The ledger states it structurally instead.
446
563
  const ledger = new GateLedger('lint_harvest_provenance');
447
- const known = new Set(records.map((r) => r.harvest_id));
564
+ const known = new Set([...records.map((r) => r.harvest_id), ...mechanisms.map((m) => m.mechanism_id)]);
448
565
  // The row population is every PARSED ledger line, not `records`: a row that
449
566
  // fails validation or uniqueness is `continue`d and never reaches `records`,
450
567
  // so planning `records` would plan exactly the rows that passed and account
@@ -452,8 +569,11 @@ export function main(argv: string[] = process.argv.slice(2)): number {
452
569
  // 100 %. Lines are the stable key; a Finding already carries one.
453
570
  const { parsed } = parseLedgerText(text);
454
571
  const badLines = new Set(findings.filter((f) => f.line > 0).map((f) => f.line));
572
+ const { parsed: mechParsed } = parseLedgerText(mechText);
573
+ const badMechLines = new Set(mechFindings.filter((f) => f.line > 0).map((f) => f.line));
455
574
  ledger.plan([
456
575
  ...parsed.map((r) => `row:${String(r.line)}`),
576
+ ...mechParsed.map((r) => `mechanism:${String(r.line)}`),
457
577
  ...citations.map((c) => `citation:${c.id}@${c.file}`),
458
578
  ]);
459
579
  for (const r of parsed) {
@@ -461,6 +581,11 @@ export function main(argv: string[] = process.argv.slice(2)): number {
461
581
  if (badLines.has(r.line)) ledger.fail(id, 'ledger-row finding');
462
582
  else ledger.complete(id);
463
583
  }
584
+ for (const r of mechParsed) {
585
+ const id = `mechanism:${String(r.line)}`;
586
+ if (badMechLines.has(r.line)) ledger.fail(id, 'mechanism-row finding');
587
+ else ledger.complete(id);
588
+ }
464
589
  for (const c of citations) {
465
590
  const id = `citation:${c.id}@${c.file}`;
466
591
  if (known.has(c.id)) ledger.complete(id);
@@ -470,7 +595,8 @@ export function main(argv: string[] = process.argv.slice(2)): number {
470
595
 
471
596
  if (allFindings.length > 0) {
472
597
  for (const f of allFindings) {
473
- const at = f.line > 0 ? `${LEDGER_REL}:${f.line}` : LEDGER_REL;
598
+ const file = f.ledger ?? LEDGER_REL;
599
+ const at = f.line > 0 ? `${file}:${f.line}` : file;
474
600
  process.stderr.write(`❌ ${at}: [${f.rule}] ${f.message}\n`);
475
601
  }
476
602
  process.stderr.write(
@@ -484,7 +610,7 @@ export function main(argv: string[] = process.argv.slice(2)): number {
484
610
  // that does not distinguish "nothing to check" from "everything checked"
485
611
  // is the failure mode this repo has already paid for once.
486
612
  process.stdout.write(
487
- `✅ lint_harvest_provenance: ${records.length} ledger row(s) OK · ` +
613
+ `✅ lint_harvest_provenance: ${records.length} ledger row(s) OK · ${mechanisms.length} mechanism row(s) OK · ` +
488
614
  `${citations.length} citation(s) resolved across ${CITATION_SCAN_ROOTS.length} scanned root(s)` +
489
615
  `${records.length === 0 && citations.length === 0 ? ' — ledger is empty and nothing cites it, which is a legitimate pass' : ''}\n`,
490
616
  );
@@ -514,4 +640,4 @@ if (_isCliEntry()) {
514
640
  }
515
641
  }
516
642
 
517
- export { REPO, LEDGER_REL, ExitCode };
643
+ export { REPO, LEDGER_REL, MECHANISMS_REL, ExitCode };
@@ -69,6 +69,7 @@ const DEFAULT_HOST_LOWERING = path.join(HOOKS_DIR, "host_lowering.yaml");
69
69
  * - `memory` reads or writes the memory layer
70
70
  * - `notification` tells the human something and changes nothing else
71
71
  * - `formatting` reshapes a command or a generated file
72
+ * - `execution` starts a process that outlives the event it fired on
72
73
  */
73
74
  export const CONCERN_EFFECTS: ReadonlySet<string> = new Set([
74
75
  "permission",
@@ -78,6 +79,7 @@ export const CONCERN_EFFECTS: ReadonlySet<string> = new Set([
78
79
  "memory",
79
80
  "notification",
80
81
  "formatting",
82
+ "execution",
81
83
  ]);
82
84
 
83
85
  // Canonical event vocabulary — keep in lock-step with