@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
@@ -37,6 +37,7 @@
37
37
  */
38
38
 
39
39
  import { load_anthropic_key } from '../ai_council/clients.js';
40
+ import { sanitize_text } from './retrieval_sanitize.js';
40
41
  import type {
41
42
  GenerationRequest,
42
43
  GenerationResult,
@@ -140,10 +141,19 @@ export function anthropicGenerator(keyPath: string | null = null): TextGenerator
140
141
  );
141
142
  }
142
143
  const data = (await resp.json()) as AnthropicResponse;
143
- const text = (data.content ?? [])
144
- .filter((b) => b.type === 'text')
145
- .map((b) => b.text ?? '')
146
- .join('');
144
+ // Sanitize AT THE TRANSPORT, which is the choke point and one call site.
145
+ // A flag every caller has to remember is the instruction-only
146
+ // enforcement this package diagnoses in itself, and a partially
147
+ // sanitized world — some callers filtered, some not — is worse than
148
+ // either end state. The floor is a no-op on ordinary text, so what
149
+ // changes here is only a response that carries a hidden-instruction
150
+ // vector; see `docs/contracts/retrieval-read-surfaces.md`.
151
+ const text = sanitize_text(
152
+ (data.content ?? [])
153
+ .filter((b) => b.type === 'text')
154
+ .map((b) => b.text ?? '')
155
+ .join(''),
156
+ );
147
157
  // An empty body is returned AS an empty body rather than repaired here.
148
158
  // The arm's own output contract refuses it and classifies the refusal;
149
159
  // repairing it in the transport would move a decision into the layer
@@ -44,7 +44,9 @@ import {
44
44
  session_state_file,
45
45
  update_json_under_lock,
46
46
  } from '../hooks/state_io.js';
47
+ import { manifest_path } from './installed_tools.js';
47
48
  import { type EnforcementClass, is_enforcement_class } from './obligation_frequency.js';
49
+ import { hasSentinel } from './repo_root.js';
48
50
 
49
51
  /**
50
52
  * Directory holding one ledger file per session.
@@ -61,6 +63,77 @@ export function statePathFor(session_id: string): string {
61
63
  return session_state_file(OBLIGATION_STATE_DIR, session_id);
62
64
  }
63
65
 
66
+ /**
67
+ * WHICH KIND OF TREE WROTE A ROW — the whole vocabulary, and nothing else.
68
+ *
69
+ * `obligation-settle-shadow-bar` is read off `agents/runtime/state/obligations/`,
70
+ * which is one machine's gitignored runtime state. That scope bound lives in the
71
+ * claim's prose and in a roadmap paragraph, and in nothing a reader of the rows
72
+ * can compute. A reading that clears the pre-registered floor would therefore be
73
+ * quotable as evidence about a population nobody separated. This field makes the
74
+ * separation a property of the DATA, so the split is computable from the ledger
75
+ * alone rather than asserted beside it.
76
+ *
77
+ * THREE VALUES, CLOSED, AND A UNION OF LITERALS ON PURPOSE. The record gains one
78
+ * enumerated field and no `payload`, `notes` or `extra` slot, so the privacy
79
+ * property is the SHAPE and not a scrubber somebody has to keep correct — the
80
+ * same by-construction posture `artifact-engagement-recording` states for the
81
+ * telemetry event. A type that cannot hold a path cannot leak one.
82
+ *
83
+ * WHAT IT DELIBERATELY IS NOT. Not a path, not a username, not a hostname, not a
84
+ * machine id, not a git remote. The obvious implementation of "which tree wrote
85
+ * this" is the resolved root's filesystem path, which on a developer machine
86
+ * carries an account name — and because the bar is read by QUOTING rows, one such
87
+ * field would make every row already written unquotable rather than scrubbable.
88
+ * So the field carries a ROLE: what kind of tree this is, never which one.
89
+ */
90
+ export type WriterRole =
91
+ /** The tree carries this package's own `package.json` sentinel — a maintainer checkout. */
92
+ | 'package'
93
+ /** Not the package, but an install manifest is present — a consumer project. */
94
+ | 'consumer'
95
+ /** Neither marker resolved. An honest absence, never a default. */
96
+ | 'unknown';
97
+
98
+ /** The closed set, in one place, so a reader and a writer cannot disagree. */
99
+ export const WRITER_ROLES: readonly WriterRole[] = ['package', 'consumer', 'unknown'];
100
+
101
+ /** Narrowing guard for a value read back off disk. */
102
+ export function isWriterRole(value: unknown): value is WriterRole {
103
+ return typeof value === 'string' && (WRITER_ROLES as readonly string[]).includes(value);
104
+ }
105
+
106
+ /**
107
+ * Classify the root this ledger lives under.
108
+ *
109
+ * ORDER IS THE CONTRACT. `package` is tested FIRST, because a maintainer checkout
110
+ * routinely also carries the install artifacts a consumer has — this repository
111
+ * installs itself — and a consumer-first test would label the maintainer tree
112
+ * `consumer` on exactly the machine the bar is being read from. The sentinel is
113
+ * `hasSentinel` from `repo_root.ts` rather than a second `package.json` reader:
114
+ * the sentinel name lives in one place or it drifts.
115
+ *
116
+ * `consumer` keys on the INSTALL MANIFEST (`agents/installed-tools.lock`, via the
117
+ * canonical `manifest_path` so the `AGENT_CONFIG_INSTALLED_TOOLS` override is
118
+ * honoured) and not on `.agent-settings.yml`. The settings file is something a
119
+ * human writes by hand; the lock is written by the installer and by nothing else.
120
+ * A weaker marker would buy coverage by guessing, which is the one thing this
121
+ * field may not do.
122
+ *
123
+ * NEVER THROWS. A probe that cannot stat returns `unknown`, which is a real
124
+ * answer — the ledger's contract is that an instrument missing a reading is
125
+ * cheaper than a refused turn, and a classifier is no exception.
126
+ */
127
+ export function resolveWriterRole(root: string): WriterRole {
128
+ try {
129
+ if (hasSentinel(root)) return 'package';
130
+ if (fs.existsSync(manifest_path(root))) return 'consumer';
131
+ } catch {
132
+ return 'unknown';
133
+ }
134
+ return 'unknown';
135
+ }
136
+
64
137
  /**
65
138
  * One rule delivered into this session, with the class its frontmatter declares.
66
139
  *
@@ -75,8 +148,21 @@ export interface DeliveredRow {
75
148
  readonly cls: EnforcementClass;
76
149
  /** ISO-8601, second precision. */
