@event4u/agent-config 7.1.0 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.claude-plugin/marketplace.json +2 -1
  2. package/CHANGELOG.md +39 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/rules/brand-source-of-truth.md +10 -0
  5. package/dist/agent-src/rules/delegation-policy.md +1 -0
  6. package/dist/agent-src/rules/legal-safety-floor.md +42 -4
  7. package/dist/agent-src/rules/output-discipline.md +85 -0
  8. package/dist/agent-src/rules/telegraph-speak.md +4 -1
  9. package/dist/agent-src/rules/token-budget-discipline.md +94 -0
  10. package/dist/agent-src/skills/accessibility-auditor/SKILL.md +20 -5
  11. package/dist/agent-src/skills/blade-ui/SKILL.md +9 -0
  12. package/dist/agent-src/skills/contract-review/SKILL.md +3 -2
  13. package/dist/agent-src/skills/dashboard-design/SKILL.md +21 -5
  14. package/dist/agent-src/skills/design-intelligence/SKILL.md +96 -0
  15. package/dist/agent-src/skills/design-review/SKILL.md +83 -0
  16. package/dist/agent-src/skills/design-system-capture/SKILL.md +234 -0
  17. package/dist/agent-src/skills/design-tokens/SKILL.md +1 -0
  18. package/dist/agent-src/skills/dpa-review/SKILL.md +3 -2
  19. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +15 -4
  20. package/dist/agent-src/skills/fe-design/SKILL.md +98 -16
  21. package/dist/agent-src/skills/flux/SKILL.md +8 -0
  22. package/dist/agent-src/skills/iconography/SKILL.md +1 -0
  23. package/dist/agent-src/skills/legal-intake-triage/SKILL.md +3 -2
  24. package/dist/agent-src/skills/legal-practice-profile/SKILL.md +3 -2
  25. package/dist/agent-src/skills/livewire/SKILL.md +8 -0
  26. package/dist/agent-src/skills/nda-triage/SKILL.md +3 -2
  27. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +21 -9
  28. package/dist/agent-src/skills/skill-writing/SKILL.md +18 -0
  29. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +7 -10
  30. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +17 -7
  31. package/dist/agent-src/skills/typography-system/SKILL.md +14 -0
  32. package/dist/agent-src/skills/ui-component-architect/SKILL.md +9 -0
  33. package/dist/discovery/deprecation-report.md +1 -1
  34. package/dist/discovery/discovery-manifest.json +123 -59
  35. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  36. package/dist/discovery/discovery-manifest.summary.md +8 -8
  37. package/dist/discovery/orphan-report.md +1 -1
  38. package/dist/discovery/packs.json +18 -15
  39. package/dist/discovery/trust-report.md +5 -5
  40. package/dist/discovery/workspaces.json +15 -12
  41. package/dist/mcp/registry-manifest.json +2 -2
  42. package/dist/router.json +1 -1
  43. package/dist/server/routes/wizard.js +4 -3
  44. package/dist/server/routes/wizard.js.map +1 -1
  45. package/dist/server/schemas/settings.js +9 -0
  46. package/dist/server/schemas/settings.js.map +1 -1
  47. package/dist/ui/assets/{index-CoQo90sz.js → index-UjcTG_9O.js} +2 -2
  48. package/dist/ui/assets/index-UjcTG_9O.js.map +1 -0
  49. package/dist/ui/index.html +1 -1
  50. package/docs/RELEASE_STORY_TEMPLATE.md +37 -0
  51. package/docs/architecture.md +3 -3
  52. package/docs/contracts/capability-boundary.md +64 -0
  53. package/docs/contracts/discovery-manifest.schema.json +2 -2
  54. package/docs/contracts/package-self-orientation.md +3 -0
  55. package/docs/contracts/subagent-boundary.md +54 -0
  56. package/docs/decisions/ADR-013-discovery-frontmatter-contract.md +13 -0
  57. package/docs/decisions/ADR-107-legal-domain-pack-adoption.md +19 -0
  58. package/docs/getting-started.md +1 -1
  59. package/docs/guidelines/agent-infra/domain-pack-architecture.md +1 -0
  60. package/docs/guidelines/design-antipatterns-triggers.json +17 -0
  61. package/docs/guidelines/design-antipatterns.md +170 -0
  62. package/docs/guidelines/design-modes.md +97 -0
  63. package/package.json +1 -1
  64. package/src/config/agent-settings.template.yml +31 -0
  65. package/src/config/discovery/packs.yml +16 -5
  66. package/src/config/discovery/workspaces.yml +4 -4
  67. package/src/scripts/check_trunk_drift.ts +67 -0
  68. package/src/scripts/lint_design_quality.ts +321 -0
  69. package/src/scripts/lint_discovery_vocabulary.ts +4 -4
  70. package/src/scripts/lint_legal_pack.ts +62 -4
  71. package/src/scripts/lint_output_slop.ts +223 -0
  72. package/src/scripts/lint_pack_risk_class.ts +79 -0
  73. package/src/scripts/schemas/agent-settings.schema.json +12 -0
  74. package/src/scripts/schemas/skill.schema.json +5 -0
  75. package/dist/ui/assets/index-CoQo90sz.js.map +0 -1
