@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +110 -167
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/reference-repo.md +62 -0
- package/dist/agent-src/commands/roadmap/create.md +4 -1
- package/dist/agent-src/commands/roadmap/next.md +38 -0
- package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +22 -1
- package/dist/agent-src/contexts/contracts/frugality-charter.md +1 -1
- package/dist/agent-src/personas/advisors/first-principles.md +6 -0
- package/dist/agent-src/rules/active-remediation.md +53 -2
- package/dist/agent-src/rules/code-provenance.md +66 -4
- package/dist/agent-src/rules/content-quoting-floor.md +6 -0
- package/dist/agent-src/rules/context-hygiene.md +39 -0
- package/dist/agent-src/rules/design-fidelity.md +49 -3
- package/dist/agent-src/rules/token-budget-discipline.md +3 -2
- package/dist/agent-src/rules/token-efficiency.md +22 -0
- package/dist/agent-src/skills/ai-council/SKILL.md +28 -6
- package/dist/agent-src/skills/condense-memory/SKILL.md +1 -1
- package/dist/agent-src/skills/design-review/references/verification-automation.md +23 -1
- package/dist/agent-src/skills/fe-design/SKILL.md +27 -3
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +30 -0
- package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +11 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +9 -9
- package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
- package/dist/agent-src/skills/token-optimizer/SKILL.md +5 -1
- package/dist/agent-src/templates/agent-settings.md +12 -28
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +2 -2
- package/dist/agent-src/templates/persona.md +26 -0
- package/dist/agent-src/templates/roadmaps.md +27 -7
- package/dist/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +0 -1
- package/dist/agent-src/templates/skill.md +35 -1
- package/dist/cli/commands/commands.candidates.test.js +257 -0
- package/dist/cli/commands/commands.candidates.test.js.map +1 -0
- package/dist/cli/commands/commands.js +199 -0
- package/dist/cli/commands/commands.js.map +1 -1
- package/dist/cli/main.js +1 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/registry.js +2 -2
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-3JUTT26I.js → chunk-6ENSXSBW.js} +1 -1
- package/dist/cli-delegate/{chunk-TVGXH7VA.js → chunk-6LOPHJLD.js} +1 -1
- package/dist/cli-delegate/{chunk-XT2YTJCZ.js → chunk-AYKQGIBG.js} +4 -0
- package/dist/cli-delegate/{chunk-HJSKTMIF.js → chunk-BV3WR67D.js} +3 -19
- package/dist/cli-delegate/{chunk-HTIU3W56.js → chunk-BWMXLD4B.js} +6 -2
- package/dist/cli-delegate/{chunk-2H5SE7DU.js → chunk-FV34EGEX.js} +166 -39
- package/dist/cli-delegate/{chunk-XTC5ARB6.js → chunk-IR3IVRVC.js} +7 -2
- package/dist/cli-delegate/{chunk-LRXJK6GN.js → chunk-JMQZ7MM5.js} +1 -1
- package/dist/cli-delegate/{chunk-NX5AXSLD.js → chunk-X3VOARZD.js} +3 -3
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +881 -204
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +4 -4
- package/dist/cli-delegate/cmd_handoff.js +6 -5
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +4 -4
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +44 -9
- package/dist/cli-delegate/cmd_settings_get.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_settings_set.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +4 -4
- package/dist/cli-delegate/cmd_uninstall.js +4 -4
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +4 -4
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +21 -21
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +204 -44
- package/dist/install/install.mjs +9 -21
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1337 -490
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +25 -2
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/dist/scripts/_lib/compile_time_toggles.js +4 -3
- package/dist/scripts/_lib/compile_time_toggles.js.map +1 -1
- package/dist/server/io/yamlIO.js +0 -2
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/schemas/settings.js +3 -9
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/shared/settingsClasses.js +64 -2
- package/dist/shared/settingsClasses.js.map +1 -1
- package/dist/ui/assets/{index-B5qGYOJo.js → index-5fXOHoVh.js} +2 -2
- package/dist/ui/assets/index-5fXOHoVh.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/contracts/settings-classes.md +307 -145
- package/docs/guidelines/agent-infra/active-remediation-mechanics.md +54 -0
- package/docs/guidelines/agent-infra/layered-settings.md +1 -2
- package/docs/guidelines/agent-infra/symptom-driven-harvest-loop.md +104 -0
- package/docs/guidelines/design-antipatterns.md +79 -10
- package/docs/guidelines/design-fidelity-mechanics.md +120 -2
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +0 -1
- package/src/config/agent-settings.template.yml +12 -30
- package/src/config/gate-coverage.yml +42 -8
- package/src/config/gate-violation-baselines.json +15 -3
- package/src/config/profiles/balanced.ini +0 -2
- package/src/config/profiles/full.ini +1 -3
- package/src/config/profiles/minimal.ini +0 -2
- package/src/config/review-prompt-binding-baseline.json +19 -0
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/scripts/_cli/cmd_session_recycle.ts +66 -9
- package/src/scripts/_cli/handoff_sessions.ts +3 -2
- package/src/scripts/_dispatch.bash +1 -0
- package/src/scripts/_lib/agent_settings.ts +25 -2
- package/src/scripts/_lib/bench_telegraph.ts +1 -1
- package/src/scripts/_lib/cc_transcript.ts +25 -0
- package/src/scripts/_lib/compile_time_toggles.ts +4 -3
- package/src/scripts/ai_council/clients.ts +243 -34
- package/src/scripts/cache_realization_report.ts +3 -2
- package/src/scripts/check_ci_local_parity.ts +60 -6
- package/src/scripts/check_review_prompt_binding.ts +553 -0
- package/src/scripts/check_rule_projection_integrity.ts +3 -1
- package/src/scripts/check_suppression_hygiene.ts +19 -3
- package/src/scripts/conformance_scan.ts +282 -9
- package/src/scripts/cost/track.mjs +10 -4
- package/src/scripts/council_cli.ts +33 -3
- package/src/scripts/design_slop_rules.test.ts +37 -6
- package/src/scripts/design_slop_rules.ts +150 -22
- package/src/scripts/dispatch_r2_reviewer.ts +15 -1
- package/src/scripts/external_sources_denylist.json +3 -1
- package/src/scripts/hook_manifest.yaml +17 -6
- package/src/scripts/hooks/block_unauthorized_git.ts +54 -8
- package/src/scripts/hooks/delegation_nudge_hook.ts +33 -8
- package/src/scripts/hooks/evidence_independence.ts +61 -9
- package/src/scripts/hooks/session_eol_hook.ts +123 -3
- package/src/scripts/hooks/turn_end_gate_hook.ts +31 -0
- package/src/scripts/hooks/ui_route_nudge_hook.ts +55 -2
- package/src/scripts/lint_design_antipattern_parity.ts +201 -0
- package/src/scripts/lint_harvest_provenance.ts +483 -0
- package/src/scripts/lint_settings_classes.ts +273 -11
- package/src/scripts/lint_skill_router_head.ts +254 -0
- package/src/scripts/lint_symptom_intake.ts +241 -0
- package/src/scripts/report_consultation_rate.ts +128 -2
- package/src/scripts/report_skill_activation.ts +6 -4
- package/src/scripts/report_skill_obligation_violations.ts +2 -1
- package/src/scripts/run_skill_evals.ts +10 -5
- package/src/scripts/schemas/persona.schema.json +8 -0
- package/src/scripts/schemas/provenance-harvest.schema.json +39 -0
- package/src/scripts/sweep_dead_scan_roots.ts +51 -0
- package/src/server/io/yamlIO.ts +0 -2
- package/src/server/schemas/settings.ts +3 -19
- package/src/shared/settingsClasses.ts +93 -2
- 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
|
-
/**
|
|
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
|
|
145
|
-
`envelope at
|
|
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 (
|
|
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
|
+
}
|