77
150
  readonly at: string;
151
+ /** Which kind of tree wrote this. Stamped here, never supplied — see {@link WriterInput}. */
152
+ readonly writer: WriterRole;
78
153
  }
79
154
 
155
+ /**
156
+ * What a CALLER offers, for every row type: the stored shape minus its writer.
157
+ *
158
+ * The writer is stamped by the append functions from the `root` they were
159
+ * already given, and is deliberately not part of any caller's input. A caller
160
+ * that could supply it could supply the wrong one — and a provenance field a
161
+ * caller can set is a provenance field, not provenance. Making it unsettable is
162
+ * cheaper than any check that it was set correctly.
163
+ */
164
+ export type WriterInput<T> = Omit<T, 'writer'>;
165
+
80
166
  /**
81
167
  * One obligation discharged this session.
82
168
  *
@@ -92,6 +178,8 @@ export interface DischargeRow {
92
178
  /** What observed it — a concern name, so a reader can go and check. */
93
179
  readonly by: string;
94
180
  readonly at: string;
181
+ /** Which kind of tree wrote this. Stamped, never supplied. */
182
+ readonly writer: WriterRole;
95
183
  }
96
184
 
97
185
  /**
@@ -109,6 +197,14 @@ export interface ShadowRow {
109
197
  readonly attempt: number;
110
198
  /** Always true today: a row is only written when the detector would refuse. */
111
199
  readonly would_refuse: boolean;
200
+ /**
201
+ * Which kind of tree wrote this. Stamped, never supplied.
202
+ *
203
+ * The shadow array is the one the pre-registered bar actually counts, so
204
+ * this is the field that turns "the corpus is one machine" from a sentence
205
+ * in a claim into something a reader can compute off the rows.
206
+ */
207
+ readonly writer: WriterRole;
112
208
  }
113
209
 
114
210
  /** The on-disk shape of one session's ledger. */
@@ -125,10 +221,11 @@ interface SerialisedRow {
125
221
  rule: string;
126
222
  class: string;
127
223
  at: string;
224
+ writer: WriterRole;
128
225
  }
129
226
 
130
227
  function serialise(row: DeliveredRow): SerialisedRow {
131
- return { rule: row.rule, class: row.cls, at: row.at };
228
+ return { rule: row.rule, class: row.cls, at: row.at, writer: row.writer };
132
229
  }
133
230
 
