@event4u/agent-config 10.1.0 → 10.3.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 (158) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +110 -167
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/reference-repo.md +62 -0
  5. package/dist/agent-src/commands/roadmap/create.md +4 -1
  6. package/dist/agent-src/commands/roadmap/next.md +38 -0
  7. package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +22 -1
  8. package/dist/agent-src/contexts/contracts/frugality-charter.md +1 -1
  9. package/dist/agent-src/personas/advisors/first-principles.md +6 -0
  10. package/dist/agent-src/rules/active-remediation.md +53 -2
  11. package/dist/agent-src/rules/code-provenance.md +66 -4
  12. package/dist/agent-src/rules/content-quoting-floor.md +6 -0
  13. package/dist/agent-src/rules/context-hygiene.md +39 -0
  14. package/dist/agent-src/rules/design-fidelity.md +49 -3
  15. package/dist/agent-src/rules/token-budget-discipline.md +3 -2
  16. package/dist/agent-src/rules/token-efficiency.md +22 -0
  17. package/dist/agent-src/skills/ai-council/SKILL.md +28 -6
  18. package/dist/agent-src/skills/condense-memory/SKILL.md +1 -1
  19. package/dist/agent-src/skills/design-review/references/verification-automation.md +23 -1
  20. package/dist/agent-src/skills/fe-design/SKILL.md +27 -3
  21. package/dist/agent-src/skills/judge-synthesis/SKILL.md +30 -0
  22. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +11 -0
  23. package/dist/agent-src/skills/roadmap-writing/SKILL.md +9 -9
  24. package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
  25. package/dist/agent-src/skills/token-optimizer/SKILL.md +5 -1
  26. package/dist/agent-src/templates/agent-settings.md +12 -28
  27. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +2 -2
  28. package/dist/agent-src/templates/persona.md +26 -0
  29. package/dist/agent-src/templates/roadmaps.md +27 -7
  30. package/dist/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +0 -1
  31. package/dist/agent-src/templates/skill.md +35 -1
  32. package/dist/cli/commands/commands.candidates.test.js +257 -0
  33. package/dist/cli/commands/commands.candidates.test.js.map +1 -0
  34. package/dist/cli/commands/commands.js +199 -0
  35. package/dist/cli/commands/commands.js.map +1 -1
  36. package/dist/cli/main.js +1 -0
  37. package/dist/cli/main.js.map +1 -1
  38. package/dist/cli/registry.js +2 -2
  39. package/dist/cli/registry.js.map +1 -1
  40. package/dist/cli-delegate/{chunk-3JUTT26I.js → chunk-6ENSXSBW.js} +1 -1
  41. package/dist/cli-delegate/{chunk-TVGXH7VA.js → chunk-6LOPHJLD.js} +1 -1
  42. package/dist/cli-delegate/{chunk-XT2YTJCZ.js → chunk-AYKQGIBG.js} +4 -0
  43. package/dist/cli-delegate/{chunk-HJSKTMIF.js → chunk-BV3WR67D.js} +3 -19
  44. package/dist/cli-delegate/{chunk-HTIU3W56.js → chunk-BWMXLD4B.js} +6 -2
  45. package/dist/cli-delegate/{chunk-2H5SE7DU.js → chunk-FV34EGEX.js} +166 -39
  46. package/dist/cli-delegate/{chunk-XTC5ARB6.js → chunk-IR3IVRVC.js} +7 -2
  47. package/dist/cli-delegate/{chunk-LRXJK6GN.js → chunk-JMQZ7MM5.js} +1 -1
  48. package/dist/cli-delegate/{chunk-NX5AXSLD.js → chunk-X3VOARZD.js} +3 -3
  49. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  50. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  51. package/dist/cli-delegate/cmd_conformance.js +881 -204
  52. package/dist/cli-delegate/cmd_doctor.js +3 -3
  53. package/dist/cli-delegate/cmd_explain.js +2 -2
  54. package/dist/cli-delegate/cmd_export.js +4 -4
  55. package/dist/cli-delegate/cmd_handoff.js +6 -5
  56. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  57. package/dist/cli-delegate/cmd_migrate.js +2 -2
  58. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  59. package/dist/cli-delegate/cmd_prune.js +4 -4
  60. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  61. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  62. package/dist/cli-delegate/cmd_session_recycle.js +44 -9
  63. package/dist/cli-delegate/cmd_settings_get.js +2 -2
  64. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  65. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  66. package/dist/cli-delegate/cmd_sync.js +4 -4
  67. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  68. package/dist/cli-delegate/cmd_update.js +1 -1
  69. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  70. package/dist/cli-delegate/cmd_validate.js +4 -4
  71. package/dist/cli-delegate/cmd_versions.js +1 -1
  72. package/dist/discovery/deprecation-report.md +1 -1
  73. package/dist/discovery/discovery-manifest.json +21 -21
  74. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  75. package/dist/discovery/discovery-manifest.summary.md +1 -1
  76. package/dist/discovery/dormancy-report.md +1 -1
  77. package/dist/discovery/orphan-report.md +1 -1
  78. package/dist/discovery/packs.json +2 -2
  79. package/dist/discovery/trust-report.md +1 -1
  80. package/dist/discovery/workspaces.json +2 -2
  81. package/dist/hooks/dispatch.js +204 -44
  82. package/dist/install/install.mjs +9 -21
  83. package/dist/mcp/registry-manifest.json +2 -2
  84. package/dist/mcp/server.json +2 -2
  85. package/dist/mcp/server.mjs +1337 -490
  86. package/dist/router.json +1 -1
  87. package/dist/scripts/_lib/agent_settings.js +25 -2
  88. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  89. package/dist/scripts/_lib/compile_time_toggles.js +4 -3
  90. package/dist/scripts/_lib/compile_time_toggles.js.map +1 -1
  91. package/dist/server/io/yamlIO.js +0 -2
  92. package/dist/server/io/yamlIO.js.map +1 -1
  93. package/dist/server/schemas/settings.js +3 -9
  94. package/dist/server/schemas/settings.js.map +1 -1
  95. package/dist/shared/settingsClasses.js +64 -2
  96. package/dist/shared/settingsClasses.js.map +1 -1
  97. package/dist/ui/assets/{index-B5qGYOJo.js → index-5fXOHoVh.js} +2 -2
  98. package/dist/ui/assets/index-5fXOHoVh.js.map +1 -0
  99. package/dist/ui/index.html +1 -1
  100. package/docs/contracts/settings-classes.md +307 -145
  101. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +54 -0
  102. package/docs/guidelines/agent-infra/layered-settings.md +1 -2
  103. package/docs/guidelines/agent-infra/symptom-driven-harvest-loop.md +104 -0
  104. package/docs/guidelines/design-antipatterns.md +79 -10
  105. package/docs/guidelines/design-fidelity-mechanics.md +120 -2
  106. package/package.json +1 -1
  107. package/src/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +0 -1
  108. package/src/config/agent-settings.template.yml +12 -30
  109. package/src/config/gate-coverage.yml +42 -8
  110. package/src/config/gate-violation-baselines.json +15 -3
  111. package/src/config/profiles/balanced.ini +0 -2
  112. package/src/config/profiles/full.ini +1 -3
  113. package/src/config/profiles/minimal.ini +0 -2
  114. package/src/config/review-prompt-binding-baseline.json +19 -0
  115. package/src/config/rule-enforcement-baseline.json +0 -1
  116. package/src/scripts/_cli/cmd_session_recycle.ts +66 -9
  117. package/src/scripts/_cli/handoff_sessions.ts +3 -2
  118. package/src/scripts/_dispatch.bash +1 -0
  119. package/src/scripts/_lib/agent_settings.ts +25 -2
  120. package/src/scripts/_lib/bench_telegraph.ts +1 -1
  121. package/src/scripts/_lib/cc_transcript.ts +25 -0
  122. package/src/scripts/_lib/compile_time_toggles.ts +4 -3
  123. package/src/scripts/ai_council/clients.ts +243 -34
  124. package/src/scripts/cache_realization_report.ts +3 -2
  125. package/src/scripts/check_ci_local_parity.ts +60 -6
  126. package/src/scripts/check_review_prompt_binding.ts +553 -0
  127. package/src/scripts/check_rule_projection_integrity.ts +3 -1
  128. package/src/scripts/check_suppression_hygiene.ts +19 -3
  129. package/src/scripts/conformance_scan.ts +282 -9
  130. package/src/scripts/cost/track.mjs +10 -4
  131. package/src/scripts/council_cli.ts +33 -3
  132. package/src/scripts/design_slop_rules.test.ts +37 -6
  133. package/src/scripts/design_slop_rules.ts +150 -22
  134. package/src/scripts/dispatch_r2_reviewer.ts +15 -1
  135. package/src/scripts/external_sources_denylist.json +3 -1
  136. package/src/scripts/hook_manifest.yaml +17 -6
  137. package/src/scripts/hooks/block_unauthorized_git.ts +54 -8
  138. package/src/scripts/hooks/delegation_nudge_hook.ts +33 -8
  139. package/src/scripts/hooks/evidence_independence.ts +61 -9
  140. package/src/scripts/hooks/session_eol_hook.ts +123 -3
  141. package/src/scripts/hooks/turn_end_gate_hook.ts +31 -0
  142. package/src/scripts/hooks/ui_route_nudge_hook.ts +55 -2
  143. package/src/scripts/lint_design_antipattern_parity.ts +201 -0
  144. package/src/scripts/lint_harvest_provenance.ts +483 -0
  145. package/src/scripts/lint_settings_classes.ts +273 -11
  146. package/src/scripts/lint_skill_router_head.ts +254 -0
  147. package/src/scripts/lint_symptom_intake.ts +241 -0
  148. package/src/scripts/report_consultation_rate.ts +128 -2
  149. package/src/scripts/report_skill_activation.ts +6 -4
  150. package/src/scripts/report_skill_obligation_violations.ts +2 -1
  151. package/src/scripts/run_skill_evals.ts +10 -5
  152. package/src/scripts/schemas/persona.schema.json +8 -0
  153. package/src/scripts/schemas/provenance-harvest.schema.json +39 -0
  154. package/src/scripts/sweep_dead_scan_roots.ts +51 -0
  155. package/src/server/io/yamlIO.ts +0 -2
  156. package/src/server/schemas/settings.ts +3 -19
  157. package/src/shared/settingsClasses.ts +93 -2
  158. package/dist/ui/assets/index-B5qGYOJo.js.map +0 -1
