@event4u/agent-config 16.0.0 → 16.1.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 (246) 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 +297 -137
  5. package/CONTRIBUTING.md +9 -0
  6. package/README.md +2 -2
  7. package/dist/agent-src/commands/analyze/inbox.md +26 -0
  8. package/dist/agent-src/commands/analyze/roadmap-repos.md +27 -0
  9. package/dist/agent-src/commands/challenge-me/closure.md +178 -0
  10. package/dist/agent-src/commands/challenge-me.md +9 -2
  11. package/dist/agent-src/commands/council/default.md +25 -5
  12. package/dist/agent-src/commands/feature/plan.md +28 -1
  13. package/dist/agent-src/commands/feature/roadmap.md +28 -1
  14. package/dist/agent-src/commands/implement-ticket.md +27 -0
  15. package/dist/agent-src/commands/jira-ticket.md +27 -0
  16. package/dist/agent-src/commands/pr/create.md +29 -0
  17. package/dist/agent-src/commands/pr/merge.md +60 -9
  18. package/dist/agent-src/commands/roadmap/ai-council.md +7 -1
  19. package/dist/agent-src/commands/roadmap/create.md +74 -27
  20. package/dist/agent-src/commands/roadmap/materialize.md +27 -0
  21. package/dist/agent-src/commands/roadmap/process-full.md +123 -18
  22. package/dist/agent-src/commands/team.md +22 -0
  23. package/dist/agent-src/contexts/authority/scope-growth-ownership.md +80 -0
  24. package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +47 -0
  25. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +44 -3
  26. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +15 -15
  27. package/dist/agent-src/contexts/execution/decision-closure-execution.md +93 -0
  28. package/dist/agent-src/contexts/execution/fix-loop-ladder.md +108 -0
  29. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +4 -2
  30. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +75 -6
  31. package/dist/agent-src/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  32. package/dist/agent-src/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  33. package/dist/agent-src/guidelines/design-fidelity-routing.md +29 -0
  34. package/dist/agent-src/rules/autonomous-execution.md +15 -8
  35. package/dist/agent-src/rules/delegation-policy.md +23 -45
  36. package/dist/agent-src/rules/design-fidelity.md +32 -37
  37. package/dist/agent-src/rules/evaluator-independence.md +43 -49
  38. package/dist/agent-src/rules/test-first.md +41 -0
  39. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
  40. package/dist/agent-src/skills/ai-council/SKILL.md +11 -3
  41. package/dist/agent-src/skills/api-design/data/manifest.json +3 -3
  42. package/dist/agent-src/skills/database/data/manifest.json +4 -4
  43. package/dist/agent-src/skills/database/data/query-tuning.csv +3 -3
  44. package/dist/agent-src/skills/design-review/SKILL.md +50 -0
  45. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +14 -4
  46. package/dist/agent-src/skills/experiment-loop/SKILL.md +31 -1
  47. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  48. package/dist/agent-src/skills/storybook-workshop/SKILL.md +42 -3
  49. package/dist/agent-src/skills/threat-modeling/SKILL.md +9 -4
  50. package/dist/agent-src/skills/threat-modeling/data/manifest.json +4 -4
  51. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +37 -3
  52. package/dist/agent-src/templates/agent-settings.md +1 -1
  53. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  54. package/dist/agent-src/templates/roadmaps.md +188 -0
  55. package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  56. package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  57. package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  58. package/dist/cli-delegate/check_update_banner.js +40 -2
  59. package/dist/cli-delegate/chunk-4E2W63DA.js +335 -0
  60. package/dist/cli-delegate/chunk-5R7HOLAF.js +99 -0
  61. package/dist/cli-delegate/{chunk-IVBPRCQO.js → chunk-6KFEC3G7.js} +1 -1
  62. package/dist/cli-delegate/{chunk-2TAQ2K4V.js → chunk-6KM6ORRY.js} +55 -386
  63. package/dist/cli-delegate/{chunk-2XY7ILBT.js → chunk-FC75PGJE.js} +42 -2
  64. package/dist/cli-delegate/{chunk-GYC3VRYN.js → chunk-FXYUYU4N.js} +57 -13
  65. package/dist/cli-delegate/{chunk-55ACLDUE.js → chunk-GJCBB2UG.js} +168 -10
  66. package/dist/cli-delegate/{chunk-AK6HODNQ.js → chunk-HDVVGPQW.js} +873 -539
  67. package/dist/cli-delegate/{chunk-H32DGUJH.js → chunk-P6ZHP5W2.js} +1 -1
  68. package/dist/cli-delegate/{chunk-57PN4GYJ.js → chunk-RGNIWTEA.js} +1 -1
  69. package/dist/cli-delegate/chunk-ROYYTEZU.js +39 -0
  70. package/dist/cli-delegate/{chunk-3FKR64R5.js → chunk-UNCRJBT3.js} +31 -7
  71. package/dist/cli-delegate/{chunk-2DK2VJWG.js → chunk-WGXTVKCQ.js} +20 -6
  72. package/dist/cli-delegate/chunk-YDPILCOL.js +152 -0
  73. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  74. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  75. package/dist/cli-delegate/cmd_conformance.js +90 -56
  76. package/dist/cli-delegate/cmd_doctor.js +7 -5
  77. package/dist/cli-delegate/cmd_explain.js +2 -2
  78. package/dist/cli-delegate/cmd_export.js +9 -7
  79. package/dist/cli-delegate/cmd_fleet.js +4 -2
  80. package/dist/cli-delegate/cmd_handoff.js +4 -4
  81. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  82. package/dist/cli-delegate/cmd_migrate.js +2 -2
  83. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  84. package/dist/cli-delegate/cmd_preflight.js +4 -2
  85. package/dist/cli-delegate/cmd_prune.js +10 -8
  86. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  87. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  88. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  89. package/dist/cli-delegate/cmd_settings_get.js +28 -2
  90. package/dist/cli-delegate/cmd_settings_migrate.js +9 -7
  91. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  92. package/dist/cli-delegate/cmd_sync.js +10 -8
  93. package/dist/cli-delegate/cmd_uninstall.js +16 -13
  94. package/dist/cli-delegate/cmd_update.js +1 -1
  95. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  96. package/dist/cli-delegate/cmd_validate.js +12 -10
  97. package/dist/cli-delegate/cmd_versions.js +1 -1
  98. package/dist/discovery/deprecation-report.md +1 -1
  99. package/dist/discovery/discovery-manifest.json +85 -38
  100. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  101. package/dist/discovery/discovery-manifest.summary.md +4 -4
  102. package/dist/discovery/dormancy-report.md +1 -1
  103. package/dist/discovery/orphan-report.md +1 -1
  104. package/dist/discovery/packs.json +10 -8
  105. package/dist/discovery/trust-report.md +3 -3
  106. package/dist/discovery/workspaces.json +8 -6
  107. package/dist/hooks/dispatch.js +1832 -1007
  108. package/dist/install/claudeRuleRewrite.js +14 -3
  109. package/dist/install/conflict.js +60 -10
  110. package/dist/install/conflictTracker.js +218 -0
  111. package/dist/install/fsPrimitives.js +54 -0
  112. package/dist/install/install.mjs +2007 -1481
  113. package/dist/install/manifestFiles.js +259 -0
  114. package/dist/install/preflight.js +14 -2
  115. package/dist/install/preserve.js +157 -0
  116. package/dist/install/recordedOwnership.js +132 -0
  117. package/dist/install/txlog.js +11 -2
  118. package/dist/mcp/registry-manifest.json +3 -3
  119. package/dist/mcp/server.json +2 -2
  120. package/dist/mcp/server.mjs +398 -39
  121. package/dist/router.json +1 -1
  122. package/dist/scripts/_lib/agent_settings.js +3 -2
  123. package/dist/scripts/_lib/fs_atomic.js +167 -0
  124. package/dist/scripts/_lib/installed_tools.js +460 -0
  125. package/dist/scripts/_lib/lean_projection_mode.js +3 -2
  126. package/dist/scripts/_lib/settings_renamed_keys.js +78 -0
  127. package/dist/server/routes/install.js +8 -0
  128. package/dist/server/routes/wizard.js +9 -3
  129. package/dist/server/schemas/settings.js +17 -5
  130. package/dist/shared/missionExecution.js +97 -0
  131. package/dist/ui/assets/{index-D0jaCRPM.js → index-DH1xc62v.js} +17 -17
  132. package/dist/ui/index.html +1 -1
  133. package/docs/MIGRATION.md +103 -0
  134. package/docs/contracts/settings-classes.md +26 -7
  135. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  136. package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  137. package/docs/guidelines/design-fidelity-routing.md +29 -0
  138. package/package.json +1 -1
  139. package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  140. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  141. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  142. package/src/config/agent-settings.template.yml +139 -27
  143. package/src/config/auto-rule-budget.json +16 -0
  144. package/src/config/ci-local-parity.yml +18 -5
  145. package/src/config/continuity-surface.json +21 -0
  146. package/src/config/gate-coverage.yml +146 -13
  147. package/src/config/gate-violation-baselines.json +6 -6
  148. package/src/config/hook-token-budget.json +2 -0
  149. package/src/config/loop-surfaces.yaml +215 -0
  150. package/src/config/platform-anchor.json +3 -2
  151. package/src/config/release-gate-locality.yml +11 -0
  152. package/src/config/routing-coverage-seed.json +18 -4
  153. package/src/config/rule-enforcement-baseline.json +0 -1
  154. package/src/install/claudeRuleRewrite.ts +16 -2
  155. package/src/install/conflict.ts +65 -9
  156. package/src/install/conflictTracker.ts +299 -0
  157. package/src/install/fsPrimitives.ts +54 -0
  158. package/src/install/manifestFiles.ts +312 -0
  159. package/src/install/preflight.ts +16 -2
  160. package/src/install/preserve.ts +205 -0
  161. package/src/install/recordedOwnership.ts +149 -0
  162. package/src/install/txlog.ts +11 -2
  163. package/src/install/types.ts +19 -4
  164. package/src/scripts/_cli/cmd_conformance.ts +88 -15
  165. package/src/scripts/_cli/cmd_doctor.ts +4 -3
  166. package/src/scripts/_cli/cmd_route_explain.ts +9 -2
  167. package/src/scripts/_cli/cmd_settings_get.ts +44 -0
  168. package/src/scripts/_cli/doctor_execution.ts +90 -0
  169. package/src/scripts/_lib/activation_payload.ts +247 -0
  170. package/src/scripts/_lib/agent_settings.ts +4 -2
  171. package/src/scripts/_lib/authority_path.ts +102 -0
  172. package/src/scripts/_lib/cascade_base.ts +94 -0
  173. package/src/scripts/_lib/changelog_eras.ts +7 -2
  174. package/src/scripts/_lib/continuation_ladder.ts +149 -2
  175. package/src/scripts/_lib/council_transport.ts +129 -0
  176. package/src/scripts/_lib/delivery_ready.ts +112 -0
  177. package/src/scripts/_lib/forge_protection.ts +267 -0
  178. package/src/scripts/_lib/hook_platform_keys.ts +27 -0
  179. package/src/scripts/_lib/json_python_parity.ts +127 -0
  180. package/src/scripts/_lib/lean_projection_mode.ts +14 -3
  181. package/src/scripts/_lib/loop_surfaces.ts +382 -0
  182. package/src/scripts/_lib/mission_record.ts +190 -0
  183. package/src/scripts/_lib/obligation_frequency.ts +217 -0
  184. package/src/scripts/_lib/obligations.ts +402 -0
  185. package/src/scripts/_lib/orchestration_record.ts +11 -20
  186. package/src/scripts/_lib/release_highlights.ts +199 -10
  187. package/src/scripts/_lib/release_holds.ts +493 -0
  188. package/src/scripts/_lib/release_migration_gate.ts +184 -0
  189. package/src/scripts/_lib/rides_along.ts +102 -0
  190. package/src/scripts/_lib/settings_renamed_keys.ts +82 -0
  191. package/src/scripts/_lib/test_delta.ts +166 -0
  192. package/src/scripts/_lib/test_provenance.ts +171 -0
  193. package/src/scripts/_lib/typed_op_grant.ts +246 -0
  194. package/src/scripts/_lib/typed_op_watch.ts +185 -0
  195. package/src/scripts/ai_council/config.ts +8 -11
  196. package/src/scripts/ai_council/fallback_config.ts +5 -3
  197. package/src/scripts/ai_council/ownership_classes.ts +118 -0
  198. package/src/scripts/ask_block_census.ts +285 -15
  199. package/src/scripts/check_enforcement_matrix.ts +624 -0
  200. package/src/scripts/check_gate_reachability.ts +173 -10
  201. package/src/scripts/check_generator_sync.ts +954 -0
  202. package/src/scripts/check_preamble_payload_budget.ts +75 -2
  203. package/src/scripts/check_release_highlights.ts +33 -1
  204. package/src/scripts/check_release_holds.ts +567 -0
  205. package/src/scripts/check_rule_projection_integrity.ts +22 -12
  206. package/src/scripts/check_test_delta.ts +294 -0
  207. package/src/scripts/check_test_weakening.ts +299 -0
  208. package/src/scripts/closure_scan.ts +333 -0
  209. package/src/scripts/compile_hook_manifest.ts +43 -3
  210. package/src/scripts/council_record_shape.ts +209 -0
  211. package/src/scripts/hook_manifest.json +1 -1
  212. package/src/scripts/hook_manifest.yaml +88 -9
  213. package/src/scripts/hooks/concern_registry.ts +4 -0
  214. package/src/scripts/hooks/design_pass_hook.ts +128 -3
  215. package/src/scripts/hooks/evidence_independence.ts +96 -0
  216. package/src/scripts/hooks/memory_recall_hook.ts +540 -0
  217. package/src/scripts/hooks/obligation_settle_hook.ts +182 -0
  218. package/src/scripts/hooks/rule_inject_hook.ts +49 -1
  219. package/src/scripts/hooks/run_continuation_hook.ts +17 -20
  220. package/src/scripts/hooks_doctor.ts +85 -0
  221. package/src/scripts/hooks_status.ts +19 -0
  222. package/src/scripts/install.ts +106 -206
  223. package/src/scripts/janitor.ts +7 -0
  224. package/src/scripts/lint_decision_classes.ts +464 -0
  225. package/src/scripts/lint_hook_manifest.ts +11 -0
  226. package/src/scripts/lint_major_migration_sections.ts +450 -0
  227. package/src/scripts/lint_plan_risk_register.ts +47 -3
  228. package/src/scripts/lint_roadmap_blockers.ts +61 -0
  229. package/src/scripts/lint_roadmap_producers.ts +202 -0
  230. package/src/scripts/measure_rule_budget.ts +101 -5
  231. package/src/scripts/new_roadmap.ts +37 -0
  232. package/src/scripts/orchestration_record.ts +0 -5
  233. package/src/scripts/release.ts +14 -98
  234. package/src/scripts/release_drill.ts +267 -1
  235. package/src/scripts/release_publication.ts +51 -0
  236. package/src/scripts/report_enforcement_drift.ts +566 -0
  237. package/src/scripts/report_host_injection_effect.ts +3 -11
  238. package/src/scripts/report_standing_payload_by_host.ts +26 -152
  239. package/src/scripts/schemas/agent-settings.schema.json +23 -0
  240. package/src/scripts/schemas/command.schema.json +4 -0
  241. package/src/scripts/scope_growth.ts +219 -0
  242. package/src/scripts/trigger_eval_grandfather.json +0 -1
  243. package/src/scripts/ui_conformance_probe.ts +622 -0
  244. package/src/server/schemas/settings.ts +31 -7
  245. package/src/shared/missionExecution.ts +126 -0
  246. package/dist/cli-delegate/chunk-523HCFYO.js +0 -90