134
231
  /**
@@ -139,6 +236,16 @@ function serialise(row: DeliveredRow): SerialisedRow {
139
236
  * whose class is outside the closed set must not enter the ledger wearing a
140
237
  * value the vocabulary denies — `is_enforcement_class` is the gate, so a
141
238
  * future widening of the schema cannot leak in through the state file.
239
+ *
240
+ * THE WRITER IS TREATED THE OTHER WAY ROUND, and the asymmetry is deliberate. A
241
+ * row whose CLASS is outside the vocabulary is dropped, because the class is the
242
+ * row's subject matter and a row wearing a denied class is a false statement
243
+ * about what was delivered. A row whose WRITER is absent or unrecognised is KEPT
244
+ * and reads `unknown`, because the writer is provenance ABOUT a delivery that
245
+ * genuinely happened — dropping the row would destroy a real record to punish a
246
+ * missing label, and every row written before this field existed is exactly that
247
+ * case. `unknown` is the honest reading of both, which is why one value covers
248
+ * absent and unresolvable alike.
142
249
  */
143
250
  export function parseRows(loaded: unknown): DeliveredRow[] {
144
251
  if (typeof loaded !== 'object' || loaded === null) return [];
@@ -154,7 +261,8 @@ export function parseRows(loaded: unknown): DeliveredRow[] {
154
261
  if (typeof rule !== 'string' || rule === '') continue;
155
262
  if (typeof at !== 'string' || at === '') continue;
156
263
  if (!is_enforcement_class(cls)) continue;
157
- out.push({ rule, cls, at });
264
+ const writer = e['writer'];
265
+ out.push({ rule, cls, at, writer: isWriterRole(writer) ? writer : 'unknown' });
158
266
  }
159
267
  return out;
160
268
  }
