@event4u/agent-config 17.3.0 → 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 (137) 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 +164 -342
  5. package/CREDITS.md +2 -2
  6. package/README.md +1 -1
  7. package/dist/agent-src/skills/code-intelligence/SKILL.md +3 -0
  8. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  9. package/dist/cli/registry.js +2 -2
  10. package/dist/cli-delegate/{chunk-DHSSP7V3.js → chunk-5AYJGUA3.js} +0 -2
  11. package/dist/cli-delegate/chunk-DIFZBP4G.js +1336 -0
  12. package/dist/cli-delegate/{chunk-MT2WOOCY.js → chunk-WY5ZZPGL.js} +2 -2
  13. package/dist/cli-delegate/{chunk-ORJPBER2.js → chunk-X6LZ7W5A.js} +73 -10
  14. package/dist/cli-delegate/cmd_conformance.js +175 -183
  15. package/dist/cli-delegate/cmd_doctor.js +5 -5
  16. package/dist/cli-delegate/cmd_doctor_neighbours.js +3 -3
  17. package/dist/cli-delegate/cmd_explain.js +1 -1
  18. package/dist/cli-delegate/cmd_handoff.js +2 -2
  19. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  20. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  21. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  22. package/dist/cli-delegate/cmd_update.js +24 -13
  23. package/dist/cli-delegate/cmd_upgrade.js +2 -2
  24. package/dist/cli-delegate/dist-25AZAHOP.js +3240 -0
  25. package/dist/cli-delegate/dist-P7MFPMF3.js +411 -0
  26. package/dist/cli-delegate/dist-XAS5ZLQB.js +6 -0
  27. package/dist/cli-delegate/{upgrade_unattended-VVL6JLSX.js → upgrade_unattended-QCWOKTUN.js} +178 -163
  28. package/dist/discovery/deprecation-report.md +1 -1
  29. package/dist/discovery/discovery-manifest.json +3 -3
  30. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  31. package/dist/discovery/discovery-manifest.summary.md +1 -1
  32. package/dist/discovery/dormancy-report.md +1 -1
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +2 -2
  35. package/dist/discovery/trust-report.md +1 -1
  36. package/dist/discovery/workspaces.json +2 -2
  37. package/dist/hooks/code_graph_refresh.chunk.cjs +56 -27
  38. package/dist/hooks/code_graph_refresh.chunk.cjs.sha256 +1 -1
  39. package/dist/hooks/dispatch.js +982 -929
  40. package/dist/hooks/dispatch.sha256 +1 -1
  41. package/dist/hooks/egress_engine.chunk.cjs +56 -6
  42. package/dist/hooks/egress_engine.chunk.cjs.sha256 +1 -1
  43. package/dist/hooks/update_notice.chunk.cjs +914 -0
  44. package/dist/hooks/update_notice.chunk.cjs.sha256 +1 -0
  45. package/dist/mcp/registry-manifest.json +1 -1
  46. package/dist/mcp/server.json +2 -2
  47. package/dist/mcp/server.mjs +71259 -66437
  48. package/dist/scripts/condense.js +9 -6
  49. package/package.json +4 -1
  50. package/src/config/evaluator-budgets.json +9 -8
  51. package/src/config/gate-violation-baselines.json +2 -2
  52. package/src/config/hook-bundle-budget.json +31 -6
  53. package/src/config/packed-binary-manifest.json +9 -0
  54. package/src/config/sigstore/npm_keys.json +50 -0
  55. package/src/config/sigstore/trust-material.json +9 -0
  56. package/src/config/sigstore/trusted_root.json +126 -0
  57. package/src/config/skill-body-budget.json +7 -1
  58. package/src/scripts/_cli/cmd_update.ts +48 -20
  59. package/src/scripts/_cli/cmd_upgrade.ts +7 -1
  60. package/src/scripts/_cli/upgrade_unattended.ts +90 -76
  61. package/src/scripts/_dispatch.bash +3 -2
  62. package/src/scripts/_lib/confusables.ts +0 -19
  63. package/src/scripts/_lib/egress_normalize.ts +53 -23
  64. package/src/scripts/_lib/egress_text.ts +5 -3
  65. package/src/scripts/_lib/egress_token.ts +7 -5
  66. package/src/scripts/_lib/gate_collector_experimental.ts +633 -0
  67. package/src/scripts/_lib/git_common_dir.ts +10 -0
  68. package/src/scripts/_lib/graph_feeder_record.ts +3 -2
  69. package/src/scripts/_lib/install_reach_checks.ts +32 -2
  70. package/src/scripts/_lib/lineage_backfill.ts +136 -0
  71. package/src/scripts/_lib/lineage_ledger.ts +211 -0
  72. package/src/scripts/_lib/lineage_map.ts +55 -0
  73. package/src/scripts/_lib/lineage_query.ts +140 -0
  74. package/src/scripts/_lib/lineage_seed.ts +116 -0
  75. package/src/scripts/_lib/lineage_watch.ts +249 -0
  76. package/src/scripts/_lib/link_crypto.ts +8 -4
  77. package/src/scripts/_lib/production_reach.ts +3 -3
  78. package/src/scripts/_lib/project_registry.ts +227 -0
  79. package/src/scripts/_lib/provenance_verify.ts +14 -242
  80. package/src/scripts/_lib/sigstore_provenance.ts +177 -0
  81. package/src/scripts/_lib/source_id.ts +48 -0
  82. package/src/scripts/_lib/source_shape.ts +4 -0
  83. package/src/scripts/_lib/unattended_gates.ts +6 -1
  84. package/src/scripts/advisories.ts +26 -10
  85. package/src/scripts/ai_council/council_egress.ts +5 -6
  86. package/src/scripts/bench_graph_feeder_latency.ts +10 -3
  87. package/src/scripts/check_dependency_floors.ts +7 -0
  88. package/src/scripts/check_egress_mutations.ts +1 -0
  89. package/src/scripts/code_graph/build.ts +67 -3
  90. package/src/scripts/code_graph/cli.ts +131 -28
  91. package/src/scripts/code_graph/conventions_default.ts +20 -1
  92. package/src/scripts/code_graph/detect.ts +5 -5
  93. package/src/scripts/code_graph/estate_cli.ts +135 -0
  94. package/src/scripts/code_graph/estate_index.ts +394 -0
  95. package/src/scripts/code_graph/estate_manifests.ts +223 -0
  96. package/src/scripts/code_graph/estate_map.ts +141 -0
  97. package/src/scripts/code_graph/extract.ts +4 -1
  98. package/src/scripts/code_graph/extract_more.ts +137 -2
  99. package/src/scripts/code_graph/graph_budget.ts +158 -0
  100. package/src/scripts/code_graph/graph_home.ts +80 -0
  101. package/src/scripts/code_graph/lineage_cli.ts +315 -0
  102. package/src/scripts/code_graph/map.ts +22 -13
  103. package/src/scripts/code_graph/map_model.ts +67 -12
  104. package/src/scripts/code_graph/map_verify.ts +68 -5
  105. package/src/scripts/code_graph/map_viewer/viewer.css +12 -0
  106. package/src/scripts/code_graph/map_viewer/viewer.js +45 -15
  107. package/src/scripts/code_graph/project_ref.ts +25 -0
  108. package/src/scripts/code_graph/projects_cli.ts +254 -0
  109. package/src/scripts/code_graph/providers/catalog.ts +14 -5
  110. package/src/scripts/code_graph/providers/django.ts +52 -9
  111. package/src/scripts/code_graph/providers/index.ts +2 -0
  112. package/src/scripts/code_graph/providers/rails.ts +204 -0
  113. package/src/scripts/code_graph/providers/spring.ts +38 -1
  114. package/src/scripts/code_graph/query.ts +9 -9
  115. package/src/scripts/code_graph/refresh_lock.ts +12 -8
  116. package/src/scripts/code_graph/repo_identity.ts +169 -0
  117. package/src/scripts/code_graph/sqlite_store.ts +22 -25
  118. package/src/scripts/code_graph/types.ts +6 -2
  119. package/src/scripts/condense.ts +6 -6
  120. package/src/scripts/hook_chunks.mjs +7 -0
  121. package/src/scripts/hook_manifest.json +1 -1
  122. package/src/scripts/hook_manifest.yaml +20 -17
  123. package/src/scripts/hooks/code_graph_refresh_hook.ts +10 -7
  124. package/src/scripts/hooks/dispatch_hook.ts +6 -7
  125. package/src/scripts/hooks/host_semantics.ts +72 -0
  126. package/src/scripts/hooks/lazy_concern.ts +4 -0
  127. package/src/scripts/hooks/user_notice.ts +105 -0
  128. package/src/scripts/lint_harvest_provenance.ts +137 -11
  129. package/src/scripts/mcp_server/consumer_tool_catalog.json +69 -15
  130. package/src/scripts/mcp_server/graph_tools.ts +161 -41
  131. package/src/scripts/network_sources.ts +24 -6
  132. package/src/scripts/schemas/provenance-mechanism.schema.json +46 -0
  133. package/src/scripts/update_notice_hook.ts +75 -7
  134. package/src/vendor/grammars/README.md +7 -6
  135. package/src/vendor/grammars/tree-sitter-ruby.wasm +0 -0
  136. /package/dist/cli-delegate/{chunk-FKYZOWVD.js → chunk-QBAFP67O.js} +0 -0
  137. /package/dist/cli-delegate/{chunk-BMGUGSY6.js → chunk-TQG6UMPC.js} +0 -0