@@ -167,6 +167,7 @@
167
167
  workspaces: [finance, founder]
168
168
  trust_level_default: professional
169
169
  domain: finance
170
+ risk_class: medium # advisory (finance-safety-floor): not-financial-advice disclaimer
170
171
  size_class: medium
171
172
  onboarding:
172
173
  first_win_doc: FIRST_WIN.md
@@ -180,6 +181,7 @@
180
181
  requires: [finance-basic]
181
182
  trust_level_default: core
182
183
  domain: finance
184
+ risk_class: medium # advisory (finance-safety-floor)
183
185
  size_class: medium
184
186
 
185
187
  - id: gtm-sales
@@ -223,6 +225,7 @@
223
225
  trust_level_default: core
224
226
  domain: founder
225
227
  size_class: medium
228
+ risk_class: medium # advisory (strategy-safety-floor)
226
229
  onboarding:
227
230
  first_win_doc: FIRST_WIN.md
228
231
  example_workflow: vision-articulation
@@ -240,14 +243,22 @@
240
243
  workspaces: [construction]
241
244
  trust_level_default: professional
242
245
 
243
- - id: legal
244
- label: Legal
245
- description: "NOT LEGAL ADVICE — a research/drafting aid, not a substitute for a lawyer. In-house legal — EU/DE-scoped contract/NDA/DPA review and triage, procedure-only (no default legal positions); general information + templates only, never individual-case examination; every output a draft for attorney review. See LEGAL_NOTICE.md."
246
- workspaces: [legal]
247
- trust_level_default: experimental # gated: ships lab-tier until the Gate-2 maintenance owner + qualification is confirmed (road-to-legal-pack Phase 0.2)
246
+ - id: legal-review-prep
247
+ label: Legal Review Prep
248
+ description: "NOT LEGAL ADVICE — a research/drafting aid to PREPARE for attorney review, not a substitute for a lawyer. EU/DE-scoped contract/NDA/DPA review and triage, procedure-only (no default legal positions); general information + templates only, never individual-case examination; every output a draft for attorney review. Opt-in + council-gated. See LEGAL_NOTICE.md."
249
+ workspaces: [legal-review-prep]
250
+ trust_level_default: experimental
248
251
  surface_tier: lab
249
252
  domain: legal
250
253
  size_class: medium
254
+ # High-risk posture (road-to-legal-review-prep Phase 4). Documentation-first;
255
+ # enforcement lives in the legal-safety-floor rule + consent gate + lint.
256
+ risk_class: high
257
+ risk_profile: rdg_regulated_eu_de
258
+ requires_explicit_consent: true
259
+ requires_council: true
260
+ default_install: false
261
+ promotion_gate: attorney_framing_review
251
262
  onboarding:
252
263
  first_win_doc: FIRST_WIN.md
253
264
  example_workflow: dpa-review
@@ -64,11 +64,11 @@
64
64
  default_packs: [construction]
65
65
  optional_packs: []
66
66
 
67
- - id: legal
68
- label: Legal
69
- description: In-house legal — contract/NDA/DPA review, privacy, legal triage (EU/DE scope).
67
+ - id: legal-review-prep
68
+ label: Legal Review Prep
69
+ description: In-house legal review-prep — EU/DE-scoped contract/NDA/DPA review and triage to PREPARE for attorney review. Not legal advice; opt-in + council-gated.
70
70
  example_roles: [In-house Counsel, DPO, Legal Ops]
71
- default_packs: [legal]
71
+ default_packs: [legal-review-prep]
72
72
  optional_packs: []
73
73
 
