@event4u/agent-config 13.0.0 → 14.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 (123) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +195 -315
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/evals/roadmap.json +8 -0
  5. package/dist/agent-src/commands/fix/ci.md +39 -1
  6. package/dist/agent-src/commands/pr/create.md +65 -4
  7. package/dist/agent-src/commands/roadmap/next.md +28 -5
  8. package/dist/agent-src/commands/work.md +16 -0
  9. package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
  10. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
  11. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
  12. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
  13. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
  14. package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
  15. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
  16. package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
  17. package/dist/agent-src/rules/design-review-after-ui-write.md +25 -8
  18. package/dist/agent-src/rules/ui-audit-gate.md +25 -8
  19. package/dist/agent-src/scripts/gate_execute.ts +395 -0
  20. package/dist/agent-src/scripts/resume_probe.ts +422 -0
  21. package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
  22. package/dist/agent-src/scripts/update_roadmap_progress.ts +53 -2
  23. package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
  24. package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
  25. package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
  26. package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
  27. package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
  28. package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
  29. package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
  30. package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
  31. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  32. package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
  33. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  34. package/dist/agent-src/templates/roadmaps.md +43 -3
  35. package/dist/cli/registry.js +1 -0
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/cli-delegate/{chunk-N7FEST6D.js → chunk-7PZ4FSZB.js} +351 -174
  38. package/dist/cli-delegate/{chunk-3B72LSZL.js → chunk-F3YUF7SA.js} +1 -1
  39. package/dist/cli-delegate/cmd_conformance.js +336 -112
  40. package/dist/cli-delegate/cmd_doctor.js +2 -2
  41. package/dist/cli-delegate/cmd_handoff.js +1 -1
  42. package/dist/discovery/deprecation-report.md +1 -1
  43. package/dist/discovery/discovery-manifest.json +40 -18
  44. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  45. package/dist/discovery/discovery-manifest.summary.md +3 -3
  46. package/dist/discovery/dormancy-report.md +1 -1
  47. package/dist/discovery/orphan-report.md +1 -1
  48. package/dist/discovery/packs.json +6 -5
  49. package/dist/discovery/trust-report.md +2 -2
  50. package/dist/discovery/workspaces.json +5 -4
  51. package/dist/hooks/dispatch.js +2128 -1326
  52. package/dist/mcp/registry-manifest.json +3 -3
  53. package/dist/mcp/server.json +2 -2
  54. package/dist/mcp/server.mjs +1764 -899
  55. package/dist/router.json +1 -1
  56. package/dist/scripts/condense.js +1 -1
  57. package/dist/scripts/condense.js.map +1 -1
  58. package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
  59. package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
  60. package/dist/ui/index.html +1 -1
  61. package/package.json +1 -1
  62. package/src/agent-src/scripts/gate_execute.ts +395 -0
  63. package/src/agent-src/scripts/resume_probe.ts +422 -0
  64. package/src/agent-src/scripts/roadmap_gates.ts +138 -4
  65. package/src/agent-src/scripts/update_roadmap_progress.ts +53 -2
  66. package/src/config/ci-local-parity.yml +13 -0
  67. package/src/config/evaluator-budgets.json +4 -4
  68. package/src/config/gate-coverage.yml +111 -5
  69. package/src/config/gate-violation-baselines.json +5 -5
  70. package/src/config/rule-activation-census.json +41 -0
  71. package/src/config/rule-stub-ceilings.json +52 -0
  72. package/src/scripts/_dispatch.bash +4 -0
  73. package/src/scripts/_lib/evaluator_contract.ts +159 -0
  74. package/src/scripts/_lib/gate_ledger.ts +5 -1
  75. package/src/scripts/_lib/gh_transient.ts +80 -0
  76. package/src/scripts/_lib/release_material.ts +16 -5
  77. package/src/scripts/_lib/review_independence.ts +111 -0
  78. package/src/scripts/_lib/session_register.ts +14 -0
  79. package/src/scripts/_lib/skill_catalogue.ts +20 -0
  80. package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
  81. package/src/scripts/_lib/turn_end_refusals.ts +482 -0
  82. package/src/scripts/ai_council/_py_json.ts +96 -0
  83. package/src/scripts/ai_council/cli_call_budget.ts +357 -0
  84. package/src/scripts/ai_council/clients.ts +80 -143
  85. package/src/scripts/ai_council/config.ts +25 -11
  86. package/src/scripts/ai_council/probe_store.ts +196 -0
  87. package/src/scripts/ai_council/qualification.ts +425 -0
  88. package/src/scripts/ai_council/qualification_wiring.ts +243 -0
  89. package/src/scripts/ai_council/quorum_wiring.ts +211 -0
  90. package/src/scripts/ai_team/team_dispatch.ts +40 -6
  91. package/src/scripts/build_archive_index.ts +62 -3
  92. package/src/scripts/capture_skill_catalogue.ts +264 -4
  93. package/src/scripts/check_evaluator_schema.ts +249 -0
  94. package/src/scripts/check_gate_coverage.ts +93 -0
  95. package/src/scripts/check_pr_ci_current.ts +492 -0
  96. package/src/scripts/check_review_schema.ts +195 -0
  97. package/src/scripts/check_rule_activation_census.ts +451 -0
  98. package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
  99. package/src/scripts/condense.ts +1 -1
  100. package/src/scripts/council_cli.ts +141 -191
  101. package/src/scripts/dispatch_r2_reviewer.ts +8 -1
  102. package/src/scripts/git_authorization_hook.ts +186 -1
  103. package/src/scripts/hook_manifest.yaml +72 -5
  104. package/src/scripts/hooks/block_unauthorized_git.ts +62 -6
  105. package/src/scripts/hooks/concern_registry.ts +4 -0
  106. package/src/scripts/hooks/dispatch_hook.ts +43 -1
  107. package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
  108. package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
  109. package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
  110. package/src/scripts/hooks_doctor.ts +112 -0
  111. package/src/scripts/interruption_report.ts +443 -0
  112. package/src/scripts/lint_evidence_artifacts.ts +359 -0
  113. package/src/scripts/lint_roadmap_blockers.ts +65 -0
  114. package/src/scripts/lint_roadmap_complexity.ts +7 -1
  115. package/src/scripts/release.ts +38 -53
  116. package/src/scripts/rule_activation_census.ts +292 -0
  117. package/src/scripts/schemas/evaluator-output.schema.json +60 -0
  118. package/src/scripts/schemas/review-findings.schema.json +31 -0
  119. package/src/scripts/self_review_gate.ts +6 -0
  120. package/src/scripts/session_register_hook.ts +30 -1
  121. package/src/scripts/sync_pr_branch.ts +271 -0
  122. package/src/scripts/test_council_qualification.ts +213 -0
  123. package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