@@ -17,6 +17,16 @@
17
17
  * SILENCE, never a block (fail-open); hooks cannot inject `/clear`, so the
18
18
  * recycle action itself stays advisory-carried by design (roadmap 5.1).
19
19
  *
20
+ * Counter-check (second warn path, also once per session): when the advisory
21
+ * fired on an earlier Stop and no envelope written since then exists, inject
22
+ * one further line saying so — the advisory recommends an action whose next
23
+ * step destroys the session, so recommending it without ever checking the
24
+ * result is how a silent write failure becomes total context loss. Stamped by
25
+ * `missing_envelope_warned_at`, gated on the same threshold, so the emergency
26
+ * off-switch silences BOTH paths. Two independent emitters, at most one line
27
+ * each per session — the budget the Stop slot carries from this hook is two,
28
+ * not one.
29
+ *
20
30
  * Phase 4.2 (read surface): every Stop also overwrites
21
31
  * `agents/runtime/state/context-fill.json` with the machine-readable fill
22
32
  * level + threshold state — display substrate for an external statusline;
@@ -35,6 +45,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
35
45
 
36
46
  import recycleThresholdConfig from '../../config/recycle-threshold-budget.json';
37
47
 
48
+ import { RECYCLE_ENVELOPE_REL } from '../_lib/recycle_envelope_paths.js';
38
49
  import {
39
50
  emptyCounters,
40
51
  eolSessionKey,
@@ -67,6 +78,13 @@ export interface SessionEolState {
67
78
  counters: EolCounters;
68
79
  /** ISO stamp when the recycle advisory fired for this session (F2), or null. */
69
80
  advisory_fired_at: string | null;
81
+ /**
82
+ * ISO stamp when the follow-up "advised, but no envelope exists" line
83
+ * fired, or null. Separate from `advisory_fired_at` so the counter-check
84
+ * is once-per-session in its own right: one reminder is a safety net, one
85
+ * per Stop for the rest of the session is a nag the reader learns to skip.
86
+ */
87
+ missing_envelope_warned_at?: string | null;
70
88
  updated_at: string;
71
89
  }
72
90
 
@@ -105,6 +123,10 @@ export function readState(file: string): SessionEolState {
105
123
  counters: parsed.counters,
106
124
  advisory_fired_at:
107
125
  typeof parsed.advisory_fired_at === 'string' ? parsed.advisory_fired_at : null,
126
+ missing_envelope_warned_at:
127
+ typeof parsed.missing_envelope_warned_at === 'string'
128
+ ? parsed.missing_envelope_warned_at
129
+ : null,
108
130
  updated_at: typeof parsed.updated_at === 'string' ? parsed.updated_at : '',
109
131
  };
110
132
  }