@@ -80,6 +80,223 @@ export function is_frequency(v: unknown): v is Frequency {
80
80
  return typeof v === 'string' && (FREQUENCIES as readonly string[]).includes(v);
81
81
  }
82
82
 
83
+ // ------------------------------------------------- declared enforcement class
84
+
85
+ /**
86
+ * The CLOSED set of classes a rule may DECLARE in `enforced_by:`.
87
+ *
88
+ * It lives here, beside `Frequency`, because these are the two halves of one
89
+ * question and splitting them across files is how a third taxonomy gets born.
90
+ * `enforced_by` says *what kind of thing carries this*; `obligation_frequency`
91
+ * says *how often it comes due*. Neither answers the other, and
92
+ * `check_enforcement_coverage.ts` joins both.
93
+ *
94
+ * DECLARED IS NOT RESOLVED, and conflating them is the live trap.
95
+ * This set is what an AUTHOR may write. It is deliberately NARROWER than
96
+ * `Resolution` in `check_enforcement_coverage.ts:79-88`, which is what the
97
+ * RESOLVER may conclude. Four of the resolver's values — `validator-local`,
98
+ * `unwired`, `missing`, and resolved-`observer` — are findings about whether
99
+ * the declared carrier is actually reachable. No author can declare them,
100
+ * because they are not facts about intent.
101
+ *
102
+ * The asymmetry is load-bearing rather than accidental. A rule declaring
103
+ * `hook:design-pass` whose hook is `fail_closed: false` resolves to `observer`
104
+ * (`check_enforcement_coverage.ts:421-422`). Measured 2026-09-13: 11 declared
105
+ * hooks become 1 effective hook and 10 effective observers exactly this way.
106
+ * A reader who takes the resolved table for the declared one concludes the
107
+ * frontmatter is lying when it is merely being resolved.
108
+ *
109
+ * `observer` AND `none` ARE FIRST-CLASS HERE, and always were.
110
+ * Recorded because a proposed five-value taxonomy had no slot for `observer`,
111
+ * and adopting it would have silently remapped a value the schema already
112
+ * accepts and the census already counts. The falsifier is the schema pattern
113
+ * itself (`src/scripts/schemas/rule.schema.json`, `enforced_by.items.pattern`),
114
+ * which admits `observer:<reason>` and bare `none`. There was no decision to
115
+ * take: the set below is a transcription of a constraint the tree enforces,
116
+ * not a new vocabulary competing with it.
117
+ *
118
+ * Keep this in sync with that pattern. It is the same set written twice — once
119
+ * where JSON Schema can check authored frontmatter, once where TypeScript can
120
+ * check code — and `tests/scripts/obligation_frequency.test.ts` pins the
121
+ * two together so the pair cannot drift silently.
122
+ */
123
+ export type EnforcementClass =
124
+ | 'hook'
125
+ | 'validator'
126
+ | 'test'
127
+ | 'observer'
128
+ | 'instruction-only'
129
+ | 'none';
130
+
131
+ export const ENFORCEMENT_CLASSES: readonly EnforcementClass[] = [
132
+ 'hook',
133
+ 'validator',
134
+ 'test',
135
+ 'observer',
136
+ 'instruction-only',
137
+ 'none',
138
+ ] as const;
139
+
140
+ export function is_enforcement_class(v: unknown): v is EnforcementClass {
141
+ return typeof v === 'string' && (ENFORCEMENT_CLASSES as readonly string[]).includes(v);
142
+ }
143
+
144
+ /**
145
+ * The class a single declared `enforced_by:` entry belongs to.
146
+ *
147
+ * `null` for anything outside the closed set — never a guess and never a
148
+ * silent `none`, because `none` is itself a meaningful declaration ("recorded
149
+ * gap") and returning it for an unparseable string would convert a schema
150
+ * violation into a legitimate-looking record.
151
+ *
152
+ * Bare `none` is matched exactly; every other class carries a `:` payload the
153
+ * schema requires to be non-empty.
154
+ */
155
+ export function enforcement_class_of(declared: string): EnforcementClass | null {
156
+ const trimmed = declared.trim();
157
+ if (trimmed === 'none') return 'none';
158
+ const colon = trimmed.indexOf(':');
159
+ if (colon <= 0) return null;
160
+ const head = trimmed.slice(0, colon);
161
+ if (!is_enforcement_class(head) || head === 'none') return null;
162
+ // A prefix with an empty payload is a schema violation, not a declaration.
163
+ if (trimmed.slice(colon + 1).trim() === '') return null;
164
+ return head;
165
+ }
166
+
167
+ /**
168
+ * The class for a whole rule's `enforced_by:` list.
169
+ *
170
+ * A rule may declare several carriers; the ledger row carries ONE class, so a
171
+ * choice is forced. It takes the STRONGEST by {@link ENFORCEMENT_CLASSES}
172
+ * order, which is the same direction `check_enforcement_coverage.ts`'s `RANK`
173
+ * runs, so the two never disagree about which of two declarations dominates.
174
+ *
175
+ * An empty list — a rule declaring nothing at all — is `none`. That is the
176
+ * honest reading: the roadmap step requiring this says the row carries `none`
177
+ * when the rule declares none, and a rule with no declaration has made no
178
+ * claim this could contradict.
179
+ */
180
+ /**
181
+ * Classes whose discharge a machine could, in principle, observe.
182
+ *
183
+ * The discriminator is not severity — it is whether a mechanical discharge
184
+ * EXISTS to look for. `hook`, `validator` and `test` name a carrier that runs
185
+ * and leaves a trace. `observer`, `instruction-only` and `none` name,
186
+ * respectively, an instrument that cannot block, an honestly model-carried
187
+ * obligation, and no carrier at all — none of the three produces an artefact a
188
+ * detector could read, so a detector refusing on one would be demanding
189
+ * evidence nobody can produce.
190
+ *
191
+ * Measured 2026-09-13 over the frozen routing corpus: 69 of 97 delivered rules
192
+ * are class `none`. A detector without this filter would fire on the large
193
+ * majority of every turn, which is the "one false block and the operator
194
+ * disables the carrier for good" failure the risk register ranks first.
195
+ *
196
+ * The source set's proposed taxonomy also named a `judge` class that never
197
+ * refuses. It has no representation here because it is not in the declared
198
+ * vocabulary the schema admits, and adding a value to make a sentence
199
+ * transcribe literally would be inventing the second taxonomy this work exists
200
+ * to avoid. Its INTENT — a class whose discharge is a human judgement and so
201
+ * must never be refused on — is carried by `observer` and `instruction-only`,
202
+ * both excluded below.
203
+ */
204
+ export const REFUSABLE_CLASSES: ReadonlySet<EnforcementClass> = new Set<EnforcementClass>([
205
+ 'hook',
206
+ 'validator',
207
+ 'test',
208
+ ]);
209
+
210
+ /** Whether a detector may ever refuse on this class. */
211
+ export function may_refuse_on(cls: EnforcementClass): boolean {
212
+ return REFUSABLE_CLASSES.has(cls);
213
+ }
214
+
215
+ /**
216
+ * The class declared by a rule's frontmatter, read straight off the body text.
217
+ *
218
+ * Lives here rather than in the ledger that calls it, so every piece of
219
+ * reasoning about the class vocabulary sits in one module — the roadmap's
220
+ * second-ranked risk is this set drifting from the census that ratchets it,
221
+ * and two readers in two files is the first step of that drift.
222
+ *
223
+ * Deliberately NOT a general YAML parser and deliberately not the census's
224
+ * `read_frontmatter`: this runs inside a hook on the prompt path, where the
225
+ * cost of importing the coverage module's whole wiring corpus is paid on every
226
+ * fire, and the only key it needs is one list. The accepted shapes are exactly
227
+ * the two the rule schema permits — inline `enforced_by: [a, b]` and a block
228
+ * of `- ` items.
229
+ *
230
+ * A rule that declares nothing is `none`, which is the honest reading: it has
231
+ * made no claim, so there is none to contradict.
232
+ */
233
+ export function enforcement_class_from_frontmatter(text: string): EnforcementClass {
234
+ if (!text.startsWith('---\n')) return 'none';
235
+ const end = text.indexOf('\n---\n', 4);
236
+ if (end === -1) return 'none';
237
+ const lines = text.slice(4, end).split('\n');
238
+
239
+ const entries: string[] = [];
240
+ let collecting = false;
241
+ for (const line of lines) {
242
+ if (collecting) {
243
+ const item = /^\s+-\s*(.+?)\s*$/.exec(line);
244
+ if (item !== null) {
245
+ entries.push(unquote(item[1] as string));
246
+ continue;
247
+ }
248
+ // Any non-item line ends the block. Checked before anything else so
249
+ // a following key can never be read as a carrier declaration.
250
+ if (/^\S/.test(line)) collecting = false;
251
+ if (!collecting && /^\s*$/.test(line)) continue;
252
+ if (!collecting) break;
253
+ continue;
254
+ }
255
+ const m = /^enforced_by:\s*(.*)$/.exec(line);
256
+ if (m === null) continue;
257
+ const rest = (m[1] ?? '').trim();
258
+ if (rest === '') {
259
+ collecting = true;
260
+ continue;
261
+ }
262
+ const inline = /^\[(.*)\]$/.exec(rest);
263
+ if (inline !== null) {
264
+ for (const part of (inline[1] as string).split(',')) {
265
+ const v = unquote(part.trim());
266
+ if (v !== '') entries.push(v);
267
+ }
268
+ } else {
269
+ entries.push(unquote(rest));
270
+ }
271
+ break;
272
+ }
273
+ return enforcement_class_for(entries);
274
+ }
275
+
276
+ /** Strip one layer of matching quotes, which the schema permits on any entry. */
277
+ function unquote(raw: string): string {
278
+ const s = raw.trim();
279
+ if (s.length >= 2 && ((s[0] === '"' && s.endsWith('"')) || (s[0] === "'" && s.endsWith("'")))) {
280
+ return s.slice(1, -1);
281
+ }
282
+ return s;
283
+ }
284
+
285
+ export function enforcement_class_for(declared: readonly string[]): EnforcementClass {
286
+ let best: EnforcementClass = 'none';
287
+ let bestRank = ENFORCEMENT_CLASSES.length;
288
+ for (const entry of declared) {
289
+ const cls = enforcement_class_of(entry);
290
+ if (cls === null) continue;
291
+ const rank = ENFORCEMENT_CLASSES.indexOf(cls);
292
+ if (rank < bestRank) {
293
+ bestRank = rank;
294
+ best = cls;
295
+ }
296
+ }
297
+ return best;
298
+ }
299
+
83
300
  export type Root = 'lifecycle' | 'tool-call' | 'repository' | 'external-event' | 'none';