@@ -0,0 +1,482 @@
1
+ /**
2
+ * `turn-end-gate` refusal accounting — the number a blocking gate owes.
3
+ *
4
+ * `road-to-stop-gate-honesty` § D-2: per-session ordinal state exists, but
5
+ * nothing aggregates refusals into a rate anyone reviews. Every advisory in this
6
+ * estate carries a registered kill standard; the one BLOCKING concern carries
7
+ * none, and a gate that blocks unobserved is the shape that discipline exists to
8
+ * prevent.
9
+ *
10
+ * This module is the reader. It owns three things and deliberately not a fourth:
11
+ *
12
+ * 1. the on-disk record shape, extended so a session's refusals are COUNTED
13
+ * rather than overwritten (§ Phase 1 step 1.1);
14
+ * 2. a TTL prune, which the gate's own header admits is missing (step 1.2);
15
+ * 3. the split by recorded package version, so claim 10's prediction — that
16
+ * refusals correlate with the local 12.1 install — is TESTED rather than
17
+ * assumed (step 1.3).
18
+ *
19
+ * The fourth thing it does not own is a **rate over all sessions**, and the
20
+ * reason is structural rather than an omission. A record is written only when a
21
+ * turn is refused, so a session that was never refused leaves no file at all.
22
+ * The denominator this module can honestly report is *sessions that had at least
23
+ * one refusal*; refusals-per-session-overall would need a per-session marker
24
+ * written on every session, which is a write on the hot Stop path that step 1.1
25
+ * explicitly refuses ("adds no spawn"). `sessionsWithRefusals` is named for what
26
+ * it is so a reader cannot mistake it for the wider denominator.
27
+ *
28
+ * ## Backward compatibility is not optional here
29
+ *
30
+ * The field already holds records in the pre-count shape — 36 of them on the
31
+ * maintainer machine at the time of writing, spanning 2026-08-12 (the day the
32
+ * settings switch was removed) to 2026-08-17. Discarding them would throw away
33
+ * the only field evidence this roadmap has. A legacy record carries one detector
34
+ * and one timestamp, so it counts as exactly ONE refusal of that detector, and
35
+ * `legacyRecords` reports how much of the total came from that weaker shape.
36
+ */
37
+ import * as crypto from 'node:crypto';
38
+ import * as fs from 'node:fs';
39
+ import * as path from 'node:path';
40
+
41
+ import { read_lockfile } from './installed_lock.js';
42
+
43
+ /**
44
+ * The four detectors, in the order the gate runs them.
45
+ *
46
+ * The roadmap's § 0 names three (A promissory, B language, C verification).
47
+ * That was true of the draft and is not true of the tree: detector D
48
+ * (`completion`) landed under round 7 § Phase 1 and is in the same
49
+ * unconditional list as the other three. Counting three would silently drop a
50
+ * detector's refusals, so the set is read off `DetectorId` in the gate rather
51
+ * than off the prose.
52
+ */
53
+ export const DETECTOR_IDS = [
54
+ 'promissory',
55
+ 'language',
56
+ 'verification',
57
+ 'completion',
58
+ ] as const;
59
+
60
+ export type RefusalDetectorId = (typeof DETECTOR_IDS)[number];
61
+
62
+ export type DetectorCounts = Record<RefusalDetectorId, number>;
63
+
64
+ /** State directory, relative to a workspace root. Shared with the gate. */
65
+ export const REFUSAL_STATE_REL = path.join('agents', 'runtime', 'state', 'turn-end-gate');
66
+
67
+ export function refusalStateDir(workspaceRoot: string): string {
68
+ return path.join(workspaceRoot, REFUSAL_STATE_REL);
69
+ }
70
+
71
+ /**
72
+ * The filename stem for a session's refusal record.
73
+ *
74
+ * Defined here rather than in the gate because two modules now need it — the
75
+ * gate writes the file and the session register reads its own session's counts
76
+ * back for live per-session visibility (D-2's "no per-session visibility into
77
+ * how often it happens"). A second sha256 spelled out in the reader is how a
78
+ * reader and a writer end up disagreeing about which file they mean.
79
+ */
80
+ export function deriveSessionKey(sessionId: string): string {
81
+ return crypto.createHash('sha256').update(sessionId).digest('hex').slice(0, 32);
82
+ }
83
+
84
+ export function sessionRefusalFile(workspaceRoot: string, sessionKey: string): string {
85
+ return path.join(refusalStateDir(workspaceRoot), `${sessionKey}.json`);
86
+ }
87
+
88
+ /**
89
+ * This session's own refusal counts, or `null` when it has never been refused.
90
+ *
91
+ * Cheap by construction: one `readFileSync` of a record that is a few hundred
92
+ * bytes, on a path the caller already touches. Step 1.1 requires the counter to
93
+ * ride the existing session-register write and add no spawn, and this is what
94
+ * makes that possible.
95
+ */
96
+ export function readSessionCounts(
97
+ workspaceRoot: string,
98
+ sessionId: string,
99
+ ): DetectorCounts | null {
100
+ try {
101
+ const raw = fs.readFileSync(
102
+ sessionRefusalFile(workspaceRoot, deriveSessionKey(sessionId)),
103
+ 'utf-8',
104
+ );
105
+ const rec = parseRecord(raw);
106
+ return rec === null ? null : countsOf(rec);
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * One session's refusal record.
114
+ *
115
+ * `refused_turn` and `detector` keep their original meaning exactly — the
116
+ * re-entrancy guard reads `refused_turn` and nothing else, so extending this
117
+ * record cannot change whether a turn is refused twice. Everything below them
118
+ * is additive.
119
+ */
120
+ export interface RefusalRecord {
121
+ /** ISO stamp of the MOST RECENT refusal in this session. */
122
+ refused_at: string;
123
+ /** Turn ordinal of the most recent refusal — the re-entrancy marker. */
124
+ refused_turn: number;
125
+ /** First detector of the most recent refusal. Kept for compatibility. */
126
+ detector: RefusalDetectorId;
127
+ /** ISO stamp of the FIRST refusal in this session. Added 2026-08-17. */
128
+ first_refused_at?: string;
129
+ /** Cumulative per-detector refusal counts for this session. */
130
+ counts?: Partial<DetectorCounts>;
131
+ /** Package version recorded at the most recent refusal, when readable. */
132
+ agent_config_version?: string;
133
+ }
134
+
135
+ export function emptyCounts(): DetectorCounts {
136
+ return { promissory: 0, language: 0, verification: 0, completion: 0 };
137
+ }
138
+
139
+ function isDetector(v: unknown): v is RefusalDetectorId {
140
+ return typeof v === 'string' && (DETECTOR_IDS as readonly string[]).includes(v);
141
+ }
142
+
143
+ /**
144
+ * Parse one record. Returns `null` for anything unreadable — a malformed state
145
+ * file must never become an exception on the Stop path, which is the same
146
+ * fail-open contract the gate itself keeps.
147
+ */
148
+ export function parseRecord(raw: string): RefusalRecord | null {
149
+ let decoded: unknown;
150
+ try {
151
+ decoded = JSON.parse(raw);
152
+ } catch {
153
+ return null;
154
+ }
155
+ if (typeof decoded !== 'object' || decoded === null || Array.isArray(decoded)) return null;
156
+ const o = decoded as Record<string, unknown>;
157
+ if (typeof o['refused_at'] !== 'string') return null;
158
+ if (typeof o['refused_turn'] !== 'number') return null;
159
+ if (!isDetector(o['detector'])) return null;
160
+ const rec: RefusalRecord = {
161
+ refused_at: o['refused_at'],
162
+ refused_turn: o['refused_turn'],
163
+ detector: o['detector'],
164
+ };
165
+ if (typeof o['first_refused_at'] === 'string') rec.first_refused_at = o['first_refused_at'];
166
+ if (typeof o['agent_config_version'] === 'string') {
167
+ rec.agent_config_version = o['agent_config_version'];
168
+ }
169
+ const counts = o['counts'];
170
+ if (typeof counts === 'object' && counts !== null && !Array.isArray(counts)) {
171
+ const parsed: Partial<DetectorCounts> = {};
172
+ for (const id of DETECTOR_IDS) {
173
+ const n = (counts as Record<string, unknown>)[id];
174
+ if (typeof n === 'number' && Number.isFinite(n) && n >= 0) parsed[id] = n;
175
+ }
176
+ rec.counts = parsed;
177
+ }
178
+ return rec;
179
+ }
180
+
181
+ /**
182
+ * The per-detector counts a record contributes.
183
+ *
184
+ * A record written before counts existed contributes one refusal of its single
185
+ * recorded detector — the honest floor, since the old shape overwrote itself and
186
+ * cannot say how many times that session was refused.
187
+ */
188
+ export function countsOf(rec: RefusalRecord): DetectorCounts {
189
+ const out = emptyCounts();
190
+ if (rec.counts === undefined) {
191
+ out[rec.detector] = 1;
192
+ return out;
193
+ }
194
+ let any = false;
195
+ for (const id of DETECTOR_IDS) {
196
+ const n = rec.counts[id];
197
+ if (typeof n === 'number' && n > 0) {
198
+ out[id] = n;
199
+ any = true;
200
+ }
201
+ }
202
+ // A counts block that is present but all-zero is a record whose writer knew
203
+ // about counts and recorded none — treat it like the legacy shape rather
204
+ // than reporting a refusal that left no trace of which detector fired.
205
+ if (!any) out[rec.detector] = 1;
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * Fold one refusal into a record. Pure — the caller writes.
211
+ *
212
+ * `detectors` is every finding of THIS refusal, not just the first. The gate
213
+ * stores `findings[0].detector` in `detector` for compatibility, but a turn that
214
+ * trips B and C at once is two detector observations and counting it as one
215
+ * would understate whichever detector lost the tie — precisely the pooling the
216
+ * roadmap's step 1.1 forbids ("per detector separately ... a pooled rate would
217
+ * hide which one is firing").
218
+ */
219
+ export function foldRefusal(
220
+ prev: RefusalRecord | null,
221
+ input: {
222
+ detectors: readonly RefusalDetectorId[];
223
+ turnOrdinal: number;
224
+ at: string;
225
+ version?: string | undefined;
226
+ },
227
+ ): RefusalRecord {
228
+ const counts = prev === null ? emptyCounts() : countsOf(prev);
229
+ for (const d of input.detectors) counts[d] += 1;
230
+ const primary = input.detectors[0] ?? prev?.detector ?? 'verification';
231
+ const rec: RefusalRecord = {
232
+ refused_at: input.at,
233
+ refused_turn: input.turnOrdinal,
234
+ detector: primary,
235
+ first_refused_at: prev?.first_refused_at ?? prev?.refused_at ?? input.at,
236
+ counts,
237
+ };
238
+ if (input.version !== undefined) rec.agent_config_version = input.version;
239
+ else if (prev?.agent_config_version !== undefined) {
240
+ rec.agent_config_version = prev.agent_config_version;
241
+ }
242
+ return rec;
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // The install boundary — step 1.3
247
+ // ---------------------------------------------------------------------------
248
+
249
+ export interface InstallBoundary {
250
+ version: string | null;
251
+ installed_at: string | null;
252
+ }
253
+
254
+ /**
255
+ * The machine's own install stamp, read from `~/.event4u/agent-config/installed.lock`.
256
+ *
257
+ * **What this can and cannot answer, because the difference decides whether
258
+ * step 1.3 tests claim 10 or merely looks like it does.** The lockfile records
259
+ * the version that performed the MOST RECENT install and when — not the date any
260
+ * particular version arrived. On a machine that has since upgraded past the
261
+ * version under suspicion, `installed_at` is the newer install's date and a
262
+ * before/after split on it says nothing about 12.1.
263
+ *
264
+ * That is why the record carries `agent_config_version` at refusal time and why
265
+ * `collectRefusalStats` splits by RECORDED VERSION as its primary axis: a
266
+ * version stamped on the refusal itself is evidence, a date compared against a
267
+ * moving lockfile is an inference. The boundary is still reported, because it is
268
+ * the only thing that dates the corpus written before versions were recorded.
269
+ */
270
+ export function readInstallBoundary(): InstallBoundary {
271
+ try {
272
+ const lock = read_lockfile();
273
+ if (lock === null) return { version: null, installed_at: null };
274
+ return {
275
+ version: lock.agent_config_version ?? null,
276
+ installed_at: lock.installed_at ?? null,
277
+ };
278
+ } catch {
279
+ return { version: null, installed_at: null };
280
+ }
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // TTL — step 1.2
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /**
288
+ * Default retention for refusal state.
289
+ *
290
+ * **A stated default, not a measured optimum.** 90 days is long enough that a
291
+ * per-detector rate can be read over a window rather than an anecdote (AC-1's
292
+ * own requirement) and short enough that a long-lived workspace does not
293
+ * accumulate indefinitely, which is the defect the gate's header admits. It is
294
+ * deliberately far longer than the session-register TTLs, because those bound
295
+ * LIVENESS and this bounds EVIDENCE — pruning evidence on a liveness clock would
296
+ * delete the corpus this roadmap exists to read.
297
+ *
298
+ * *Revisit-if:* a rollup window longer than 90 days is needed to read a rate, or
299
+ * the directory's file count becomes a measured cost rather than a projected one.
300
+ */
301
+ export const REFUSAL_STATE_MAX_AGE_DAYS = 90;
302
+
303
+ export interface PruneResult {
304
+ scanned: number;
305
+ pruned: number;
306
+ kept: number;
307
+ }
308
+
309
+ /**
310
+ * Remove refusal records whose most recent refusal is older than `maxAgeDays`.
311
+ *
312
+ * Age is read from the record's own `refused_at`, never from the filesystem
313
+ * mtime: a `git checkout` or an rsync rewrites mtimes and would prune a live
314
+ * corpus or preserve a dead one at random. A record that cannot be parsed is
315
+ * KEPT — deleting a file this reader cannot understand is the one irreversible
316
+ * move available here, and an unparseable record costs a few bytes.
317
+ */
318
+ export function pruneAgedRefusalState(
319
+ workspaceRoot: string,
320
+ opts: { maxAgeDays?: number; now?: Date } = {},
321
+ ): PruneResult {
322
+ const maxAgeDays = opts.maxAgeDays ?? REFUSAL_STATE_MAX_AGE_DAYS;
323
+ const now = opts.now ?? new Date();
324
+ const dir = refusalStateDir(workspaceRoot);
325
+ const result: PruneResult = { scanned: 0, pruned: 0, kept: 0 };
326
+ let entries: string[];
327
+ try {
328
+ entries = fs.readdirSync(dir);
329
+ } catch {
330
+ return result; // no directory yet — nothing to prune, not an error
331
+ }
332
+ const cutoffMs = now.getTime() - maxAgeDays * 24 * 60 * 60 * 1000;
333
+ for (const name of entries) {
334
+ if (!name.endsWith('.json')) continue;
335
+ result.scanned += 1;
336
+ const file = path.join(dir, name);
337
+ let rec: RefusalRecord | null = null;
338
+ try {
339
+ rec = parseRecord(fs.readFileSync(file, 'utf-8'));
340
+ } catch {
341
+ rec = null;
342
+ }
343
+ if (rec === null) {
344
+ result.kept += 1;
345
+ continue;
346
+ }
347
+ const at = Date.parse(rec.refused_at);
348
+ if (!Number.isFinite(at) || at >= cutoffMs) {
349
+ result.kept += 1;
350
+ continue;
351
+ }
352
+ try {
353
+ fs.unlinkSync(file);
354
+ result.pruned += 1;
355
+ } catch {
356
+ result.kept += 1;
357
+ }
358
+ }
359
+ return result;
360
+ }
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // Aggregation — steps 1.1 and 1.3
364
+ // ---------------------------------------------------------------------------
365
+
366
+ export interface PeriodBucket {
367
+ /** `YYYY-MM-DD`, in UTC. */
368
+ period: string;
369
+ sessions: number;
370
+ total: number;
371
+ byDetector: DetectorCounts;
372
+ }
373
+
374
+ export interface VersionBucket {
375
+ version: string;
376
+ sessions: number;
377
+ total: number;
378
+ byDetector: DetectorCounts;
379
+ }
380
+
381
+ export interface RefusalStats {
382
+ /** Sessions that had AT LEAST ONE refusal — never "all sessions". */
383
+ sessionsWithRefusals: number;
384
+ total: number;
385
+ byDetector: DetectorCounts;
386
+ /** Newest first. */
387
+ byPeriod: PeriodBucket[];
388
+ /** Records still in the pre-count shape; their contribution is a floor. */
389
+ legacyRecords: number;
390
+ /** Records carrying no readable package version. */
391
+ unversionedRecords: number;
392
+ byVersion: VersionBucket[];
393
+ earliest: string | null;
394
+ latest: string | null;
395
+ }
396
+
397
+ function addInto(target: DetectorCounts, source: DetectorCounts): void {
398
+ for (const id of DETECTOR_IDS) target[id] += source[id];
399
+ }
400
+
401
+ function sumOf(counts: DetectorCounts): number {
402
+ let n = 0;
403
+ for (const id of DETECTOR_IDS) n += counts[id];
404
+ return n;
405
+ }
406
+
407
+ /**
408
+ * Aggregate every refusal record under a workspace.
409
+ *
410
+ * Period attribution uses the record's `refused_at` — the session's LAST
411
+ * refusal — for the whole session's counts. A session that straddles midnight
412
+ * therefore lands entirely in the later day. This is an approximation and is
413
+ * named as one: the alternative is a per-refusal timestamp list, which grows
414
+ * without bound inside a file whose whole point is to stay one small record per
415
+ * session.
416
+ */
417
+ export function collectRefusalStats(workspaceRoot: string): RefusalStats {
418
+ const dir = refusalStateDir(workspaceRoot);
419
+ const stats: RefusalStats = {
420
+ sessionsWithRefusals: 0,
421
+ total: 0,
422
+ byDetector: emptyCounts(),
423
+ byPeriod: [],
424
+ legacyRecords: 0,
425
+ unversionedRecords: 0,
426
+ byVersion: [],
427
+ earliest: null,
428
+ latest: null,
429
+ };
430
+ let entries: string[];
431
+ try {
432
+ entries = fs.readdirSync(dir);
433
+ } catch {
434
+ return stats;
435
+ }
436
+ const periods = new Map<string, PeriodBucket>();
437
+ const versions = new Map<string, VersionBucket>();
438
+ for (const name of entries) {
439
+ if (!name.endsWith('.json')) continue;
440
+ let rec: RefusalRecord | null = null;
441
+ try {
442
+ rec = parseRecord(fs.readFileSync(path.join(dir, name), 'utf-8'));
443
+ } catch {
444
+ rec = null;
445
+ }
446
+ if (rec === null) continue;
447
+ const counts = countsOf(rec);
448
+ stats.sessionsWithRefusals += 1;
449
+ if (rec.counts === undefined) stats.legacyRecords += 1;
450
+ addInto(stats.byDetector, counts);
451
+
452
+ const period = rec.refused_at.slice(0, 10);
453
+ let bucket = periods.get(period);
454
+ if (bucket === undefined) {
455
+ bucket = { period, sessions: 0, total: 0, byDetector: emptyCounts() };
456
+ periods.set(period, bucket);
457
+ }
458
+ bucket.sessions += 1;
459
+ addInto(bucket.byDetector, counts);
460
+
461
+ const version = rec.agent_config_version;
462
+ if (version === undefined) stats.unversionedRecords += 1;
463
+ const vkey = version ?? '(unrecorded)';
464
+ let vbucket = versions.get(vkey);
465
+ if (vbucket === undefined) {
466
+ vbucket = { version: vkey, sessions: 0, total: 0, byDetector: emptyCounts() };
467
+ versions.set(vkey, vbucket);
468
+ }
469
+ vbucket.sessions += 1;
470
+ addInto(vbucket.byDetector, counts);
471
+
472
+ const first = rec.first_refused_at ?? rec.refused_at;
473
+ if (stats.earliest === null || first < stats.earliest) stats.earliest = first;
474
+ if (stats.latest === null || rec.refused_at > stats.latest) stats.latest = rec.refused_at;
475
+ }
476
+ stats.total = sumOf(stats.byDetector);
477
+ for (const b of periods.values()) b.total = sumOf(b.byDetector);
478
+ for (const b of versions.values()) b.total = sumOf(b.byDetector);
479
+ stats.byPeriod = [...periods.values()].sort((a, b) => (a.period < b.period ? 1 : -1));
480
+ stats.byVersion = [...versions.values()].sort((a, b) => (a.version < b.version ? 1 : -1));
481
+ return stats;
482
+ }
@@ -0,0 +1,96 @@
1
+ // Python-compatible JSON serialisation, extracted verbatim from `clients.ts`
2
+ // (road-to-council-quota-accounting-truth) so that file drops below the
3
+ // source-size ratchet without changing a byte of behaviour.
4
+ //
5
+ // A frozen byte-parity golden compares the counter state file this writes
6
+ // against output captured from the deleted Python twin, so this is a MOVE and
7
+ // must stay one: `ensure_ascii=True`, insertion-order keys, Python float
8
+ // repr, astral-plane surrogate pairs. Any "cleanup" here forks that golden.
9
+
10
+ /** `json.dumps(obj, indent=2)` — default ensure_ascii=True, insertion-order keys. */
11
+ export function jsonDumpsIndent2(value: unknown): string {
12
+ return _jsonDumpsIndented(value, 2, 0);
13
+ }
14
+
15
+ function _jsonDumpsIndented(value: unknown, indent: number, level: number): string {
16
+ if (value === null || value === undefined) {
17
+ return 'null';
18
+ }
19
+ switch (typeof value) {
20
+ case 'boolean':
21
+ return value ? 'true' : 'false';
22
+ case 'number':
23
+ return _pyJsonNumber(value);
24
+ case 'string':
25
+ return _pyJsonStringAscii(value);
26
+ case 'object':
27
+ break;
28
+ default:
29
+ throw new TypeError(`Object of type ${typeof value} is not JSON serializable`);
30
+ }
31
+ const pad = ' '.repeat(indent * (level + 1));
32
+ const closePad = ' '.repeat(indent * level);
33
+ if (Array.isArray(value)) {
34
+ if (value.length === 0) {
35
+ return '[]';
36
+ }
37
+ const items = value.map((v) => pad + _jsonDumpsIndented(v, indent, level + 1));
38
+ return `[\n${items.join(',\n')}\n${closePad}]`;
39
+ }
40
+ const obj = value as Record<string, unknown>;
41
+ const keys = Object.keys(obj);
42
+ if (keys.length === 0) {
43
+ return '{}';
44
+ }
45
+ const items = keys.map(
46
+ (k) => `${pad}${_pyJsonStringAscii(k)}: ${_jsonDumpsIndented(obj[k], indent, level + 1)}`,
47
+ );
48
+ return `{\n${items.join(',\n')}\n${closePad}}`;
49
+ }
50
+
51
+ /** Render a number like Python `json.dumps` (int vs float; JS has one type). */
52
+ function _pyJsonNumber(n: number): string {
53
+ if (!Number.isFinite(n)) {
54
+ if (Number.isNaN(n)) {
55
+ return 'NaN';
56
+ }
57
+ return n > 0 ? 'Infinity' : '-Infinity';
58
+ }
59
+ return String(n);
60
+ }
61
+
62
+ /** Escape a string like Python `json.dumps(..., ensure_ascii=True)` (default). */
63
+ function _pyJsonStringAscii(s: string): string {
64
+ let out = '"';
65
+ for (const ch of s) {
66
+ const code = ch.codePointAt(0) ?? 0;
67
+ if (ch === '"') {
68
+ out += '\\"';
69
+ } else if (ch === '\\') {
70
+ out += '\\\\';
71
+ } else if (ch === '\b') {
72
+ out += '\\b';
73
+ } else if (ch === '\f') {
74
+ out += '\\f';
75
+ } else if (ch === '\n') {
76
+ out += '\\n';
77
+ } else if (ch === '\r') {
78
+ out += '\\r';
79
+ } else if (ch === '\t') {
80
+ out += '\\t';
81
+ } else if (code < 0x20) {
82
+ out += `\\u${code.toString(16).padStart(4, '0')}`;
83
+ } else if (code < 0x7f) {
84
+ out += ch;
85
+ } else if (code <= 0xffff) {
86
+ out += `\\u${code.toString(16).padStart(4, '0')}`;
87
+ } else {
88
+ // Astral plane → surrogate pair, matching Python json.dumps default.
89
+ const c = code - 0x10000;
90
+ const hi = 0xd800 + (c >> 10);
91
+ const lo = 0xdc00 + (c & 0x3ff);
92
+ out += `\\u${hi.toString(16).padStart(4, '0')}\\u${lo.toString(16).padStart(4, '0')}`;
93
+ }
94
+ }
95
+ return out + '"';
96
+ }