@@ -115,10 +137,70 @@ export function readState(file: string): SessionEolState {
115
137
  schema_version: 1,
116
138
  counters: emptyCounters(),
117
139
  advisory_fired_at: null,
140
+ missing_envelope_warned_at: null,
118
141
  updated_at: '',
119
142
  };
120
143
  }
121
144
 
145
+ /**
146
+ * Is there a recycle envelope under this workspace, written since `since`?
147
+ *
148
+ * Two properties, both load-bearing:
149
+ *
150
+ * **Only ENOENT counts as missing.** `fs.existsSync` swallows every error into
151
+ * `false`, so an unreadable directory (EACCES on a mounted or root-owned tree)
152
+ * would read as "no envelope" and produce the manufactured "your envelope is
153
+ * gone" warning this check exists to avoid. `statSync` + an errno test is the
154
+ * only shape that can tell the two apart.
155
+ *
156
+ * **Freshness, not mere existence.** The consumer moves the envelope aside at
157
+ * session_start, so a file still sitting here belongs to a session that never
158
+ * cleared. Counting it would silence the warning in the case it exists to
159
+ * catch, and worse: `/clear` would then resume the successor from another
160
+ * session's state — a wrong resume instead of an empty one. An envelope whose
161
+ * `written_at` predates the advisory is not this session's.
162
+ */
163
+ export function envelopeExists(workspaceRoot: string, since: string | null = null): boolean {
164
+ const target = path.join(workspaceRoot, RECYCLE_ENVELOPE_REL);
165
+ let raw: string;
166
+ try {
167
+ raw = fs.readFileSync(target, 'utf-8');
168
+ } catch (exc) {
169
+ // ENOENT is the real "no envelope"; anything else is a failed CHECK,
170
+ // and a failed check must never assert absence.
171
+ return (exc as NodeJS.ErrnoException)?.code !== 'ENOENT';
172
+ }
173
+ if (since === null) return true;
174
+ try {
175
+ const written = (JSON.parse(raw) as { written_at?: unknown }).written_at;
176
+ // Unparseable or undated: present, and not provably stale. Treat it as
177
+ // this session's — the alternative warns about a file that is there.
178
+ if (typeof written !== 'string') return true;
179
+ return Date.parse(written) >= Date.parse(since);
180
+ } catch {
181
+ return true;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * The follow-up line: the advisory already fired, and no envelope arrived.
187
+ *
188
+ * This is the half of the reported minimum fix that lives outside the command
189
+ * — the hook that recommends the call making the same counter-check before the
190
+ * operator acts on it. Whatever stopped the write (a wrong root, a refusal
191
+ * scrolled past, a forgotten step), the state visible here is identical, and
192
+ * the next action the advisory recommended is the one that destroys the
193
+ * session.
194
+ */
195
+ export function buildMissingEnvelopeLine(workspaceRoot: string): string {
196
+ return (
197
+ `recycle advised earlier, but no envelope exists at ` +
198
+ `${path.join(workspaceRoot, RECYCLE_ENVELOPE_REL)} — /clear now starts the successor ` +
199
+ `from nothing. Re-run \`agent-config session:recycle\` and check that it prints the ` +
200
+ `absolute path it wrote before clearing.`
201
+ );
202
+ }
203
+
122
204
  /**
123
205
  * Resolve the recycle threshold (Phase 3.1): the per-process override wins
124
206
  * (tests; `0` = advisory lane off), else the committed constant from
@@ -136,13 +218,22 @@ export function readThresholdTokens(): number | null {
136
218
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
137
219
  }
138
220
 
139
- /** The exactly-one advisory line injected past threshold (Phase 3.2). */
221
+ /**
222
+ * The exactly-one advisory line injected past threshold (Phase 3.2).
223
+ *
224
+ * The `/clear` half is conditional on purpose. An earlier wording read
225
+ * `run X … then /clear`, which is an instruction to destroy the session with
226
+ * no check in between — and a reader who followed it while the command was
227
+ * silently writing nothing lost everything. The proof to wait for is the
228
+ * absolute path the command prints; naming it costs one clause.
229
+ */
140
230
  export function buildAdvisoryLine(tokens: number, threshold: number): string {
141
231
  return (
142
232
  `context past recycle threshold (${tokens.toLocaleString('en-US')} of ` +
143
233
  `${threshold.toLocaleString('en-US')} tokens): run \`agent-config session:recycle\` to ` +
144
- `write the recycle envelope, then /clear — the successor session resumes from the ` +
145
- `envelope at session_start (road-to-token-economy-recycling)`
234
+ `write the recycle envelope. It prints the absolute path it wrote — /clear only after ` +
235
+ `you have seen that line. The successor session then resumes from the envelope at ` +
236
+ `session_start (road-to-token-economy-recycling)`
146
237
  );
147
238
  }
148
239
 
@@ -213,10 +304,29 @@ export function main(): number {
213
304
  tokens >= threshold &&
214
305
  state.advisory_fired_at === null;
215
306
 
307
+ // The counter-check: the advisory fired on an EARLIER Stop (so `state`,
308
+ // the pre-update snapshot, already carries the stamp) and no envelope has
309
+ // appeared since. It can never collide with `shouldAdvise` — that branch
310
+ // requires `advisory_fired_at === null` — so at most one line is emitted
311
+ // per Stop, and this one at most once per session.
312
+ //
313
+ // `threshold !== null` gates it for the same reason the advisory is gated:
314
+ // `AGENT_RECYCLE_THRESHOLD_TOKENS=0` is documented as the emergency switch
315
+ // for the whole advisory lane, and a lane that keeps one more warn after
316
+ // being switched off is not off.
317
+ const shouldWarnMissing =
318
+ threshold !== null &&
319
+ state.advisory_fired_at !== null &&
320
+ (state.missing_envelope_warned_at ?? null) === null &&
321
+ !envelopeExists(workspaceRoot, state.advisory_fired_at);
322
+
216
323
  const nextState: SessionEolState = {
217
324
  schema_version: 1,
218
325
  counters,
219
326
  advisory_fired_at: shouldAdvise ? now : state.advisory_fired_at,
327
+ missing_envelope_warned_at: shouldWarnMissing
328
+ ? now
329
+ : (state.missing_envelope_warned_at ?? null),
220
330
  updated_at: now,
221
331
  };
222
332
  try {
@@ -237,6 +347,16 @@ export function main(): number {
237
347
  );
238
348
  return EXIT_WARN;
239
349
  }
350
+ if (shouldWarnMissing) {
351
+ process.stdout.write(
352
+ `${JSON.stringify({
353
+ decision: 'warn',
354
+ reason: 'session-eol: recycle advised, no envelope written',
355
+ additional_context: buildMissingEnvelopeLine(workspaceRoot),
356
+ })}\n`,
357
+ );
358
+ return EXIT_WARN;
359
+ }
240
360
  return 0;
241
361
  }
242
362
 
@@ -400,6 +400,34 @@ export function readCiSettled(workspaceRoot: string): { seen: boolean; settled:
400
400
  const _COMPLETION_RE =
401
401
  /(^|\n)\s*(?:\*\*)?(?:fertig\b|damit ist alles erledigt|aufgabe erledigt|alles erledigt\b|komplett(?:er)? (?:auftrag|abgearbeitet)|der komplette auftrag ist durch|done[.!]|all done\b|task complete)/i;
402
402
 
403
+ /**
404
+ * The line the claim was found on says the opposite of a claim.
405
+ *
406
+ * A line-anchored keyword cannot tell "Fertig." from "Fertig ist der Fix noch
407
+ * nicht." — both open with the same token, and the second is exactly the honest
408
+ * status report this gate exists to ENCOURAGE. Measured 2026-08-12: three of ten
409
+ * realistic closings were refused, and all three were "not done yet" lines.
410
+ *
411
+ * This is a negation check on the SAME line, not another attempt to enumerate
412
+ * completion phrasings. The council that reviewed this guard (anthropic +
413
+ * openai, 2026-08-12) stated the bound plainly: a finite pattern cannot cover an
414
+ * infinite false-positive set, so extending the keyword list again would repeat
415
+ * the move that has already failed three times on the sibling git guard. What
416
+ * makes a block defensible here is not the pattern — it is that the pattern only
417
+ * fires as a TRIGGER to consult structured CI state (`ci.settled`), which is the
418
+ * council's Tier-2 shape. This check removes the cases where the prose itself
419
+ * already contradicts the trigger.
420
+ */
421
+ const _NEGATED_CLAIM_RE =
422
+ /\b(noch nicht|nicht fertig|nicht durch|wäre verfrüht|ist verfrüht|not yet|not done|isn'?t done|nein)\b/i;
423
+
424
+ /** The line `at` falls on — the scope a negation has to appear in to count. */
425
+ function _lineAround(prose: string, at: number): string {
426
+ const start = prose.lastIndexOf('\n', at) + 1;
427
+ const end = prose.indexOf('\n', at);
428
+ return prose.slice(start, end === -1 ? prose.length : end);
429
+ }
430
+
403
431
  export function detectCompletionClaim(
404
432
  reply: string,
405
433
  ci: { seen: boolean; settled: boolean },
@@ -410,6 +438,9 @@ export function detectCompletionClaim(
410
438
  const m = _COMPLETION_RE.exec(prose);
411
439
  if (!m) return null;
412
440
  const at = m.index;
441
+ // The trigger fired, but the line it fired on negates it — a status report,
442
+ // not a claim. Refusing these punishes exactly the honesty the gate wants.
443
+ if (_NEGATED_CLAIM_RE.test(_lineAround(prose, at))) return null;
413
444
  return {
414
445
  detector: 'completion',
415
446
  evidence: prose.slice(at, at + 120).trim(),
@@ -123,6 +123,45 @@ export function isUiWrite(event: ToolEvent): boolean {
123
123
  return isUiPath(event.file) || isUiTreePath(event.file);
124
124
  }
125
125
 
126
+ /**
127
+ * The two handover shapes `design-fidelity` routes on a FILE rather than on a
128
+ * word: its `file_pattern: *design.html` and its
129
+ * `path_prefix: .claude/design-system/`. Reading one is the agent reaching
130
+ * rung 1 of the data-basis ladder before it writes.
131
+ *
132
+ * A SHARED PREDICATE, NOT A DECISION INPUT HERE. `decide` does not branch on
133
+ * it: the nudge's behaviour is unchanged by an artifact read, and the phase
134
+ * that introduced this states `nothing behavioural`. The one consumer is
135
+ * `report_consultation_rate`, which measures over transcripts. Keeping the
136
+ * predicate beside `isUiWrite` / `isConsultation` is what stops the analyzer
137
+ * and the nudge from classifying the same event differently.
138
+ *
139
+ * THIS IS A COPY OF THE RULE'S TRIGGERS, NOT A READ OF THEM — same honesty
140
+ * boundary the header states for `ui_surface.ts`: nothing here parses
141
+ * `src/rules/*.md`. `ui_route_nudge_artifact_read.test.ts` pins the two against
142
+ * the rule's own frontmatter so the copy cannot drift silently.
143
+ *
144
+ * **Not disjoint from `isConsultation` by construction** —
145
+ * `src/skills/design-review/references/design.html` satisfies both. Callers
146
+ * must evaluate the two independently rather than branching on the first hit;
147
+ * an early return on this predicate silently removes such an event from the
148
+ * consultation numerator.
149
+ *
150
+ * **Known over-count, not fixed here.** A `Grep` / `Glob` whose `path` argument
151
+ * happens to be the artifact satisfies this predicate although nothing was
152
+ * read — the same search-is-not-a-read class `isConsultation` was narrowed to
153
+ * exclude. It is not excluded here because `ToolEvent` carries no tool name,
154
+ * and adding one would change the shared shape `isConsultation` and `isUiWrite`
155
+ * are measured through, moving a published rate this branch does not own. The
156
+ * consequence is stated where it matters: the rate is a CEILING on
157
+ * read-before-write, and `report_consultation_rate`'s output says so.
158
+ */
159
+ export function isArtifactRead(event: ToolEvent): boolean {
160
+ if (event.isWrite || !event.file) return false;
161
+ const normalized = event.file.replace(/\\/g, '/').toLowerCase();
162
+ return normalized.endsWith('design.html') || normalized.includes('.claude/design-system/');
163
+ }
164
+
126
165
  export interface SessionState {
127
166
  consulted: boolean;
128
167
  nudges: number;
@@ -203,8 +242,22 @@ export function decide(
203
242
  return { state: { ...state, nudges: state.nudges + 1 }, warn: true };
204
243
  }
205
244
 
245
+ /**
246
+ * Did anything worth persisting change?
247
+ *
248
+ * Enumerated per field rather than deep-compared. **A new field must be added
249
+ * here or it does not persist** — a review on this branch caught exactly that
250
+ * shape: two session fields were computed on every event and written on none,
251
+ * because the guard tested the two older fields by name. Those fields are gone
252
+ * (the measurement lives in the analyzer, one implementation instead of two),
253
+ * but the guard stays enumerated and this note stays with it.
254
+ */
255
+ export function stateChanged(before: SessionState, after: SessionState): boolean {
256
+ return before.consulted !== after.consulted || before.nudges !== after.nudges;
257
+ }
258
+
206
259
  export function nudgeReason(file: string): string {
207
- return `Non-trivial UI write to ${file} with no design consultation this session. Run \`existing-ui-audit\` first, then the fe-design loop (brief → build → review). Skip only if this is ui-trivial: <=1 file, <=5 lines, no new component, no new state, no new dependency.`;
260
+ return `Non-trivial UI write to ${file} with no design consultation this session. Run \`existing-ui-audit\` first, then the fe-design loop (brief → inventory, if an artifact was provided → build → review). Skip only if this is ui-trivial: <=1 file, <=5 lines, no new component, no new state, no new dependency.`;
208
261
  }
209
262
 
210
263
  export function main(): number {
@@ -230,7 +283,7 @@ export function main(): number {
230
283
 
231
284
  const before = readState(root, session);
232
285
  const { state, warn } = decide(event, before);
233
- if (state.consulted !== before.consulted || state.nudges !== before.nudges) {
286
+ if (stateChanged(before, state)) {
234
287
  writeState(root, session, state);
235
288
  }
236
289
  if (!warn) return EXIT_ALLOW;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Gate: catalog ↔ slop-rule parity (road-to-design-detector-evidence Phase 1).
3
+ *
4
+ * `src/scripts/design_slop_rules.ts` states in its header that every rule cites
5
+ * its catalog id "so prose ↔ rule stay traceable". Nothing enforced that. The
6
+ * unit test validates the *shape* `/^[A-Z]+\d+$/` and never opens
7
+ * `docs/guidelines/design-antipatterns.md`, so a rule citing `Z99` passed, and a
8
+ * catalog entry could silently lose or gain a detector with no signal anywhere.
9
+ * The tree happened to be consistent; the defect was that consistency was
10
+ * unguarded, not that it was absent.
11
+ *
12
+ * This gate makes the claim true, in both directions:
13
+ *
14
+ * A. every non-`Q*` catalog entry appears exactly once in § Detector status
15
+ * B. every § Detector status row names a catalog entry that exists
16
+ * C. the set of `backed` rows equals the registry's `catalogId` set
17
+ * D. every status is one of the five allowed values
18
+ * E. every non-`backed` row carries a reason
19
+ *
20
+ * `Q*` entries are out of scope by construction: they are objective floors owned
21
+ * by `lint_design_quality`, not aesthetic tells, and the status table says so.
22
+ *
23
+ * Pure core (`parityFindings`) takes both documents as text, so the failure
24
+ * paths are unit-testable without the filesystem. Exit 0 = clean, 1 = at least
25
+ * one finding.
26
+ */
27
+
28
+ import * as fs from 'node:fs';
29
+ import * as path from 'node:path';
30
+ import { fileURLToPath, pathToFileURL } from 'node:url';
31
+
32
+ import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
33
+
34
+ const QUIET = process.argv.includes('--quiet');
35
+ const _HERE = fileURLToPath(import.meta.url);
36
+ const REPO = path.resolve(path.dirname(_HERE), '..', '..');
37
+ const CATALOG = path.join(REPO, 'docs', 'guidelines', 'design-antipatterns.md');
38
+ const REGISTRY = path.join(REPO, 'src', 'scripts', 'design_slop_rules.ts');
39
+
40
+ /** The five statuses § Detector status may use. */
41
+ export const STATUSES = ['backed', 'floor', 'judgment-only', 'deferred', 'candidate'] as const;
42
+ export type Status = (typeof STATUSES)[number];
43
+
44
+ export interface ParityFinding {
45
+ kind: string;
46
+ msg: string;
47
+ }
48
+
49
+ const ENTRY_ROW = /^\|\s*((?:V|C|T|L|M|CP)\d+)\s*\|/gm;
50
+ const STATUS_ROW = /^\|\s*((?:V|C|T|L|M|CP)\d+)\s*\|\s*([a-z-]*)\s*\|([^|]*)\|/gm;
51
+ const STATUS_SECTION = /^## Detector status$([\s\S]*?)(?=^## )/m;
52
+ const CATALOG_ID = /^\s+catalogId:\s*"([A-Z]+\d+)"/gm;
53
+
54
+ /** Catalog entry ids outside the § Detector status section. */
55
+ export function catalogEntries(catalogMd: string): string[] {
56
+ const section = STATUS_SECTION.exec(catalogMd)?.[0] ?? '';
57
+ const outside = section === '' ? catalogMd : catalogMd.replace(section, '');
58
+ return [...outside.matchAll(ENTRY_ROW)].map((m) => m[1] as string);
59
+ }
60
+
61
+ /** `id -> {status, note}` parsed from the § Detector status table. */
62
+ export function statusRows(catalogMd: string): Map<string, { status: string; note: string }> {
63
+ const section = STATUS_SECTION.exec(catalogMd)?.[1] ?? '';
64
+ const out = new Map<string, { status: string; note: string }>();
65
+ for (const m of section.matchAll(STATUS_ROW)) {
66
+ out.set(m[1] as string, { status: (m[2] ?? '').trim(), note: (m[3] ?? '').trim() });
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /** `catalogId` values declared by the rule registry. */
72
+ export function registryCatalogIds(registryTs: string): string[] {
73
+ return [...registryTs.matchAll(CATALOG_ID)].map((m) => m[1] as string);
74
+ }
75
+
76
+ /** Pure invariant check over both documents. */
77
+ export function parityFindings(catalogMd: string, registryTs: string): ParityFinding[] {
78
+ const out: ParityFinding[] = [];
79
+
80
+ if (STATUS_SECTION.exec(catalogMd) === null) {
81
+ return [
82
+ {
83
+ kind: 'no-status-section',
84
+ msg: 'design-antipatterns.md has no `## Detector status` section — the parity claim has nothing to check against',
85
+ },
86
+ ];
87
+ }
88
+
89
+ const entries = catalogEntries(catalogMd);
90
+ const seen = new Set<string>();
91
+ for (const id of entries) {
92
+ if (seen.has(id)) out.push({ kind: 'duplicate-entry', msg: `${id}: appears more than once in the catalog tables` });
93
+ seen.add(id);
94
+ }
95
+
96
+ const rows = statusRows(catalogMd);
97
+
98
+ // A — every catalog entry is classified.
99
+ for (const id of seen) {
100
+ if (!rows.has(id)) {
101
+ out.push({ kind: 'unclassified', msg: `${id}: catalog entry missing from § Detector status` });
102
+ }
103
+ }
104
+ // B — every classified row names a real entry.
105
+ for (const id of rows.keys()) {
106
+ if (!seen.has(id)) {
107
+ out.push({ kind: 'orphan-status', msg: `${id}: § Detector status row names an entry that no catalog table defines` });
108
+ }
109
+ }
110
+ // D + E — status vocabulary and reasons.
111
+ for (const [id, row] of rows) {
112
+ if (!(STATUSES as readonly string[]).includes(row.status)) {
113
+ out.push({
114
+ kind: 'bad-status',
115
+ msg: `${id}: status '${row.status || '(empty)'}' is not one of ${STATUSES.join(', ')}`,
116
+ });
117
+ continue;
118
+ }
119
+ if (row.status !== 'backed' && row.note === '') {
120
+ out.push({ kind: 'missing-reason', msg: `${id}: status '${row.status}' carries no reason` });
121
+ }
122
+ }
123
+
124
+ // C — backed set equals the registry set, both directions.
125
+ const backed = new Set([...rows].filter(([, r]) => r.status === 'backed').map(([id]) => id));
126
+ const registry = new Set(registryCatalogIds(registryTs));
127
+ for (const id of backed) {
128
+ if (!registry.has(id)) {
129
+ out.push({ kind: 'backed-without-rule', msg: `${id}: marked backed but no registry rule declares that catalogId` });
130
+ }
131
+ }
132
+ for (const id of registry) {
133
+ if (!backed.has(id)) {
134
+ const known = rows.get(id);
135
+ out.push({
136
+ kind: 'rule-without-backed',
137
+ msg: known
138
+ ? `${id}: a registry rule declares this catalogId but § Detector status marks it '${known.status}'`
139
+ : `${id}: a registry rule declares this catalogId and no catalog entry carries it`,
140
+ });
141
+ }
142
+ }
143
+
144
+ return out;
145
+ }
146
+
147
+ function _print(s: string): void {
148
+ if (!QUIET) process.stdout.write(`${s}\n`);
149
+ }
150
+
151
+ export function main(): number {
152
+ try {
153
+ assertWatchlistResolves({
154
+ gate: 'lint_design_antipattern_parity',
155
+ candidates: [path.relative(REPO, CATALOG), path.relative(REPO, REGISTRY)],
156
+ repoRoot: REPO,
157
+ });
158
+ } catch (exc) {
159
+ if (exc instanceof DeadScopeError) {
160
+ process.stderr.write(`❌ ${exc.message}\n`);
161
+ return 1;
162
+ }
163
+ throw exc;
164
+ }
165
+
166
+ const catalogMd = fs.readFileSync(CATALOG, 'utf-8');
167
+ const registryTs = fs.readFileSync(REGISTRY, 'utf-8');
168
+ const findings = parityFindings(catalogMd, registryTs);
169
+
170
+ const entries = catalogEntries(catalogMd).length;
171
+ const backed = [...statusRows(catalogMd).values()].filter((r) => r.status === 'backed').length;
172
+
173
+ if (findings.length === 0) {
174
+ _print(`✅ catalog ↔ rule parity OK — ${entries} entries classified, ${backed} detector-backed`);
175
+ return 0;
176
+ }
177
+ _print(`❌ catalog ↔ rule parity — ${findings.length} finding(s):`);
178
+ for (const f of findings) _print(` - ${f.msg}`);
179
+ return 1;
180
+ }
181
+
182
+ function _isCliEntry(): boolean {
183
+ if (process.argv[1] === undefined) {
184
+ return false;
185
+ }
186
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
187
+ if (import.meta.url === argvUrl) {
188
+ return true;
189
+ }
190
+ try {
191
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
192
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
193
+ return here === argv;
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+
199
+ if (_isCliEntry()) {
200
+ process.exit(main());
201
+ }