84
301
 
85
302
  const ROOT_OF: Record<Frequency, Root> = {
@@ -0,0 +1,402 @@
1
+ /**
2
+ * The obligation ledger — write side.
3
+ *
4
+ * This repository already knows which obligations it delivered into a turn.
5
+ * `hooks/rule_inject_hook.ts` writes one record per session naming every rule
6
+ * whose body it injected, and until now the only reads of that path were the
7
+ * writer itself and its own test. The tree could say what it delivered and
8
+ * nothing could ask whether the turn discharged any of it.
9
+ *
10
+ * This module is the state that closes that gap, and ONLY the state. It adds
11
+ * no reader, no detector and no refusal — those arrive in later phases of
12
+ * `road-to-a-ledger-that-closes-the-loop`, deliberately after the rows exist,
13
+ * so that a detector is written against real recorded shapes instead of
14
+ * against an imagined one.
15
+ *
16
+ * WHAT A `delivered` ROW MEANS, AND WHAT IT MAY NEVER BE CITED FOR.
17
+ * A row records that an EMITTER RAN. It does not record that a rule reached
18
+ * the model, and it may not be cited as evidence that one did. That question
19
+ * is council-locked as blocked-by-architecture (2026-08-31, two convergent
20
+ * seats) on a reproducible probe: an obligation present in the shipped tree
21
+ * was absent from the operator's installed copy, so installation proves
22
+ * availability and not exposure. The lock tested a different mechanism than
23
+ * this one, which is why the write side is allowed at all — but a reader who
24
+ * turns these rows into a compliance claim has made exactly the assertion the
25
+ * council refused.
26
+ *
27
+ * WHY THE PATH COMES FROM A HELPER AND NOT A LITERAL.
28
+ * `statePathFor` below is the same shape as `before_complete_hook.ts:128` —
29
+ * `session_state_file(<dir>, session_id)` — and shares that function rather
30
+ * than reimplementing it. The digest keying matters: it removes the
31
+ * filename-length failure mode for long ids, and it is the property an
32
+ * in-file `session_id` integrity check is written against. A second
33
+ * hand-rolled sanitiser here would be a second shape, and two shapes for one
34
+ * concept is how a session's state becomes unfindable by the tool meant to
35
+ * read it.
36
+ */
37
+
38
+ import fs from 'node:fs';
39
+ import path from 'node:path';
40
+
41
+ import {
42
+ atomic_write_json,
43
+ is_replay_mode,
44
+ session_state_file,
45
+ update_json_under_lock,
46
+ } from '../hooks/state_io.js';
47
+ import { type EnforcementClass, is_enforcement_class } from './obligation_frequency.js';
48
+
49
+ /**
50
+ * Directory holding one ledger file per session.
51
+ *
52
+ * Under `agents/runtime/state/`, which is gitignored and janitor-pruned, and
53
+ * which carries a `disposition: excluded` row in
54
+ * `src/config/continuity-surface.json` — this is per-session instrumentation,
55
+ * not continuity state a later session must be able to resume from.
56
+ */
57
+ export const OBLIGATION_STATE_DIR = path.join('agents', 'runtime', 'state', 'obligations');
58
+
59
+ /** Path of one session's ledger. Same shape as the existing consumer. */
60
+ export function statePathFor(session_id: string): string {
61
+ return session_state_file(OBLIGATION_STATE_DIR, session_id);
62
+ }
63
+
64
+ /**
65
+ * One rule delivered into this session, with the class its frontmatter declares.
66
+ *
67
+ * `cls` rather than `class` because `class` is a reserved word, and a field
68
+ * named around a keyword is worse than a field named honestly. The serialised
69
+ * key is `class`, which is what a reader of the JSON expects to see.
70
+ */
71
+ export interface DeliveredRow {
72
+ /** Rule id, as the injector names it. */
73
+ readonly rule: string;
74
+ /** Declared enforcement class, or `none` when the rule declares none. */
75
+ readonly cls: EnforcementClass;
76
+ /** ISO-8601, second precision. */
77
+ readonly at: string;
78
+ }
79
+
80
+ /**
81
+ * One obligation discharged this session.
82
+ *
83
+ * Written by the concern that ALREADY made the decision — the design-pass
84
+ * concern knows the UI audit is fresh, so it is the only thing that can say so
85
+ * without re-deriving a verdict it does not own. A discharge nobody observed
86
+ * is never inferred: absence of a row means nothing was recorded, not that
87
+ * nothing happened, and the shadow rows below are labelled accordingly.
88
+ */
89
+ export interface DischargeRow {
90
+ /** Rule id whose obligation this discharges. */
91
+ readonly rule: string;
92
+ /** What observed it — a concern name, so a reader can go and check. */
93
+ readonly by: string;
94
+ readonly at: string;
95
+ }
96
+
97
+ /**
98
+ * One turn-end reading that WOULD have refused, had anything been armed.
99
+ *
100
+ * The whole point of the shadow phase: the detector runs, the verdict is
101
+ * recorded, and nothing is refused. A row here is evidence about the
102
+ * detector's behaviour, never about the turn's compliance.
103
+ */
104
+ export interface ShadowRow {
105
+ readonly at: string;
106
+ /** The missing SET, as one row. Never one row per obligation — see `appendShadow`. */
107
+ readonly missing: readonly string[];
108
+ /** 1 for the first reading of a given missing set, incrementing after. */
109
+ readonly attempt: number;
110
+ /** Always true today: a row is only written when the detector would refuse. */
111
+ readonly would_refuse: boolean;
112
+ }
113
+
114
+ /** The on-disk shape of one session's ledger. */
115
+ export interface ObligationLedger {
116
+ /** Cheap integrity check against a copied or hand-edited file. */
117
+ readonly session_id: string;
118
+ readonly delivered: readonly DeliveredRow[];
119
+ readonly discharged: readonly DischargeRow[];
120
+ readonly shadow: readonly ShadowRow[];
121
+ }
122
+
123
+ /** The JSON spelling of a row — `class` on disk, `cls` in TypeScript. */
124
+ interface SerialisedRow {
125
+ rule: string;
126
+ class: string;
127
+ at: string;
128
+ }
129
+
130
+ function serialise(row: DeliveredRow): SerialisedRow {
131
+ return { rule: row.rule, class: row.cls, at: row.at };
132
+ }
133
+
134
+ /**
135
+ * Read rows back out of whatever is on disk, discarding anything malformed.
136
+ *
137
+ * Deliberately lenient about the FILE and strict about the ROW. A truncated or
138
+ * hand-edited ledger must not cost the rows that are still intact, and a row
139
+ * whose class is outside the closed set must not enter the ledger wearing a
140
+ * value the vocabulary denies — `is_enforcement_class` is the gate, so a
141
+ * future widening of the schema cannot leak in through the state file.
142
+ */
143
+ export function parseRows(loaded: unknown): DeliveredRow[] {
144
+ if (typeof loaded !== 'object' || loaded === null) return [];
145
+ const raw = (loaded as { delivered?: unknown }).delivered;
146
+ if (!Array.isArray(raw)) return [];
147
+ const out: DeliveredRow[] = [];
148
+ for (const entry of raw) {
149
+ if (typeof entry !== 'object' || entry === null) continue;
150
+ const e = entry as Record<string, unknown>;
151
+ const rule = e['rule'];
152
+ const cls = e['class'];
153
+ const at = e['at'];
154
+ if (typeof rule !== 'string' || rule === '') continue;
155
+ if (typeof at !== 'string' || at === '') continue;
156
+ if (!is_enforcement_class(cls)) continue;
157
+ out.push({ rule, cls, at });
158
+ }
159
+ return out;
160
+ }
161
+
162
+ /** ISO-8601 to second precision — the ledger has no use for milliseconds. */
163
+ export function stamp(now: Date = new Date()): string {
164
+ return `${now.toISOString().slice(0, 19)}Z`;
165
+ }
166
+
167
+ /**
168
+ * Append delivered rows for one session, idempotently per rule.
169
+ *
170
+ * Idempotent because the injector's own contract is once-per-session-per-rule
171
+ * re-armed on compaction: after a compaction a rule is legitimately delivered
172
+ * again, and a second row would say the same thing twice while a reader
173
+ * counting distinct obligations wants it once. First delivery wins, so the
174
+ * timestamp is the one that answers "when did this session first come under
175
+ * this rule".
176
+ *
177
+ * Returns the number of rows actually added — 0 is a real answer (every rule
178
+ * already recorded) and is not a failure.
179
+ *
180
+ * NEVER THROWS, and never fails a turn. A ledger that cannot be written is an
181
+ * instrument that missed a reading; a turn refused because an instrument
182
+ * missed a reading is a worse outcome than the missing reading, and this
183
+ * module has no refusal to offer in the first place.
184
+ */
185
+ export function appendDelivered(
186
+ root: string,
187
+ session_id: string,
188
+ rows: readonly DeliveredRow[],
189
+ ): number {
190
+ if (rows.length === 0) return 0;
191
+ if (is_replay_mode()) return 0;
192
+ if (session_id.trim() === '') return 0;
193
+
194
+ const target = path.join(root, statePathFor(session_id));
195
+ let added = 0;
196
+ try {
197
+ update_json_under_lock<LedgerFile>(target, (loaded) => {
198
+ const existing = parseRows(loaded);
199
+ const seen = new Set(existing.map((r) => r.rule));
200
+ const merged = existing.map(serialise);
201
+ for (const row of rows) {
202
+ if (seen.has(row.rule)) continue;
203
+ seen.add(row.rule);
204
+ merged.push(serialise(row));
205
+ added += 1;
206
+ }
207
+ if (added === 0) return null; // deliberate no-write
208
+ return { ...carryOver(loaded), session_id, delivered: merged };
209
+ });
210
+ } catch {
211
+ return 0;
212
+ }
213
+ return added;
214
+ }
215
+
216
+ /** The on-disk file, as the lock's mutator sees it. */
217
+ interface LedgerFile {
218
+ session_id: string;
219
+ delivered: SerialisedRow[];
220
+ discharged: DischargeRow[];
221
+ shadow: ShadowRow[];
222
+ }
223
+
224
+ /**
225
+ * The arrays this write is not touching, preserved verbatim.
226
+ *
227
+ * Three writers share one file and each owns one array. Without this, the
228
+ * last writer of a turn silently truncates the other two — the kind of loss
229
+ * that shows up as a detector reporting a clean turn because the discharges
230
+ * it was meant to read were dropped by the delivery write that followed them.
231
+ */
232
+ function carryOver(loaded: Partial<LedgerFile>): Pick<LedgerFile, 'discharged' | 'shadow'> {
233
+ return {
234
+ discharged: Array.isArray(loaded.discharged) ? loaded.discharged : [],
235
+ shadow: Array.isArray(loaded.shadow) ? loaded.shadow : [],
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Record that one concern observed an obligation discharged.
241
+ *
242
+ * Idempotent per (rule, by): the design-pass concern may fire many times in a
243
+ * turn and the ledger wants the fact, not the frequency.
244
+ */
245
+ export function appendDischarge(
246
+ root: string,
247
+ session_id: string,
248
+ rows: readonly DischargeRow[],
249
+ ): number {
250
+ if (rows.length === 0) return 0;
251
+ if (is_replay_mode()) return 0;
252
+ if (session_id.trim() === '') return 0;
253
+
254
+ const target = path.join(root, statePathFor(session_id));
255
+ let added = 0;
256
+ try {
257
+ update_json_under_lock<LedgerFile>(target, (loaded) => {
258
+ const existing = Array.isArray(loaded.discharged) ? loaded.discharged : [];
259
+ const key = (r: DischargeRow): string => `${r.rule}::${r.by}`;
260
+ const seen = new Set(existing.map(key));
261
+ const merged = [...existing];
262
+ for (const row of rows) {
263
+ if (seen.has(key(row))) continue;
264
+ seen.add(key(row));
265
+ merged.push(row);
266
+ added += 1;
267
+ }
268
+ if (added === 0) return null;
269
+ return {
270
+ session_id,
271
+ delivered: parseRows(loaded).map(serialise),
272
+ discharged: merged,
273
+ shadow: Array.isArray(loaded.shadow) ? loaded.shadow : [],
274
+ };
275
+ });
276
+ } catch {
277
+ return 0;
278
+ }
279
+ return added;
280
+ }
281
+
282
+ /** Rules discharged this session, by rule id. */
283
+ export function readDischarged(root: string, session_id: string): DischargeRow[] {
284
+ const file = readLedgerFile(root, session_id);
285
+ if (file === null) return [];
286
+ return Array.isArray(file.discharged) ? file.discharged : [];
287
+ }
288
+
289
+ /** Shadow readings recorded this session. */
290
+ export function readShadow(root: string, session_id: string): ShadowRow[] {
291
+ const file = readLedgerFile(root, session_id);
292
+ if (file === null) return [];
293
+ return Array.isArray(file.shadow) ? file.shadow : [];
294
+ }
295
+
296
+ /**
297
+ * Record one would-refuse reading — ONE ROW PER MISSING SET, never per obligation.
298
+ *
299
+ * The aggregation is the contract, not an optimisation. Five missing
300
+ * obligations are one reading of one turn, and emitting five rows would let a
301
+ * later count of "refusals" read five times the truth while every one of them
302
+ * described the same moment.
303
+ *
304
+ * `attempt` increments when the SAME missing set is seen again. An unchanged
305
+ * set on a second reading is the signal that nothing moved — the caller uses
306
+ * it to stop forcing continuation, and it is recorded here so that decision is
307
+ * auditable rather than re-derived.
308
+ *
309
+ * Returns the attempt number written, or 0 when nothing was written.
310
+ */
311
+ export function appendShadow(
312
+ root: string,
313
+ session_id: string,
314
+ missing: readonly string[],
315
+ now: string = stamp(),
316
+ ): number {
317
+ if (missing.length === 0) return 0;
318
+ if (is_replay_mode()) return 0;
319
+ if (session_id.trim() === '') return 0;
320
+
321
+ const fingerprint = [...missing].sort().join('::');
322
+ const target = path.join(root, statePathFor(session_id));
323
+ let attempt = 0;
324
+ try {
325
+ update_json_under_lock<LedgerFile>(target, (loaded) => {
326
+ const existing = Array.isArray(loaded.shadow) ? loaded.shadow : [];
327
+ const prior = existing.filter(
328
+ (r) => [...(r.missing ?? [])].sort().join('::') === fingerprint,
329
+ );
330
+ attempt = prior.length + 1;
331
+ return {
332
+ session_id,
333
+ delivered: parseRows(loaded).map(serialise),
334
+ discharged: Array.isArray(loaded.discharged) ? loaded.discharged : [],
335
+ shadow: [
336
+ ...existing,
337
+ { at: now, missing: [...missing], attempt, would_refuse: true },
338
+ ],
339
+ };
340
+ });
341
+ } catch {
342
+ return 0;
343
+ }
344
+ return attempt;
345
+ }
346
+
347
+ /** The whole file for one session, or `null` when it is absent or not ours. */
348
+ function readLedgerFile(root: string, session_id: string): Partial<LedgerFile> | null {
349
+ if (session_id.trim() === '') return null;
350
+ const target = path.join(root, statePathFor(session_id));
351
+ try {
352
+ const decoded: unknown = JSON.parse(fs.readFileSync(target, 'utf-8'));
353
+ if (typeof decoded !== 'object' || decoded === null) return null;
354
+ if ((decoded as { session_id?: unknown }).session_id !== session_id) return null;
355
+ return decoded as Partial<LedgerFile>;
356
+ } catch {
357
+ return null;
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Rows recorded for one session, or `[]` when there are none to read.
363
+ *
364
+ * A file carrying somebody else's `session_id` reads as empty rather than as
365
+ * that session's rows — a copy, a restore or a hand-edit must not be able to
366
+ * attribute one session's deliveries to another. The producer side repairs
367
+ * the same case by rewriting the id; the consumer side refuses it. Both halves
368
+ * are needed, as `hooks/state_io.ts` § Per-session concern state states for
369
+ * the two concerns that got here first.
370
+ */
371
+ export function readDelivered(root: string, session_id: string): DeliveredRow[] {
372
+ if (session_id.trim() === '') return [];
373
+ const target = path.join(root, statePathFor(session_id));
374
+ try {
375
+ const decoded: unknown = JSON.parse(fs.readFileSync(target, 'utf-8'));
376
+ if (typeof decoded !== 'object' || decoded === null) return [];
377
+ if ((decoded as { session_id?: unknown }).session_id !== session_id) return [];
378
+ return parseRows(decoded);
379
+ } catch {
380
+ return [];
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Whether the ledger directory can be written — the doctor's readiness line.
386
+ *
387
+ * Probes by writing and removing a sentinel rather than by checking mode bits,
388
+ * because the modes that matter here (a read-only mount, a directory owned by
389
+ * another user, a full disk) are not all visible in a `stat`. Returns `false`
390
+ * rather than throwing: the doctor reports, it does not fail.
391
+ */
392
+ export function ledgerWritable(root: string): boolean {
393
+ if (is_replay_mode()) return false;
394
+ const probe = path.join(root, OBLIGATION_STATE_DIR, '.writable-probe.json');
395
+ try {
396
+ atomic_write_json(probe, { probe: true });
397
+ fs.rmSync(probe, { force: true });
398
+ return true;
399
+ } catch {
400
+ return false;
401
+ }
402
+ }