74
74
  - id: agent-config-maintainer
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Pre-PR trunk-drift check (road-to-product-clarity Phase 4b).
3
+ *
4
+ * Fails when the current branch is behind origin/main — i.e. origin/main is NOT
5
+ * an ancestor of HEAD — so a PR is never opened on a stale base (the recurring
6
+ * divergence this session hit). Advisory by default: run it in the /create-pr
7
+ * pre-flight or wire into CI per your branch-protection policy.
8
+ *
9
+ * git merge-base --is-ancestor origin/main HEAD → exit 0 = up to date
10
+ *
11
+ * Pure core (`driftVerdict`) is unit-testable without git.
12
+ */
13
+
14
+ import { execFileSync } from 'node:child_process';
15
+ import { fileURLToPath, pathToFileURL } from 'node:url';
16
+ import * as path from 'node:path';
17
+
18
+ export type DriftInput = {
19
+ /** True when origin/main is an ancestor of HEAD (branch contains trunk tip). */
20
+ trunkIsAncestor: boolean;
21
+ /** Commits HEAD is behind origin/main (0 when up to date). */
22
+ behind: number;
23
+ };
24
+
25
+ export type DriftVerdict = { ok: boolean; message: string };
26
+
27
+ /** Pure decision — no git. */
28
+ export function driftVerdict(i: DriftInput): DriftVerdict {
29
+ if (i.trunkIsAncestor) {
30
+ return { ok: true, message: '✅ branch is up to date with origin/main' };
31
+ }
32
+ return {
33
+ ok: false,
34
+ message: `❌ branch is ${i.behind} commit(s) behind origin/main — rebase/merge before opening a PR`,
35
+ };
36
+ }
37
+
38
+ function _git(args: string[]): string {
39
+ return execFileSync('git', args, { encoding: 'utf-8' }).trim();
40
+ }
41
+
42
+ export function main(): number {
43
+ let input: DriftInput;
44
+ try {
45
+ _git(['fetch', 'origin', 'main', '--quiet']);
46
+ let isAncestor = true;
47
+ try {
48
+ execFileSync('git', ['merge-base', '--is-ancestor', 'origin/main', 'HEAD'], { stdio: 'ignore' });
49
+ } catch {
50
+ isAncestor = false;
51
+ }
52
+ const behind = Number(_git(['rev-list', '--count', 'HEAD..origin/main']) || '0');
53
+ input = { trunkIsAncestor: isAncestor, behind };
54
+ } catch (e) {
55
+ process.stdout.write(`⚠️ trunk-drift check skipped (no origin/main or git error): ${String(e)}\n`);
56
+ return 0; // advisory — never block on a missing remote
57
+ }
58
+ const v = driftVerdict(input);
59
+ process.stdout.write(`${v.message}\n`);
60
+ return v.ok ? 0 : 1;
61
+ }
62
+
63
+ const _HERE = fileURLToPath(import.meta.url);
64
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
65
+ process.exit(main());
66
+ }
67
+ void _HERE;
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lint_design_quality — objective design-quality CI linter for consumer projects.
4
+ *
5
+ * Analyzes HTML/CSS in consumer project source files for 6 deterministically
6
+ * provable quality violations. NOT the package's own source (which has no UI).
7
+ *
8
+ * This linter is OPT-IN for consumer projects. They run it against their
9
+ * own src/ directories. To opt in, add to your CI:
10
+ * npx tsx node_modules/event4u-agent-config/src/scripts/lint_design_quality.ts --dir src/
11
+ *
12
+ * The 6 objective rules (zero false positives on correct implementation):
13
+ * DQ1 contrast — Text/background contrast < WCAG AA (4.5:1 normal, 3:1 large)
14
+ * DQ2 font-size — Body font-size < 14px (floor; 16px strongly preferred)
15
+ * DQ3 line-length — Prose line-length > 75ch without column constraint
16
+ * DQ4 reduced-motion — @keyframes / animation present without prefers-reduced-motion alt
17
+ * DQ5 heading-skip — Heading levels skipped (h1 → h3 without h2)
18
+ * DQ6 focus-indicator— Interactive element with no :focus-visible indicator
19
+ *
20
+ * Rules NOT implemented (design-system opinions, not objective quality floors):
21
+ * - Spacing multiples (multiples of 4px/8px)
22
+ * - Font-weight count per scale
23
+ * - Magic numbers in CSS
24
+ * - Color palette opinions
25
+ *
26
+ * Inline-ignore (eslint-style, works in HTML comments, CSS comments, JS comments):
27
+ * design-quality-disable <rule-id> [-- reason] (file scope, top of file)
28
+ * design-quality-disable-next-line <rule-id> [-- reason]
29
+ * design-quality-disable-line <rule-id> [-- reason]
30
+ *
31
+ * Per-project config (.design-quality.json in project root):
32
+ * { "ignoreRules": ["dq1-contrast"], "ignoreFiles": ["src/legacy/**"] }
33
+ *
34
+ * Exit codes: 0 = clean, 2 = findings, 1 = internal error
35
+ *
36
+ * Note on CSS parsing: this linter uses pattern-based analysis on CSS text,
37
+ * NOT a full computed-style cascade. Full CSS-cascade resolution (resolving
38
+ * Tailwind utilities, cascade order, inherited values) would require a real
39
+ * browser or htmlparser2+css-tree, which adds heavy npm dependencies. The
40
+ * pattern-based approach catches the most common violations in authored CSS
41
+ * while keeping the linter dependency-free and fast. For the contrast rule
42
+ * (DQ1), use a dedicated a11y audit tool (axe-core, Lighthouse) in CI for
43
+ * full accuracy — this linter provides a best-effort static check.
44
+ */
45
+ import fs from "node:fs";
46
+ import path from "node:path";
47
+
48
+ interface Finding {
49
+ rule: string;
50
+ file: string;
51
+ line: number;
52
+ snippet: string;
53
+ severity: "warning" | "error";
54
+ }
55
+
56
+ interface ProjectConfig {
57
+ ignoreRules?: string[];
58
+ ignoreFiles?: string[];
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Rule implementations
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /** DQ2: body font-size < 14px */
66
+ function checkFontSize(lines: string[], filePath: string): Finding[] {
67
+ const findings: Finding[] = [];
68
+ const fontSizePattern = /font-size\s*:\s*(\d+(?:\.\d+)?)px/gi;
69
+ // Only flag explicit small px values on body-like selectors
70
+ const bodySelectors = /^(body|p|li|td|th|span|div|main|article|section)\s*[,{]/i;
71
+
72
+ let inBodySelector = false;
73
+ for (let i = 0; i < lines.length; i++) {
74
+ const line = lines[i] ?? "";
75
+ if (bodySelectors.test(line)) inBodySelector = true;
76
+ if (line.includes("}")) inBodySelector = false;
77
+
78
+ const match = fontSizePattern.exec(line);
79
+ if (match && match[1] && inBodySelector) {
80
+ const size = parseFloat(match[1]);
81
+ if (size > 0 && size < 14) {
82
+ findings.push({
83
+ rule: "dq2-font-size",
84
+ file: filePath,
85
+ line: i + 1,
86
+ snippet: line.trim().slice(0, 100),
87
+ severity: "error",
88
+ });
89
+ }
90
+ }
91
+ fontSizePattern.lastIndex = 0;
92
+ }
93
+ return findings;
94
+ }
95
+
96
+ /** DQ4: @keyframes / animation without @media (prefers-reduced-motion) */
97
+ function checkReducedMotion(content: string, filePath: string, lines: string[]): Finding[] {
98
+ const hasAnimation = /@keyframes\s+\w+|animation\s*:/i.test(content);
99
+ if (!hasAnimation) return [];
100
+
101
+ const hasReducedMotion = /@media\s*\([^)]*prefers-reduced-motion/i.test(content);
102
+ if (hasReducedMotion) return [];
103
+
104
+ // Find first animation declaration for the line number
105
+ const animLine = lines.findIndex((l) => /@keyframes\s+\w+|animation\s*:/.test(l));
106
+ return [
107
+ {
108
+ rule: "dq4-reduced-motion",
109
+ file: filePath,
110
+ line: animLine + 1,
111
+ snippet: "@keyframes or animation present without @media (prefers-reduced-motion) alternative",
112
+ severity: "error",
113
+ },
114
+ ];
115
+ }
116
+
117
+ /** DQ5: heading levels skipped (h1 → h3 without h2) in HTML */
118
+ function checkHeadingHierarchy(content: string, filePath: string): Finding[] {
119
+ const findings: Finding[] = [];
120
+ const headingPattern = /<h([1-6])[^>]*>/gi;
121
+ const headings: Array<{ level: number; index: number }> = [];
122
+
123
+ let match;
124
+ while ((match = headingPattern.exec(content)) !== null) {
125
+ headings.push({ level: parseInt(match[1] ?? "0"), index: match.index });
126
+ }
127
+
128
+ for (let i = 1; i < headings.length; i++) {
129
+ const prevH = headings[i - 1];
130
+ const currH = headings[i];
131
+ if (!prevH || !currH) continue;
132
+ const prev = prevH.level;
133
+ const curr = currH.level;
134
+ if (curr > prev + 1) {
135
+ // Find line number from char index
136
+ const beforeSkip = content.slice(0, currH.index);
137
+ const lineNum = (beforeSkip.match(/\n/g) ?? []).length + 1;
138
+ findings.push({
139
+ rule: "dq5-heading-skip",
140
+ file: filePath,
141
+ line: lineNum,
142
+ snippet: `Heading h${curr} skipped from h${prev} (expected h${prev + 1})`,
143
+ severity: "warning",
144
+ });
145
+ }
146
+ }
147
+ return findings;
148
+ }
149
+
150
+ /** DQ6: interactive elements without :focus-visible in the stylesheet */
151
+ function checkFocusIndicator(content: string, filePath: string): Finding[] {
152
+ // Only check .css/.scss files for the rule presence
153
+ if (!/\.(css|scss|sass|less)$/.test(filePath)) return [];
154
+
155
+ const hasFocusVisible = /:focus-visible/i.test(content);
156
+ const hasInteractiveStyle = /button\s*\{|input\s*\{|a\s*\{|\[role=['"]?button['"]?\]/i.test(content);
157
+
158
+ if (hasInteractiveStyle && !hasFocusVisible) {
159
+ return [
160
+ {
161
+ rule: "dq6-focus-indicator",
162
+ file: filePath,
163
+ line: 1,
164
+ snippet:
165
+ "CSS styles interactive elements (button/input/a) but has no :focus-visible rule",
166
+ severity: "warning",
167
+ },
168
+ ];
169
+ }
170
+ return [];
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // File collection and scanning
175
+ // ---------------------------------------------------------------------------
176
+ function* walkDir(dir: string, ext: RegExp): Generator<string> {
177
+ if (!fs.existsSync(dir)) return;
178
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
179
+ const full = path.join(dir, entry.name);
180
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
181
+ if (entry.isDirectory()) {
182
+ yield* walkDir(full, ext);
183
+ } else if (ext.test(entry.name)) {
184
+ yield full;
185
+ }
186
+ }
187
+ }
188
+
189
+ function loadConfig(dir: string): ProjectConfig {
190
+ const configPath = path.join(dir, ".design-quality.json");
191
+ if (!fs.existsSync(configPath)) return {};
192
+ try {
193
+ return JSON.parse(fs.readFileSync(configPath, "utf-8")) as ProjectConfig;
194
+ } catch {
195
+ return {};
196
+ }
197
+ }
198
+
199
+ function parseIgnores(lines: string[]): Map<string, Set<number>> {
200
+ const ignores = new Map<string, Set<number>>();
201
+
202
+ for (let i = 0; i < lines.length; i++) {
203
+ const line = lines[i] ?? "";
204
+ const fileMatch = /design-quality-disable\s+([\w-]+)/i.exec(line);
205
+ if (fileMatch && fileMatch[1] && !line.includes("next-line") && !line.includes("-line ")) {
206
+ const rule = fileMatch[1].toLowerCase();
207
+ ignores.set(rule, new Set([...Array(9999).keys()].map((n) => n + 1)));
208
+ }
209
+ const nextMatch = /design-quality-disable-next-line\s+([\w-]+)/i.exec(line);
210
+ if (nextMatch && nextMatch[1]) {
211
+ const rule = nextMatch[1].toLowerCase();
212
+ const set = ignores.get(rule) ?? new Set<number>();
213
+ set.add(i + 2);
214
+ ignores.set(rule, set);
215
+ }
216
+ const lineMatch = /design-quality-disable-line\s+([\w-]+)/i.exec(line);
217
+ if (lineMatch && lineMatch[1]) {
218
+ const rule = lineMatch[1].toLowerCase();
219
+ const set = ignores.get(rule) ?? new Set<number>();
220
+ set.add(i + 1);
221
+ ignores.set(rule, set);
222
+ }
223
+ }
224
+ return ignores;
225
+ }
226
+
227
+ function isIgnoredFinding(ignores: Map<string, Set<number>>, finding: Finding): boolean {
228
+ const ruleSet = ignores.get(finding.rule.toLowerCase());
229
+ if (!ruleSet) return false;
230
+ return ruleSet.has(finding.line);
231
+ }
232
+
233
+ function scanFile(filePath: string, relPath: string): Finding[] {
234
+ const content = fs.readFileSync(filePath, "utf-8");
235
+ const lines = content.split("\n");
236
+ const ignores = parseIgnores(lines);
237
+
238
+ const rawFindings: Finding[] = [
239
+ ...checkFontSize(lines, relPath),
240
+ ...checkReducedMotion(content, relPath, lines),
241
+ ...checkHeadingHierarchy(content, relPath),
242
+ ...checkFocusIndicator(content, relPath),
243
+ ];
244
+
245
+ return rawFindings.filter((f) => !isIgnoredFinding(ignores, f));
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Main
250
+ // ---------------------------------------------------------------------------
251
+ function main(): void {
252
+ const args = process.argv.slice(2);
253
+ const jsonMode = args.includes("--json");
254
+ const quiet = args.includes("--quiet");
255
+
256
+ // --dir <path> or default to scanning consumer project src/
257
+ const dirIdx = args.indexOf("--dir");
258
+ const dirArg = dirIdx >= 0 ? args[dirIdx + 1] : undefined;
259
+ const scanDir = dirArg ? path.resolve(dirArg) : path.resolve(process.cwd(), "src");
260
+
261
+ if (!fs.existsSync(scanDir)) {
262
+ if (!quiet) process.stderr.write(`[lint_design_quality] scan directory not found: ${scanDir}\n`);
263
+ process.stderr.write(
264
+ "This linter is opt-in for consumer projects. Run with --dir <path> pointing at your UI source.\n",
265
+ );
266
+ process.exit(0);
267
+ }
268
+
269
+ const config = loadConfig(process.cwd());
270
+ const ignoreRules = new Set((config.ignoreRules ?? []).map((r) => r.toLowerCase()));
271
+ const ignoreGlobs = config.ignoreFiles ?? [];
272
+
273
+ const htmlCssFiles: string[] = [];
274
+ for (const f of walkDir(scanDir, /\.(html|htm|css|scss|sass|less|vue|svelte|astro)$/i)) {
275
+ if (!ignoreGlobs.some((g) => f.includes(g.replace("**", "")))) {
276
+ htmlCssFiles.push(f);
277
+ }
278
+ }
279
+
280
+ const allFindings: Finding[] = [];
281
+ for (const filePath of htmlCssFiles) {
282
+ const relPath = path.relative(process.cwd(), filePath);
283
+ try {
284
+ const findings = scanFile(filePath, relPath).filter(
285
+ (f) => !ignoreRules.has(f.rule.toLowerCase()),
286
+ );
287
+ allFindings.push(...findings);
288
+ } catch (err) {
289
+ if (!quiet) process.stderr.write(`[lint_design_quality] error: ${filePath}: ${err}\n`);
290
+ }
291
+ }
292
+
293
+ if (jsonMode) {
294
+ process.stdout.write(JSON.stringify(allFindings, null, 2) + "\n");
295
+ process.exit(allFindings.length > 0 ? 2 : 0);
296
+ }
297
+
298
+ if (allFindings.length === 0) {
299
+ if (!quiet)
300
+ process.stdout.write(
301
+ `✅ lint_design_quality: clean — ${htmlCssFiles.length} file(s) checked, no violations.\n`,
302
+ );
303
+ process.exit(0);
304
+ }
305
+
306
+ const errors = allFindings.filter((f) => f.severity === "error");
307
+ const warnings = allFindings.filter((f) => f.severity === "warning");
308
+
309
+ for (const f of allFindings) {
310
+ const icon = f.severity === "error" ? "❌" : "⚠️ ";
311
+ process.stdout.write(`${icon} ${f.file}:${f.line} [${f.rule}] ${f.snippet}\n`);
312
+ }
313
+
314
+ process.stdout.write(
315
+ `\nlint_design_quality: ${errors.length} error(s), ${warnings.length} warning(s). ` +
316
+ `Use design-quality-disable-next-line <rule> -- reason or .design-quality.json to suppress.\n`,
317
+ );
318
+ process.exit(2);
319
+ }
320
+
321
+ main();
@@ -29,8 +29,8 @@ const PACKS_YML = path.join(REPO_ROOT, 'src', 'config', 'discovery', 'packs.yml'
29
29
  const ADR_WORKSPACES: ReadonlySet<string> = new Set<string>([
30
30
  'engineering', 'product', 'finance', 'founder', 'gtm', 'ops',
31
31
  'small-business', 'construction', 'agent-config-maintainer',
32
- // road-to-legal-pack Phase 0 (ADR-013 amendment, same PR).
33
- 'legal',
32
+ // road-to-legal-pack Phase 0 + road-to-legal-review-prep Phase 0 rename (ADR-013 amendments).
33
+ 'legal-review-prep',
34
34
  ]);
35
35
  const ADR_PACKS: ReadonlySet<string> = new Set<string>([
36
36
  'engineering-base', 'php', 'laravel', 'symfony', 'javascript',
@@ -44,8 +44,8 @@ const ADR_PACKS: ReadonlySet<string> = new Set<string>([
44
44
  'brand',
45
45
  // Pre-existing drift restore: present in packs.yml but missing from this set.
46
46
  'ai-image', 'analysis-workbench',
47
- // road-to-legal-pack Phase 0 (ADR-013 amendment, same PR).
48
- 'legal',
47
+ // road-to-legal-pack Phase 0 + road-to-legal-review-prep Phase 0 rename (ADR-013 amendments).
48
+ 'legal-review-prep',
49
49
  ]);
50
50
 
51
51
  // ADR-010 non-overlap reservations.
@@ -3,7 +3,7 @@
3
3
  * Lint the legal pack's deterministic safety-floor backstops.
4
4
  *
5
5
  * The `legal-safety-floor` rule is prose; this linter makes three of its
6
- * Iron-Law elements machine-checkable over every `packs: [legal]` skill —
6
+ * Iron-Law elements machine-checkable over every `packs: [legal-review-prep]` skill —
7
7
  * so they are governance, not just prompt instructions (road-to-legal-pack
8
8
  * Phase 1, the "deterministic hardening = differentiator" thesis):
9
9
  *
@@ -35,6 +35,45 @@ const ATTORNEY_LINE = 'Attorney review required on material use';
35
35
  const JURISDICTION_TAG = 'Jurisdiction:';
36
36
  const FRESHNESS_WINDOW_RE = /^\d+\s+(day|days|month|months|year|years)$/;
37
37
 
38
+ // D5 — the floor must carry the hard individual-case STOP block (not just a
39
+ // hedge). road-to-legal-review-prep Phase 1. Checked once, against the floor
40
+ // rule, only on a real (default-dir) run.
41
+ const FLOOR_RULE = path.join(REPO, 'src', 'rules', 'legal-safety-floor.md');
42
+ const STOP_MARKER = '🛑 I must stop here';
43
+
44
+ // D6 — attorney-framing-review promotion tripwire (road-to-legal-review-prep
45
+ // Phase 5 / ADR-107 amendment). Promoting the pack out of lab without a recorded
46
+ // German-attorney framing review fails CI.
47
+ const PACKS_YML = path.join(REPO, 'src', 'config', 'discovery', 'packs.yml');
48
+ const PACK_NOTICE = path.join(REPO, 'src', 'domains', 'legal-review-prep', 'LEGAL_NOTICE.md');
49
+ const FRAMING_MARKER = /framing reviewed by\s+\S.*(rechtsanw|attorney|lawyer)/i;
50
+
51
+ /**
52
+ * Pure promotion-gate check. `promoted` = the legal-review-prep pack left its
53
+ * conservative posture (surface_tier !== lab, or trust_level_default !==
54
+ * experimental, or default_install === true). A promoted pack MUST carry a
55
+ * recorded attorney framing review in its LEGAL_NOTICE; otherwise it fails.
56
+ */
57
+ export function legalPromotionViolations(packsYml: string, legalNotice: string): Violation[] {
58
+ const block = /(^|\n)- id:\s*legal-review-prep\b([\s\S]*?)(?=\n- id:|\n*$)/.exec(packsYml);
59
+ if (block === null) return [];
60
+ const body = block[2] ?? '';
61
+ const tier = /(^|\n)\s*surface_tier:\s*([^\n#]+)/.exec(body)?.[2]?.trim();
62
+ const trust = /(^|\n)\s*trust_level_default:\s*([^\n#]+)/.exec(body)?.[2]?.trim();
63
+ const defInstall = /(^|\n)\s*default_install:\s*([^\n#]+)/.exec(body)?.[2]?.trim();
64
+ const promoted = (tier !== undefined && tier !== 'lab')
65
+ || (trust !== undefined && trust !== 'experimental')
66
+ || defInstall === 'true';
67
+ if (promoted && !FRAMING_MARKER.test(legalNotice)) {
68
+ return [{
69
+ file: path.relative(REPO, PACK_NOTICE),
70
+ rule: 'promotion-gate',
71
+ msg: 'legal-review-prep promoted out of lab without a recorded attorney framing review ("Framing reviewed by <name>, Rechtsanwalt/Rechtsanwältin, <date>" in LEGAL_NOTICE.md) — ADR-107 amendment',
72
+ }];
73
+ }
74
+ return [];
75
+ }
76
+
38
77
  // D4 — definitive-legal-language blocklist (the floor bans these; this is the
39
78
  // deterministic backstop over skill bodies). Narrow on purpose; the floor's
40
79
  // prompt layer is the primary enforcement.
@@ -65,12 +104,12 @@ function _splitFrontmatter(content: string): [string | null, string] {
65
104
  return [content.slice(4, end), content.slice(end + 4)];
66
105
  }
67
106
 
68
- /** True when the frontmatter `packs:` block lists `legal`. */
107
+ /** True when the frontmatter `packs:` block lists `legal-review-prep`. */
69
108
  function _isLegalPack(fm: string): boolean {
70
- // Match a `packs:` block then a `- legal` item before the next top-level key.
109
+ // Match a `packs:` block then a `- legal-review-prep` item before the next top-level key.
71
110
  const m = /(^|\n)packs:\s*\n((?:\s*-\s*[^\n]+\n?)+)/.exec(fm);
72
111
  if (!m) return false;
73
- return /(^|\n)\s*-\s*legal\s*$/m.test(m[2] ?? '');
112
+ return /(^|\n)\s*-\s*legal-review-prep\s*$/m.test(m[2] ?? '');
74
113
  }
75
114
 
76
115
  interface Violation {
@@ -103,6 +142,10 @@ export function lintLegalPack(skillsDir: string = SKILLS_DIR): Violation[] {
103
142
  if (!body.includes(JURISDICTION_TAG)) {
104
143
  violations.push({ file: rel, rule: 'jurisdiction-tag', msg: `missing the machine-checkable "${JURISDICTION_TAG}" tag` });
105
144
  }
145
+ // 1.2b council-gate — every legal work-product skill routes deep.
146
+ if (!/(^|\n)council_depth:\s*deep\s*(\n|$)/.test(fm)) {
147
+ violations.push({ file: rel, rule: 'council-depth', msg: 'missing `council_depth: deep` (legal work-product must route through the AI council / deep-research)' });
148
+ }
106
149
  // 1.3 freshness — only when declared
107
150
  const fw = /(^|\n)freshness_window:\s*"?([^"\n]+)"?/.exec(fm);
108
151
  if (fw && !FRESHNESS_WINDOW_RE.test((fw[2] ?? '').trim())) {
@@ -118,6 +161,21 @@ export function lintLegalPack(skillsDir: string = SKILLS_DIR): Violation[] {
118
161
  }
119
162
  }
120
163
  }
164
+ // D5 — the floor itself must carry the hard individual-case STOP block.
165
+ // Only on a real run (synthetic-dir tests pass their own tmp skillsDir).
166
+ if (skillsDir === SKILLS_DIR && fs.existsSync(FLOOR_RULE)) {
167
+ if (!_readText(FLOOR_RULE).includes(STOP_MARKER)) {
168
+ violations.push({
169
+ file: path.relative(REPO, FLOOR_RULE),
170
+ rule: 'individual-case-stop',
171
+ msg: `the floor is missing the hard individual-case STOP block ("${STOP_MARKER}")`,
172
+ });
173
+ }
174
+ // D6 — promotion gate (real-run only).
175
+ if (fs.existsSync(PACKS_YML) && fs.existsSync(PACK_NOTICE)) {
176
+ violations.push(...legalPromotionViolations(_readText(PACKS_YML), _readText(PACK_NOTICE)));
177
+ }
178
+ }
121
179
  return violations;
122
180
  }
123
181