@@ -185,13 +293,14 @@ export function stamp(now: Date = new Date()): string {
185
293
  export function appendDelivered(
186
294
  root: string,
187
295
  session_id: string,
188
- rows: readonly DeliveredRow[],
296
+ rows: readonly WriterInput<DeliveredRow>[],
189
297
  ): number {
190
298
  if (rows.length === 0) return 0;
191
299
  if (is_replay_mode()) return 0;
192
300
  if (session_id.trim() === '') return 0;
193
301
 
194
302
  const target = path.join(root, statePathFor(session_id));
303
+ const writer = resolveWriterRole(root);
195
304
  let added = 0;
196
305
  try {
197
306
  update_json_under_lock<LedgerFile>(target, (loaded) => {
@@ -201,7 +310,7 @@ export function appendDelivered(
201
310
  for (const row of rows) {
202
311
  if (seen.has(row.rule)) continue;
203
312
  seen.add(row.rule);
204
- merged.push(serialise(row));
313
+ merged.push(serialise({ ...row, writer }));
205
314
  added += 1;
206
315
  }
207
316
  if (added === 0) return null; // deliberate no-write
@@ -245,24 +354,25 @@ function carryOver(loaded: Partial<LedgerFile>): Pick<LedgerFile, 'discharged' |
245
354
  export function appendDischarge(
246
355
  root: string,
247
356
  session_id: string,
248
- rows: readonly DischargeRow[],
357
+ rows: readonly WriterInput<DischargeRow>[],
249
358
  ): number {
250
359
  if (rows.length === 0) return 0;
251
360
  if (is_replay_mode()) return 0;
252
361
  if (session_id.trim() === '') return 0;
253
362
 
254
363
  const target = path.join(root, statePathFor(session_id));
364
+ const writer = resolveWriterRole(root);
255
365
  let added = 0;
256
366
  try {
257
367
  update_json_under_lock<LedgerFile>(target, (loaded) => {
258
368
  const existing = Array.isArray(loaded.discharged) ? loaded.discharged : [];
259
- const key = (r: DischargeRow): string => `${r.rule}::${r.by}`;
369
+ const key = (r: WriterInput<DischargeRow>): string => `${r.rule}::${r.by}`;
260
370
  const seen = new Set(existing.map(key));
261
371
  const merged = [...existing];
262
372
  for (const row of rows) {
263
373
  if (seen.has(key(row))) continue;
264
374
  seen.add(key(row));
265
- merged.push(row);
375
+ merged.push({ ...row, writer });
266
376
  added += 1;
267
377
  }
268
378
  if (added === 0) return null;
@@ -320,6 +430,7 @@ export function appendShadow(
320
430
 
321
431
  const fingerprint = [...missing].sort().join('::');
322
432
  const target = path.join(root, statePathFor(session_id));
433
+ const writer = resolveWriterRole(root);
323
434
  let attempt = 0;
324
435
  try {
325
436
  update_json_under_lock<LedgerFile>(target, (loaded) => {
@@ -334,7 +445,7 @@ export function appendShadow(
334
445
  discharged: Array.isArray(loaded.discharged) ? loaded.discharged : [],
335
446
  shadow: [
336
447
  ...existing,
337
- { at: now, missing: [...missing], attempt, would_refuse: true },
448
+ { at: now, missing: [...missing], attempt, would_refuse: true, writer },
338
449
  ],
339
450
  };
340
451
  });
@@ -0,0 +1,271 @@
1
+ /**
2
+ * `probe_inputs` — what the UI-conformance probe read, in a form its reader can
3
+ * re-check without starting a browser.
4
+ *
5
+ * `ui-conformance.json` recorded `generated_at` and nothing about its inputs, so
6
+ * a probe run before the last UI edit was indistinguishable from one run after
7
+ * it. This module is the shared vocabulary that closes that: the probe writes an
8
+ * `inputs` block with a content digest per input, and the design-pass reader
9
+ * recomputes those digests and compares. ONE definition, imported by both sides,
10
+ * because a producer and a consumer with separate copies of a hash rule agree
11
+ * only until one of them is edited.
12
+ *
13
+ * DIGESTS, NOT MTIMES, AND THAT IS THE WHOLE DECISION (D1). The reader already
14
+ * applies an mtime rule to the ui-audit artefact, and reusing it here was the
15
+ * obvious move. It answers the wrong question: an mtime moves on a checkout, a
16
+ * `cp -p`-less copy, or a bare `touch`, so it would report movement on turns
17
+ * where no byte changed — and a reader who learns to ignore a false stale has
18
+ * learned to ignore a real one. A content digest reports exactly what is being
19
+ * asked: are the bytes the probe saw still the bytes on disk?
20
+ *
21
+ * THE TARGET DIGEST IS A TREE DIGEST, and this is the one place the roadmap's
22
+ * own text needed correcting. A UI surface is an entry document plus the styles
23
+ * and scripts beside it; a digest of `index.html` alone would be blind to a CSS
24
+ * edit, which is the single most common UI change there is — and the roadmap's
25
+ * own verify line for step 1.1 names exactly that case. So the record covers the
26
+ * directory the entry document sits in. It costs a small recursive read the
27
+ * probe does not otherwise perform (the browser, not Node, opens `styles.css`),
28
+ * which is a real cost the roadmap's Risk 3 mitigation did not anticipate. It is
29
+ * bounded: a few small text files against a run that launches Chromium and
30
+ * reloads the page dozens of times per node.
31
+ *
32
+ * NO BROWSER IS REACHABLE FROM HERE. This module imports `node:crypto`,
33
+ * `node:fs` and `node:path` and nothing else, which is what lets the hook decide
34
+ * staleness on a host with no browser binaries installed.
35
+ */
36
+ import { createHash } from 'node:crypto';
37
+ import fs from 'node:fs';
38
+ import path from 'node:path';
39
+
40
+ /** Schema of the `inputs` block. A reader that does not know it says unknown. */
41
+ export const INPUTS_SCHEMA = 'ui-conformance-inputs/v1';
42
+
43
+ /**
44
+ * The probe build that wrote the block. Bumped when a change to the probe makes
45
+ * an older artefact's findings incomparable with a newer run's; a reader seeing
46
+ * a value it does not know reports unknown rather than guessing.
47
+ */
48
+ export const PROBE_VERSION = 'ui-conformance-probe/v1';
49
+
50
+ /** The sibling file the probe reads for declared, approved deviations. */
51
+ export const DECLARATIONS_BASENAME = 'conformance.declared.json';
52
+
53
+ /**
54
+ * Bound on a tree digest. A surface directory is a handful of files; anything
55
+ * past this is a checkout root someone pointed the probe at by mistake, and
56
+ * hashing it would cost more than the probe run. Exceeding it is recorded as
57
+ * absent with a reason — never silently truncated, which would produce a digest
58
+ * that looks authoritative and covers an arbitrary subset.
59
+ */
60
+ export const MAX_TREE_FILES = 2000;
61
+
62
+ /** Directories never worth digesting, and ruinous to walk. */
63
+ const SKIP_DIRS = new Set(['node_modules', '.git']);
64
+
65
+ export const INPUT_KEYS = ['target', 'reference', 'declarations'] as const;
66
+ export type InputKey = (typeof INPUT_KEYS)[number];
67
+
68
+ export interface InputRecord {
69
+ /**
70
+ * What was digested — a directory for `tree`, a file for `file`. `null` only
71
+ * when no path was supplied at all, which is itself recorded rather than
72
+ * omitted.
73
+ */
74
+ path: string | null;
75
+ kind: 'tree' | 'file';
76
+ /** `absent` carries a reason and a null digest. Never an omitted key. */
77
+ state: 'read' | 'absent';
78
+ /** `sha256:<hex>` when read, `null` when absent. */
79
+ digest: string | null;
80
+ /** Files folded into a tree digest. Absent on a `file` record. */
81
+ files?: number;
82
+ reason?: string;
83
+ }
84
+
85
+ export interface ProbeInputs {
86
+ schema: string;
87
+ probe: string;
88
+ target: InputRecord;
89
+ reference: InputRecord;
90
+ declarations: InputRecord;
91
+ }
92
+
93
+ const sha256 = (data: Buffer | string): string => `sha256:${createHash('sha256').update(data).digest('hex')}`;
94
+
95
+ const why = (err: unknown): string => {
96
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
97
+ return code ? String(code) : String(err);
98
+ };
99
+
100
+ /**
101
+ * The ONE definition of where a target's declarations file lives. The probe's
102
+ * CLI computed this inline; a second copy here would be a rule that agrees with
103
+ * the probe only until one of the two is edited.
104
+ */
105
+ export function declarationsPathFor(targetEntryFile: string): string {
106
+ return path.join(path.dirname(targetEntryFile), DECLARATIONS_BASENAME);
107
+ }
108
+
109
+ /** Digest one file. Never throws — an unreadable input is a recorded fact. */
110
+ export function digestFile(file: string | null, whenNull = 'no path was supplied'): InputRecord {
111
+ if (!file) return { path: null, kind: 'file', state: 'absent', digest: null, reason: whenNull };
112
+ try {
113
+ return { path: file, kind: 'file', state: 'read', digest: sha256(fs.readFileSync(file)) };
114
+ } catch (err) {
115
+ return { path: file, kind: 'file', state: 'absent', digest: null, reason: `unreadable: ${why(err)}` };
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Digest a directory as one value: every file under it, sorted by relative path,
121
+ * folded path-and-content into a single hash. Sorted because a directory listing
122
+ * order is a filesystem detail, and a digest that moved with it would report
123
+ * movement nobody made.
124
+ *
125
+ * `exclude` drops names recorded as inputs in their own right — the declarations
126
+ * file — so an edit to it moves exactly one digest rather than two.
127
+ */
128
+ export function digestTree(
129
+ dir: string | null,
130
+ exclude: readonly string[] = [],
131
+ whenNull = 'no path was supplied',
132
+ ): InputRecord {
133
+ if (!dir) return { path: null, kind: 'tree', state: 'absent', digest: null, reason: whenNull };
134
+ const skip = new Set(exclude);
135
+ const found: string[] = [];
136
+
137
+ const walk = (rel: string): string | null => {
138
+ let entries: fs.Dirent[];
139
+ try {
140
+ entries = fs.readdirSync(path.join(dir, rel), { withFileTypes: true });
141
+ } catch (err) {
142
+ return `unreadable: ${why(err)}`;
143
+ }
144
+ for (const e of [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
145
+ const child = rel ? `${rel}/${e.name}` : e.name;
146
+ if (rel === '' && skip.has(e.name)) continue;
147
+ if (e.isDirectory()) {
148
+ if (SKIP_DIRS.has(e.name)) continue;
149
+ const failed = walk(child);
150
+ if (failed) return failed;
151
+ continue;
152
+ }
153
+ if (!e.isFile()) continue;
154
+ found.push(child);
155
+ if (found.length > MAX_TREE_FILES) {
156
+ return `directory holds more than ${MAX_TREE_FILES} files — too large to digest`;
157
+ }
158
+ }
159
+ return null;
160
+ };
161
+
162
+ const failure = walk('');
163
+ if (failure) return { path: dir, kind: 'tree', state: 'absent', digest: null, reason: failure };
164
+
165
+ const h = createHash('sha256');
166
+ for (const rel of found.sort()) {
167
+ let body: Buffer;
168
+ try {
169
+ body = fs.readFileSync(path.join(dir, rel));
170
+ } catch (err) {
171
+ return { path: dir, kind: 'tree', state: 'absent', digest: null, reason: `unreadable: ${why(err)}` };
172
+ }
173
+ // The path is folded in alongside the bytes, so a rename moves the digest
174
+ // even when the content is byte-identical.
175
+ h.update(rel).update('\0').update(body).update('\0');
176
+ }
177
+ return {
178
+ path: dir,
179
+ kind: 'tree',
180
+ state: 'read',
181
+ digest: `sha256:${h.digest('hex')}`,
182
+ files: found.length,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * The block the probe writes beside `generated_at`. A target or reference is
188
+ * digested as the surface directory it belongs to; the declarations file is
189
+ * digested on its own.
190
+ */
191
+ export function collectInputs(targetEntryFile: string | null, referenceEntryFile: string | null): ProbeInputs {
192
+ const dirOf = (f: string | null): string | null => (f ? path.dirname(f) : null);
193
+ return {
194
+ schema: INPUTS_SCHEMA,
195
+ probe: PROBE_VERSION,
196
+ target: digestTree(dirOf(targetEntryFile), [DECLARATIONS_BASENAME], 'no --target was supplied'),
197
+ reference: digestTree(
198
+ dirOf(referenceEntryFile),
199
+ [DECLARATIONS_BASENAME],
200
+ 'no --reference was given; nothing was compared against',
201
+ ),
202
+ declarations: targetEntryFile
203
+ ? digestFile(declarationsPathFor(targetEntryFile))
204
+ : digestFile(null, 'no --target was supplied, so no declarations file was looked for'),
205
+ };
206
+ }
207
+
208
+ export type InputComparison =
209
+ | { verdict: 'unknown'; reason: string; moved: readonly InputKey[] }
210
+ | { verdict: 'unchanged'; moved: readonly InputKey[] }
211
+ | { verdict: 'moved'; moved: readonly InputKey[] };
212
+
213
+ const isRecord = (v: unknown): v is InputRecord =>
214
+ typeof v === 'object' && v !== null && 'state' in (v as object) && 'kind' in (v as object);
215
+
216
+ /**
217
+ * Re-read each recorded input and say whether it moved.
218
+ *
219
+ * THREE verdicts, and `unknown` is not a hedge. An artefact written before the
220
+ * `inputs` block existed has nothing to compare, and calling that `moved` would
221
+ * report stale for every pre-existing run on the first turn after this change —
222
+ * Risk 1 of the roadmap. It is a version skew, not evidence of movement, and it
223
+ * is equally not evidence of freshness.
224
+ *
225
+ * `resolve` maps a recorded path to this host's filesystem; the caller owns that
226
+ * because the probe records the path it was given and only the reader knows the
227
+ * root it is reading from.
228
+ */
229
+ export function compareInputs(recorded: unknown, resolve: (p: string) => string): InputComparison {
230
+ if (typeof recorded !== 'object' || recorded === null) {
231
+ return {
232
+ verdict: 'unknown',
233
+ moved: [],
234
+ reason: 'the artefact carries no inputs block — it predates the input digests',
235
+ };
236
+ }
237
+ const r = recorded as Partial<ProbeInputs>;
238
+ if (r.schema !== INPUTS_SCHEMA) {
239
+ return {
240
+ verdict: 'unknown',
241
+ moved: [],
242
+ reason: `inputs schema ${String(r.schema)} is not ${INPUTS_SCHEMA} — this reader cannot compare it`,
243
+ };
244
+ }
245
+ if (r.probe !== PROBE_VERSION) {
246
+ return {
247
+ verdict: 'unknown',
248
+ moved: [],
249
+ reason: `written by ${String(r.probe)}, read by ${PROBE_VERSION} — findings across builds are not comparable`,
250
+ };
251
+ }
252
+
253
+ const moved: InputKey[] = [];
254
+ for (const key of INPUT_KEYS) {
255
+ const rec = r[key];
256
+ if (!isRecord(rec)) {
257
+ return {
258
+ verdict: 'unknown',
259
+ moved: [],
260
+ reason: `the inputs block carries no usable record for ${key}`,
261
+ };
262
+ }
263
+ // An input that was never supplied has nothing to move to or from.
264
+ if (rec.path === null) continue;
265
+ const here = rec.kind === 'tree'
266
+ ? digestTree(resolve(rec.path), [DECLARATIONS_BASENAME])
267
+ : digestFile(resolve(rec.path));
268
+ if (here.state !== rec.state || here.digest !== rec.digest) moved.push(key);
269
+ }
270
+ return moved.length ? { verdict: 'moved', moved } : { verdict: 'unchanged', moved: [] };
271
+ }