@@ -52,7 +52,8 @@ import {
52
52
  setHookStdinOverride,
53
53
  clearHookStdinOverride,
54
54
  } from "./hook_stdin.js";
55
- import { emitFor, type PermissionDecision, type Severity } from "./host_semantics.js";
55
+ import { emitFor, writeStdout, type PermissionDecision, type Severity } from "./host_semantics.js";
56
+ import { collectUserNotice, type NoticeBatch } from "./user_notice.js";
56
57
  import { _concern_permission_verdict, _payload_tool_name, _permission_for } from "./permission_gate.js";
57
58
  export { _concern_permission_verdict, _payload_tool_input, _payload_tool_name, _permission_for, _working_tree_root } from "./permission_gate.js";
58
59
  import { _concern_body_classes, applyPayloadFields, planPayloadShapes } from "./payload_stub.js";
@@ -1264,6 +1265,7 @@ export function main(argv?: string[]): number {
1264
1265
  // stdout-injection; harmless surfacing elsewhere). All other events keep
1265
1266
  // the swallow-stdout contract unchanged.
1266
1267
  const context_blocks: string[] = [];
1268
+ const notices: NoticeBatch = []; // `hooks/user_notice.ts`, never model context
1267
1269
  // Per-concern message for the host emission (P0.1). Kept OUT of
1268
1270
  // feedback_entries on purpose: that record is written to disk with a
1269
1271
  // fixed-field schema (PII-exclusion-by-construction), and a concern's raw
@@ -1376,6 +1378,7 @@ export function main(argv?: string[]): number {
1376
1378
  ) {
1377
1379
  context_blocks.push(injected((reply["context"] as string).trim()));
1378
1380
  }
1381
+ collectUserNotice(notices, concern, reply, injected);
1379
1382
  feedback_entries.push({
1380
1383
  concern: String(concern["name"]),
1381
1384
  exit_code: rc,
@@ -1402,9 +1405,6 @@ export function main(argv?: string[]): number {
1402
1405
  writeConcernTimings(String(envelope["event"] ?? ""), String(envelope["platform"] ?? ""), timing_samples);
1403
1406
  _write_feedback(envelope, session_id, feedback_entries, final_rc, started_at);
1404
1407
  _record_rule_trips(envelope, feedback_entries);
1405
- if (context_blocks.length > 0 && final_rc === EXIT_ALLOW) {
1406
- process.stdout.write(context_blocks.join("\n\n") + "\n");
1407
- }
1408
1408
 
1409
1409
  // P0.1 host-semantics translation. The internal ladder (0/1/2) is written to
1410
1410
  // the feedback dir verbatim above — that record is unchanged. What leaves the
@@ -1427,9 +1427,8 @@ export function main(argv?: string[]): number {
1427
1427
  final_rc,
1428
1428
  _permission_for(args.event, envelope, final_rc, permission_verdicts),
1429
1429
  );
1430
- if (emission.stdout) {
1431
- process.stdout.write(emission.stdout);
1432
- }
1430
+ const context_out = context_blocks.length > 0 && final_rc === EXIT_ALLOW ? context_blocks.join("\n\n") + "\n" : "";
1431
+ writeStdout(args.platform, args.event, context_out, emission, notices, session_id, (s) => process.stdout.write(s));
1433
1432
  if (emission.stderr) {
1434
1433
  process.stderr.write(emission.stderr);
1435
1434
  }
@@ -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
  *
@@ -76,6 +76,10 @@ export const LAZY_CONCERNS: Readonly<Record<string, { readonly chunk: string; re
76
76
  chunk: 'code_graph_refresh.chunk.cjs',
77
77
  source: 'code_graph_refresh_hook',
78
78
  }),
79
+ 'src/scripts/update_notice_hook.ts': Object.freeze({
80
+ chunk: 'update_notice.chunk.cjs',
81
+ source: path.join('..', 'update_notice_hook'),
82
+ }),
79
83
  });
80
84
 
81
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
+ }
@@ -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 };