@event4u/agent-config 10.2.0 → 10.4.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 +168 -256
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/reference-repo.md +62 -0
- package/dist/agent-src/commands/analyze.md +2 -1
- package/dist/agent-src/commands/design-system/capture.md +44 -0
- package/dist/agent-src/commands/design-system/generate.md +53 -0
- package/dist/agent-src/commands/design-system/import.md +63 -0
- package/dist/agent-src/commands/design-system.md +66 -0
- package/dist/agent-src/commands/roadmap/create.md +4 -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/design-fidelity.md +49 -3
- package/dist/agent-src/skills/design-review/references/verification-automation.md +23 -1
- package/dist/agent-src/skills/design-system-capture/SKILL.md +9 -0
- package/dist/agent-src/skills/design-system-capture/references/design-system-json.md +70 -0
- 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 +8 -8
- package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- 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/directives/ui/design.ts +4 -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 +1 -1
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-E4RUZKXB.js → chunk-FV34EGEX.js} +1 -1
- package/dist/cli-delegate/chunk-RLN73PR5.js +157 -0
- package/dist/cli-delegate/{chunk-I5EAFRII.js → chunk-ZPSXJTLI.js} +2 -154
- package/dist/cli-delegate/cmd_conformance.js +672 -214
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +8 -6
- package/dist/cli-delegate/cmd_session_recycle.js +5 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +127 -22
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +9 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +8 -4
- package/dist/hooks/dispatch.js +1056 -411
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1103 -593
- package/dist/router.json +1 -1
- package/docs/guidelines/agent-infra/active-remediation-mechanics.md +54 -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/directives/ui/design.ts +4 -1
- package/src/config/gate-coverage.yml +65 -10
- package/src/config/gate-violation-baselines.json +6 -0
- package/src/config/review-prompt-binding-baseline.json +19 -0
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/scripts/_dispatch.bash +1 -0
- package/src/scripts/_lib/design_system_import.ts +987 -0
- package/src/scripts/ai_council/clients.ts +15 -1
- package/src/scripts/check_ci_local_parity.ts +60 -6
- package/src/scripts/check_review_prompt_binding.ts +553 -0
- package/src/scripts/check_suppression_hygiene.ts +19 -3
- package/src/scripts/design_slop_rules.test.ts +37 -6
- package/src/scripts/design_slop_rules.ts +150 -22
- package/src/scripts/design_system_import.ts +264 -0
- package/src/scripts/dispatch_r2_reviewer.ts +15 -1
- package/src/scripts/hook_manifest.yaml +71 -2
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/dispatch_hook.ts +10 -0
- package/src/scripts/hooks/host_semantics.ts +2 -0
- package/src/scripts/hooks/spawn_guard_shadow_hook.ts +212 -0
- package/src/scripts/hooks/subagent_ledger_hook.ts +723 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +39 -2
- 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_hook_manifest.ts +2 -0
- package/src/scripts/lint_rule_skill_pack_reach.ts +194 -13
- package/src/scripts/lint_skill_router_head.ts +254 -0
- package/src/scripts/report_consultation_rate.ts +126 -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/tests/fixtures/hooks/subagent_start.json +7 -0
- package/tests/fixtures/hooks/subagent_stop.json +8 -0
|
@@ -227,9 +227,20 @@ export const SUPPRESSION_INVENTORY: readonly SuppressionSpec[] = [
|
|
|
227
227
|
file: 'src/config/rule-enforcement-baseline.json',
|
|
228
228
|
listKey: null,
|
|
229
229
|
tier: 'string_list',
|
|
230
|
-
newInThisChange: true,
|
|
231
230
|
what: 'rules predating the enforced_by-declaration ratchet',
|
|
232
231
|
},
|
|
232
|
+
{
|
|
233
|
+
// Two committed prompt→verdict bindings that do not re-derive. Both
|
|
234
|
+
// sit inside round records § 2.7 declares immutable, so neither may be
|
|
235
|
+
// repaired by editing the record — the entry pins BOTH hashes, so a
|
|
236
|
+
// later repair or a further corruption reds instead of passing.
|
|
237
|
+
file: 'src/config/review-prompt-binding-baseline.json',
|
|
238
|
+
listKey: 'entries',
|
|
239
|
+
tier: 'object',
|
|
240
|
+
keyFields: ['slug'],
|
|
241
|
+
newInThisChange: true,
|
|
242
|
+
what: 'review prompts whose recorded prompt_hash does not re-derive',
|
|
243
|
+
},
|
|
233
244
|
];
|
|
234
245
|
|
|
235
246
|
export interface SuppressionFinding {
|
|
@@ -391,6 +402,12 @@ export function main(): number {
|
|
|
391
402
|
const entries = loadEntries(spec);
|
|
392
403
|
const reasonField = spec.reasonField ?? 'reason';
|
|
393
404
|
|
|
405
|
+
// Declared BEFORE the stale-flag branch below, which is a file-level
|
|
406
|
+
// finding: it must feed the single terminal resolution at the end of
|
|
407
|
+
// this iteration rather than resolve the target itself. Resolving twice
|
|
408
|
+
// is a `LedgerUsageError` that aborts the whole gate.
|
|
409
|
+
let fileFindings = 0;
|
|
410
|
+
|
|
394
411
|
let comparison: RatchetComparison;
|
|
395
412
|
try {
|
|
396
413
|
comparison = compareToBaseRef({
|
|
@@ -413,8 +430,8 @@ export function main(): number {
|
|
|
413
430
|
`\`newInThisChange\` is still set, but the baseline now resolves at ${baseRef} — ` +
|
|
414
431
|
'the flag has served its purpose and must be removed from SUPPRESSION_INVENTORY. ' +
|
|
415
432
|
'Leaving it set makes a future mistyped path pass as a new baseline forever.';
|
|
416
|
-
ledger.fail(spec.file, detail);
|
|
417
433
|
findings.push({ file: spec.file, entry: '(whole file)', kind: 'uninventoried', detail });
|
|
434
|
+
fileFindings += 1;
|
|
418
435
|
}
|
|
419
436
|
} catch (exc) {
|
|
420
437
|
if (exc instanceof BaseRefUnavailableError) {
|
|
@@ -426,7 +443,6 @@ export function main(): number {
|
|
|
426
443
|
}
|
|
427
444
|
|
|
428
445
|
const added = new Set(comparison.added);
|
|
429
|
-
let fileFindings = 0;
|
|
430
446
|
|
|
431
447
|
for (const entry of entries) {
|
|
432
448
|
if (spec.tier === 'object' && entry.reason.length < MIN_REASON_CHARS) {
|
|
@@ -100,16 +100,11 @@ const FIXTURES: Record<string, { ext: string; positive: string; negative: string
|
|
|
100
100
|
negative:
|
|
101
101
|
'<h1>Get Started</h1><p>Deploys are marked ✅ when healthy.</p><td>✅</td><button>Track progress</button>',
|
|
102
102
|
},
|
|
103
|
-
"slop-lock-shape": {
|
|
103
|
+
"slop-v8-lock-shape": {
|
|
104
104
|
ext: "css",
|
|
105
105
|
positive: ".a{border-radius:4px}.b{border-radius:8px}.c{border-radius:12px}.d{border-radius:20px}",
|
|
106
106
|
negative: ".a{border-radius:8px}.b{border-radius:12px}",
|
|
107
107
|
},
|
|
108
|
-
"slop-lock-colour": {
|
|
109
|
-
ext: "css",
|
|
110
|
-
positive: ".a{color:#e11d48}.b{background:#2563eb}.c{border-color:#16a34a}",
|
|
111
|
-
negative: ".a{color:#111827}.b{background:#6b7280}.c{border-color:#2563eb}",
|
|
112
|
-
},
|
|
113
108
|
"slop-l9-section-monotony": {
|
|
114
109
|
ext: "html",
|
|
115
110
|
// 8 identical media+text sections → 1 distinct layout family (< 4)
|
|
@@ -129,6 +124,42 @@ const FIXTURES: Record<string, { ext: string; positive: string; negative: string
|
|
|
129
124
|
// only 2 consecutive → within cap
|
|
130
125
|
negative: '<section><div class="grid"><img src="x"><h2>A</h2><p>t</p></div></section>'.repeat(2),
|
|
131
126
|
},
|
|
127
|
+
"slop-v4-over-rounded": {
|
|
128
|
+
ext: "css",
|
|
129
|
+
// 24px radius on an 80px chip — mid-range rounding on a small surface
|
|
130
|
+
positive: ".chip{width:80px;border-radius:24px;background:#1f5f9c}",
|
|
131
|
+
// a deliberate pill is a shape decision, not over-rounding
|
|
132
|
+
negative: ".chip{width:80px;border-radius:9999px;background:#1f5f9c}",
|
|
133
|
+
},
|
|
134
|
+
"slop-c3-dark-glow": {
|
|
135
|
+
ext: "css",
|
|
136
|
+
// zero-offset saturated glow inside a declared dark surface
|
|
137
|
+
positive: ".dark .btn{background:#0b0f14;box-shadow:0 0 18px #22d3ee}",
|
|
138
|
+
// same dark surface, ordinary offset depth — not a glow
|
|
139
|
+
negative: ".dark .btn{background:#0b0f14;box-shadow:0 2px 8px #22d3ee}",
|
|
140
|
+
},
|
|
141
|
+
"slop-t9-uppercase-body": {
|
|
142
|
+
ext: "css",
|
|
143
|
+
positive: "article p{text-transform:uppercase;font-size:16px}",
|
|
144
|
+
// a short field label is the legitimate home of uppercase
|
|
145
|
+
negative: ".field-label{text-transform:uppercase;font-size:12px}",
|
|
146
|
+
},
|
|
147
|
+
"slop-t10-wide-tracking-body": {
|
|
148
|
+
ext: "css",
|
|
149
|
+
positive: "article p{letter-spacing:0.12em;font-size:16px}",
|
|
150
|
+
negative: "article p{letter-spacing:0.01em;font-size:16px}",
|
|
151
|
+
},
|
|
152
|
+
"slop-m1-bounce-easing": {
|
|
153
|
+
ext: "css",
|
|
154
|
+
// y1 = 1.56 overshoots the target and returns — the bounce signature
|
|
155
|
+
positive: ".card{transition:transform .3s cubic-bezier(.34,1.56,.64,1)}",
|
|
156
|
+
negative: ".card{transition:transform .3s cubic-bezier(.4,0,.2,1)}",
|
|
157
|
+
},
|
|
158
|
+
"slop-m3-img-hover-transform": {
|
|
159
|
+
ext: "css",
|
|
160
|
+
positive: ".tile:hover img{transform:scale(1.05)}",
|
|
161
|
+
negative: ".tile:hover .tile__title{color:#16202b}",
|
|
162
|
+
},
|
|
132
163
|
};
|
|
133
164
|
|
|
134
165
|
function findingsFor(ruleId: string, content: string, ext: string, ctx = NO_CTX) {
|
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
* dependency-free approach. Council 2026-06-28 assumed those libs were present;
|
|
18
18
|
* for THIS package, pattern-based is the correct, frugal adaptation.
|
|
19
19
|
*
|
|
20
|
+
* WHAT THE NEGATIVE FIXTURES ARE NOT: each rule ships one positive and one
|
|
21
|
+
* negative fixture, and the suite asserts both exist. That is a PRESENCE guard —
|
|
22
|
+
* "this regex stays quiet on one clean snippet crafted for it" — and it is NOT a
|
|
23
|
+
* false-positive RATE. Nineteen green negatives say nothing about how often a
|
|
24
|
+
* rule fires on clean UI it has never seen. Reading them as an FP measurement is
|
|
25
|
+
* the mistake that makes the registry look better-evidenced than it is; the rate
|
|
26
|
+
* is measured against the clean corpus under `internal/bench/corpora/`, not here.
|
|
27
|
+
*
|
|
20
28
|
* Severity is a REBUTTABLE PRESUMPTION, never a hard block:
|
|
21
29
|
* P0 — objective failure (none here; those live in lint_design_quality)
|
|
22
30
|
* P1 — high-confidence tell (e.g. the side-stripe signature)
|
|
@@ -437,7 +445,7 @@ export const SLOP_RULES: SlopRule[] = [
|
|
|
437
445
|
},
|
|
438
446
|
},
|
|
439
447
|
{
|
|
440
|
-
id: "slop-lock-shape",
|
|
448
|
+
id: "slop-v8-lock-shape",
|
|
441
449
|
catalogId: "V8",
|
|
442
450
|
severity: "P2",
|
|
443
451
|
engines: ["css"],
|
|
@@ -464,27 +472,6 @@ export const SLOP_RULES: SlopRule[] = [
|
|
|
464
472
|
return [];
|
|
465
473
|
},
|
|
466
474
|
},
|
|
467
|
-
{
|
|
468
|
-
id: "slop-lock-colour",
|
|
469
|
-
catalogId: "C6",
|
|
470
|
-
severity: "P3",
|
|
471
|
-
engines: ["css"],
|
|
472
|
-
description: "Colour Lock — multiple saturated accent hue families",
|
|
473
|
-
message: "Several accent hue families read as no single accent identity; lock one (C6 / Colour Lock).",
|
|
474
|
-
gated: (ctx) => ctx.has("colour lock", "color lock"),
|
|
475
|
-
detect: ({ content }) => {
|
|
476
|
-
const buckets = new Set<number>();
|
|
477
|
-
for (const pm of content.matchAll(/#([0-9a-f]{3}|[0-9a-f]{6})\b/gi)) {
|
|
478
|
-
const b = hueBucket("#" + (pm[1] ?? ""));
|
|
479
|
-
if (b !== null) buckets.add(b);
|
|
480
|
-
}
|
|
481
|
-
if (buckets.size >= 3) {
|
|
482
|
-
const idx = content.search(/#[0-9a-f]{3,6}\b/i);
|
|
483
|
-
return [{ line: lineOf(content, Math.max(0, idx)), snippet: `${buckets.size} distinct accent hue families` }];
|
|
484
|
-
}
|
|
485
|
-
return [];
|
|
486
|
-
},
|
|
487
|
-
},
|
|
488
475
|
{
|
|
489
476
|
id: "slop-l9-section-monotony",
|
|
490
477
|
catalogId: "L9",
|
|
@@ -527,4 +514,145 @@ export const SLOP_RULES: SlopRule[] = [
|
|
|
527
514
|
return [];
|
|
528
515
|
},
|
|
529
516
|
},
|
|
517
|
+
// -------------------------------------------------------------------------
|
|
518
|
+
// Catalog thresholds promoted to rules (road-to-design-detector-evidence
|
|
519
|
+
// Phase 3). Each entry below already published its number in
|
|
520
|
+
// docs/guidelines/design-antipatterns.md — the prose is the specification,
|
|
521
|
+
// nothing here is invented, and each was graded per rule against the clean
|
|
522
|
+
// corpus rather than as a batch.
|
|
523
|
+
// -------------------------------------------------------------------------
|
|
524
|
+
{
|
|
525
|
+
id: "slop-v4-over-rounded",
|
|
526
|
+
catalogId: "V4",
|
|
527
|
+
severity: "P3",
|
|
528
|
+
engines: ["css"],
|
|
529
|
+
description: "border-radius > 16px on an element declared under 200px wide",
|
|
530
|
+
message: "Heavy rounding on a small surface reads as scaffold-default; scale the radius to the element (V4).",
|
|
531
|
+
gated: (ctx) => ctx.has("pill", "fully rounded", "soft ui", "claymorphism"),
|
|
532
|
+
detect: ({ content }) =>
|
|
533
|
+
cssBlocks(content)
|
|
534
|
+
.filter((b) => {
|
|
535
|
+
// A pill (9999px / 50% / 999px) and a circle are deliberate shapes,
|
|
536
|
+
// not over-rounding — the tell is a mid-range radius on a small box.
|
|
537
|
+
const r = /border-radius\s*:\s*(\d+)px/i.exec(b.body);
|
|
538
|
+
if (!r?.[1]) return false;
|
|
539
|
+
const radius = parseInt(r[1], 10);
|
|
540
|
+
if (radius <= 16 || radius >= 100) return false;
|
|
541
|
+
// "small" must be declared in the same block; nothing here infers
|
|
542
|
+
// layout, because inferring it would need a cascade.
|
|
543
|
+
const size = /(?:^|[;{\s])(?:max-)?(?:width|height)\s*:\s*(\d+)px/i.exec(b.body);
|
|
544
|
+
return size?.[1] !== undefined && parseInt(size[1], 10) < 200;
|
|
545
|
+
})
|
|
546
|
+
.map((b) => ({ line: b.line, snippet: b.selector.slice(0, 80) })),
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
id: "slop-c3-dark-glow",
|
|
550
|
+
catalogId: "C3",
|
|
551
|
+
severity: "P2",
|
|
552
|
+
engines: ["css"],
|
|
553
|
+
description: "Saturated zero-offset glow (box-shadow / text-shadow) in a dark context",
|
|
554
|
+
message: "A neon glow on dark reads as gaming-UI default rather than a considered accent (C3).",
|
|
555
|
+
gated: (ctx) => ctx.has("glow", "neon", "cyberpunk", "gaming"),
|
|
556
|
+
detect: ({ content, lines }) => {
|
|
557
|
+
// The tell is dark-context-specific, so a file with no dark surface at all
|
|
558
|
+
// is out of scope — a glow on a light page is a different question.
|
|
559
|
+
const darkContext =
|
|
560
|
+
/prefers-color-scheme\s*:\s*dark/i.test(content) ||
|
|
561
|
+
/\.dark\b|\[data-theme=["']?dark/i.test(content) ||
|
|
562
|
+
/background(?:-color)?\s*:\s*#(?:0[0-9a-f]{5}|1[0-9a-f]{5}|000|111)\b/i.test(content);
|
|
563
|
+
if (!darkContext) return [];
|
|
564
|
+
const out: RawHit[] = [];
|
|
565
|
+
for (let i = 0; i < lines.length; i++) {
|
|
566
|
+
const l = lines[i] ?? "";
|
|
567
|
+
// zero-offset + real blur is the glow signature; an offset shadow is depth.
|
|
568
|
+
const m = /(?:box|text)-shadow\s*:\s*0\s+0\s+(\d+)px[^;]*?(#[0-9a-f]{3,8})/i.exec(l);
|
|
569
|
+
if (!m?.[1] || !m[2]) continue;
|
|
570
|
+
if (parseInt(m[1], 10) < 8) continue;
|
|
571
|
+
if (hueBucket(m[2]) === null) continue; // a neutral halo is not a neon accent
|
|
572
|
+
out.push({ line: i + 1, snippet: l.trim().slice(0, 80) });
|
|
573
|
+
}
|
|
574
|
+
return out;
|
|
575
|
+
},
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
id: "slop-t9-uppercase-body",
|
|
579
|
+
catalogId: "T9",
|
|
580
|
+
severity: "P3",
|
|
581
|
+
engines: ["css"],
|
|
582
|
+
description: "text-transform: uppercase applied to body-length text",
|
|
583
|
+
message: "All-caps destroys word-shape for sustained reading; reserve it for short labels (T9).",
|
|
584
|
+
gated: (ctx) => ctx.has("all caps", "all-caps", "uppercase display"),
|
|
585
|
+
detect: ({ content }) =>
|
|
586
|
+
cssBlocks(content)
|
|
587
|
+
// Short UI surfaces (labels, buttons, table headers, badges, eyebrows)
|
|
588
|
+
// are the legitimate home of uppercase and are excluded by selector.
|
|
589
|
+
.filter((b) => /\b(p|body|article|\.prose|\.body|\.content|\.copy|blockquote|li)\b/i.test(b.selector))
|
|
590
|
+
.filter((b) => !/label|button|\bth\b|badge|chip|tag|eyebrow|kicker|caption|nav/i.test(b.selector))
|
|
591
|
+
.filter((b) => /text-transform\s*:\s*uppercase/i.test(b.body))
|
|
592
|
+
.map((b) => ({ line: b.line, snippet: b.selector.slice(0, 80) })),
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
id: "slop-t10-wide-tracking-body",
|
|
596
|
+
catalogId: "T10",
|
|
597
|
+
severity: "P3",
|
|
598
|
+
engines: ["css"],
|
|
599
|
+
description: "letter-spacing above 0.05em on body-length text",
|
|
600
|
+
message: "Wide tracking slows sustained reading; it belongs to display type, not body (T10).",
|
|
601
|
+
gated: (ctx) => ctx.has("wide tracking", "letterspacing", "letter-spacing"),
|
|
602
|
+
detect: ({ content }) =>
|
|
603
|
+
cssBlocks(content)
|
|
604
|
+
.filter((b) => /\b(p|body|article|\.prose|\.body|\.content|\.copy|blockquote|li)\b/i.test(b.selector))
|
|
605
|
+
.filter((b) => !/label|button|\bth\b|badge|chip|tag|eyebrow|kicker|caption|nav|h[1-6]/i.test(b.selector))
|
|
606
|
+
.filter((b) => {
|
|
607
|
+
const em = /letter-spacing\s*:\s*(\d*\.?\d+)(em|rem)/i.exec(b.body);
|
|
608
|
+
if (em?.[1]) return parseFloat(em[1]) > 0.05;
|
|
609
|
+
const px = /letter-spacing\s*:\s*(\d*\.?\d+)px/i.exec(b.body);
|
|
610
|
+
return px?.[1] !== undefined && parseFloat(px[1]) > 0.8;
|
|
611
|
+
})
|
|
612
|
+
.map((b) => ({ line: b.line, snippet: b.selector.slice(0, 80) })),
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
id: "slop-m1-bounce-easing",
|
|
616
|
+
catalogId: "M1",
|
|
617
|
+
severity: "P2",
|
|
618
|
+
engines: ["css"],
|
|
619
|
+
description: "Bounce or elastic easing on a UI transition",
|
|
620
|
+
message: "Overshoot draws attention to the animation instead of the state change (M1).",
|
|
621
|
+
gated: (ctx) => ctx.has("bounce", "playful motion", "elastic"),
|
|
622
|
+
detect: ({ lines }) => {
|
|
623
|
+
const out: RawHit[] = [];
|
|
624
|
+
for (let i = 0; i < lines.length; i++) {
|
|
625
|
+
const l = lines[i] ?? "";
|
|
626
|
+
// Overshoot is decidable from the curve itself: a control point outside
|
|
627
|
+
// [0,1] on the output axis means the value passes its target and returns.
|
|
628
|
+
const cb = /cubic-bezier\(\s*[-\d.]+\s*,\s*(-?[\d.]+)\s*,\s*[-\d.]+\s*,\s*(-?[\d.]+)\s*\)/i.exec(l);
|
|
629
|
+
if (cb?.[1] !== undefined && cb[2] !== undefined) {
|
|
630
|
+
const y1 = parseFloat(cb[1]);
|
|
631
|
+
const y2 = parseFloat(cb[2]);
|
|
632
|
+
if (y1 < 0 || y1 > 1 || y2 < 0 || y2 > 1) {
|
|
633
|
+
out.push({ line: i + 1, snippet: l.trim().slice(0, 80) });
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (/(?:transition|animation)[^;]*\b(?:bounce|elastic|back(?:In|Out|InOut))\b/i.test(l)) {
|
|
638
|
+
out.push({ line: i + 1, snippet: l.trim().slice(0, 80) });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return out;
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
id: "slop-m3-img-hover-transform",
|
|
646
|
+
catalogId: "M3",
|
|
647
|
+
severity: "P3",
|
|
648
|
+
engines: ["css"],
|
|
649
|
+
description: "transform or filter animation on an <img> hover",
|
|
650
|
+
message: "Hover-scaling an image triggers composite plus decode and often shifts layout (M3).",
|
|
651
|
+
gated: (ctx) => ctx.has("image hover", "hover zoom", "ken burns"),
|
|
652
|
+
detect: ({ content }) =>
|
|
653
|
+
cssBlocks(content)
|
|
654
|
+
.filter((b) => /\bimg\b/i.test(b.selector) && /:hover|:focus-within/i.test(b.selector))
|
|
655
|
+
.filter((b) => /(?:^|[;{\s])(?:transform|filter)\s*:/i.test(b.body))
|
|
656
|
+
.map((b) => ({ line: b.line, snippet: b.selector.slice(0, 80) })),
|
|
657
|
+
},
|
|
530
658
|
];
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Import an extracted design system into the `design-system.json` contract.
|
|
4
|
+
*
|
|
5
|
+
* Producer-side half of the contract in
|
|
6
|
+
* `design-system-capture/references/design-system-json.md`
|
|
7
|
+
* (road-to-design-system-onramp Phase 1). Three input lanes — a native
|
|
8
|
+
* artifact, a W3C DTCG token file, or an extraction tool's raw JSON — one
|
|
9
|
+
* output shape.
|
|
10
|
+
*
|
|
11
|
+
* **Pure and offline by construction.** It reads a file, transforms it, and
|
|
12
|
+
* writes to stdout. No network, no browser, no crawl: the package owns the
|
|
13
|
+
* contract and not the crawler (council 2026-06-28), and a transform that
|
|
14
|
+
* fetched would quietly become the thing the lock forbids.
|
|
15
|
+
*
|
|
16
|
+
* The output is a **proposal**, never an applied change. It is handed to
|
|
17
|
+
* `design-system-capture`, whose import step confirms it per field against
|
|
18
|
+
* `DESIGN.md` and flags — never auto-applies — anything that conflicts with a
|
|
19
|
+
* registered brand token.
|
|
20
|
+
*
|
|
21
|
+
* CLI:
|
|
22
|
+
*
|
|
23
|
+
* ./scripts-run src/scripts/design_system_import <file> [options]
|
|
24
|
+
*
|
|
25
|
+
* --lane {native,dtcg,dembrandt} Force a lane instead of detecting it.
|
|
26
|
+
* --source-kind {url,repo,dir} Provenance for formats that carry none.
|
|
27
|
+
* --source-ref REF Provenance ref (the URL / repo / path).
|
|
28
|
+
* --captured-at ISO Capture time; omitted records "unknown".
|
|
29
|
+
* --format {json,summary} json (default) or a human-readable summary.
|
|
30
|
+
*
|
|
31
|
+
* Exit codes:
|
|
32
|
+
*
|
|
33
|
+
* 0 — imported; the artifact is on stdout (or its summary).
|
|
34
|
+
* 1 — the input was rejected (no lane matched, or no provenance).
|
|
35
|
+
* 2 — argument error.
|
|
36
|
+
* 3 — internal error.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import * as fs from 'node:fs';
|
|
40
|
+
import * as path from 'node:path';
|
|
41
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
type ImportOutcome,
|
|
45
|
+
type Lane,
|
|
46
|
+
type ProvenanceOverride,
|
|
47
|
+
importDesignSystem,
|
|
48
|
+
} from './_lib/design_system_import.js';
|
|
49
|
+
|
|
50
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
51
|
+
|
|
52
|
+
const LANES = new Set(['native', 'dtcg', 'dembrandt']);
|
|
53
|
+
const SOURCE_KINDS = new Set(['url', 'repo', 'dir']);
|
|
54
|
+
const FORMATS = new Set(['json', 'summary']);
|
|
55
|
+
|
|
56
|
+
const USAGE =
|
|
57
|
+
'usage: design_system_import <file> [--lane {native,dtcg,dembrandt}] ' +
|
|
58
|
+
'[--source-kind {url,repo,dir}] [--source-ref REF] [--captured-at ISO] ' +
|
|
59
|
+
'[--format {json,summary}]';
|
|
60
|
+
|
|
61
|
+
interface Args {
|
|
62
|
+
file: string;
|
|
63
|
+
lane: Lane | null;
|
|
64
|
+
sourceKind: 'url' | 'repo' | 'dir' | null;
|
|
65
|
+
sourceRef: string | null;
|
|
66
|
+
capturedAt: string | null;
|
|
67
|
+
format: 'json' | 'summary';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function argError(message: string): never {
|
|
71
|
+
process.stderr.write(`${USAGE}\ndesign_system_import: error: ${message}\n`);
|
|
72
|
+
process.exit(2);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function parse_args(argv: readonly string[]): Args {
|
|
76
|
+
const args: Args = {
|
|
77
|
+
file: '',
|
|
78
|
+
lane: null,
|
|
79
|
+
sourceKind: null,
|
|
80
|
+
sourceRef: null,
|
|
81
|
+
capturedAt: null,
|
|
82
|
+
format: 'json',
|
|
83
|
+
};
|
|
84
|
+
const positional: string[] = [];
|
|
85
|
+
for (let i = 0; i < argv.length; i++) {
|
|
86
|
+
const a = argv[i] as string;
|
|
87
|
+
const eq = a.indexOf('=');
|
|
88
|
+
const flag = a.startsWith('--') && eq > 0 ? a.slice(0, eq) : a;
|
|
89
|
+
const inline = a.startsWith('--') && eq > 0 ? a.slice(eq + 1) : null;
|
|
90
|
+
const take = (): string => {
|
|
91
|
+
if (inline !== null) return inline;
|
|
92
|
+
const next = argv[++i];
|
|
93
|
+
if (next === undefined) argError(`argument ${flag}: expected one argument`);
|
|
94
|
+
return next;
|
|
95
|
+
};
|
|
96
|
+
switch (flag) {
|
|
97
|
+
case '-h':
|
|
98
|
+
case '--help':
|
|
99
|
+
process.stdout.write(`${USAGE}\n`);
|
|
100
|
+
process.exit(0);
|
|
101
|
+
break;
|
|
102
|
+
case '--lane': {
|
|
103
|
+
const v = take();
|
|
104
|
+
if (!LANES.has(v)) {
|
|
105
|
+
argError(`argument --lane: invalid choice: '${v}' (choose from 'native', 'dtcg', 'dembrandt')`);
|
|
106
|
+
}
|
|
107
|
+
args.lane = v as Lane;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case '--source-kind': {
|
|
111
|
+
const v = take();
|
|
112
|
+
if (!SOURCE_KINDS.has(v)) {
|
|
113
|
+
argError(`argument --source-kind: invalid choice: '${v}' (choose from 'url', 'repo', 'dir')`);
|
|
114
|
+
}
|
|
115
|
+
args.sourceKind = v as 'url' | 'repo' | 'dir';
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case '--source-ref':
|
|
119
|
+
args.sourceRef = take();
|
|
120
|
+
break;
|
|
121
|
+
case '--captured-at':
|
|
122
|
+
args.capturedAt = take();
|
|
123
|
+
break;
|
|
124
|
+
case '--format': {
|
|
125
|
+
const v = take();
|
|
126
|
+
if (!FORMATS.has(v)) {
|
|
127
|
+
argError(`argument --format: invalid choice: '${v}' (choose from 'json', 'summary')`);
|
|
128
|
+
}
|
|
129
|
+
args.format = v as 'json' | 'summary';
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
default:
|
|
133
|
+
if (a.startsWith('-')) argError(`unrecognized arguments: ${a}`);
|
|
134
|
+
positional.push(a);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (positional.length === 0) argError('the following arguments are required: file');
|
|
138
|
+
if (positional.length > 1) argError(`unrecognized arguments: ${positional.slice(1).join(' ')}`);
|
|
139
|
+
args.file = positional[0] as string;
|
|
140
|
+
// Half a provenance is worse than none: it reads as complete in the output
|
|
141
|
+
// while naming nothing traceable, and the contract's whole point is that a
|
|
142
|
+
// reader can go back to the source.
|
|
143
|
+
if ((args.sourceKind === null) !== (args.sourceRef === null)) {
|
|
144
|
+
argError('--source-kind and --source-ref must be given together');
|
|
145
|
+
}
|
|
146
|
+
// `--captured-at` alone was accepted and then ignored: the override object
|
|
147
|
+
// is only built when the kind/ref pair is present. Silently discarding a
|
|
148
|
+
// flag the user typed is worse than refusing it.
|
|
149
|
+
if (args.capturedAt !== null && args.sourceKind === null) {
|
|
150
|
+
argError('--captured-at requires --source-kind and --source-ref');
|
|
151
|
+
}
|
|
152
|
+
return args;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One line per fact, so a human can see what was mapped without reading JSON. */
|
|
156
|
+
export function render_summary(outcome: ImportOutcome): string {
|
|
157
|
+
if (!outcome.ok) {
|
|
158
|
+
return `REJECTED (${outcome.lane ?? 'no lane'})\n ${outcome.reason}`;
|
|
159
|
+
}
|
|
160
|
+
const ds = outcome.design_system;
|
|
161
|
+
const lines: string[] = [];
|
|
162
|
+
lines.push(`lane: ${outcome.lane}`);
|
|
163
|
+
lines.push(`source: ${ds.source.kind} ${ds.source.ref} @ ${ds.source.captured_at} (provenance from ${ds.source._meta?.provenance_origin ?? 'input'})`);
|
|
164
|
+
const counts: string[] = [];
|
|
165
|
+
const light = Object.keys(ds.colors?.light ?? {}).length;
|
|
166
|
+
const dark = Object.keys(ds.colors?.dark ?? {}).length;
|
|
167
|
+
if (light + dark > 0) counts.push(`colors: ${light} light / ${dark} dark`);
|
|
168
|
+
if (ds.typography?.families?.length) counts.push(`font families: ${ds.typography.families.length}`);
|
|
169
|
+
if (ds.typography?.scale?.length) counts.push(`type steps: ${ds.typography.scale.length}`);
|
|
170
|
+
if (ds.spacing?.scale?.length) counts.push(`spacing steps: ${ds.spacing.scale.length}`);
|
|
171
|
+
if (ds.radius) counts.push(`radius roles: ${Object.keys(ds.radius).length}`);
|
|
172
|
+
if (ds.shadow) counts.push(`shadow roles: ${Object.keys(ds.shadow).length}`);
|
|
173
|
+
if (ds.motion?.durations) counts.push(`motion durations: ${Object.keys(ds.motion.durations).length}`);
|
|
174
|
+
if (ds.motion?.easings) counts.push(`motion easings: ${Object.keys(ds.motion.easings).length}`);
|
|
175
|
+
if (ds.components?.length) counts.push(`components: ${ds.components.length}`);
|
|
176
|
+
lines.push(counts.length > 0 ? `mapped — ${counts.join(', ')}` : 'mapped — nothing (observation only)');
|
|
177
|
+
for (const note of outcome.notes) lines.push(`note: ${note}`);
|
|
178
|
+
lines.push('');
|
|
179
|
+
lines.push('This artifact is observed, not authoritative. Hand it to design-system-capture,');
|
|
180
|
+
lines.push('which confirms it per field; a value conflicting with a registered brand token is');
|
|
181
|
+
lines.push('flagged, never auto-applied.');
|
|
182
|
+
return lines.join('\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
186
|
+
const args = parse_args(argv);
|
|
187
|
+
|
|
188
|
+
let raw: string;
|
|
189
|
+
try {
|
|
190
|
+
raw = fs.readFileSync(args.file, 'utf-8');
|
|
191
|
+
} catch (exc) {
|
|
192
|
+
const msg = exc instanceof Error ? exc.message : String(exc);
|
|
193
|
+
process.stderr.write(`design_system_import: cannot read ${args.file}: ${msg}\n`);
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let parsed: unknown;
|
|
198
|
+
try {
|
|
199
|
+
parsed = JSON.parse(raw);
|
|
200
|
+
} catch (exc) {
|
|
201
|
+
const msg = exc instanceof Error ? exc.message : String(exc);
|
|
202
|
+
process.stderr.write(`design_system_import: ${args.file} is not valid JSON: ${msg}\n`);
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const provenance: ProvenanceOverride | undefined =
|
|
207
|
+
args.sourceKind !== null && args.sourceRef !== null
|
|
208
|
+
? {
|
|
209
|
+
kind: args.sourceKind,
|
|
210
|
+
ref: args.sourceRef,
|
|
211
|
+
...(args.capturedAt !== null ? { captured_at: args.capturedAt } : {}),
|
|
212
|
+
}
|
|
213
|
+
: undefined;
|
|
214
|
+
|
|
215
|
+
const outcome = importDesignSystem(parsed, provenance, args.lane ?? undefined);
|
|
216
|
+
|
|
217
|
+
// The input's own provenance wins over a caller override — defensible, and
|
|
218
|
+
// previously invisible: a user who typed --source-ref was never told the
|
|
219
|
+
// flag had no effect on this file.
|
|
220
|
+
if (
|
|
221
|
+
outcome.ok &&
|
|
222
|
+
provenance !== undefined &&
|
|
223
|
+
outcome.design_system.source._meta?.provenance_origin === 'input'
|
|
224
|
+
) {
|
|
225
|
+
outcome.notes.push(
|
|
226
|
+
`the file carries its own source block, so it wins over --source-ref "${provenance.ref}" — the supplied provenance was not used`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!outcome.ok) {
|
|
231
|
+
process.stderr.write(`${render_summary(outcome)}\n`);
|
|
232
|
+
return 1;
|
|
233
|
+
}
|
|
234
|
+
if (args.format === 'summary') {
|
|
235
|
+
process.stdout.write(`${render_summary(outcome)}\n`);
|
|
236
|
+
} else {
|
|
237
|
+
process.stdout.write(`${JSON.stringify(outcome.design_system, null, 2)}\n`);
|
|
238
|
+
for (const note of outcome.notes) process.stderr.write(`note: ${note}\n`);
|
|
239
|
+
}
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function _isCliEntry(): boolean {
|
|
244
|
+
if (process.argv[1] === undefined) return false;
|
|
245
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
246
|
+
if (import.meta.url === argvUrl) return true;
|
|
247
|
+
try {
|
|
248
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
249
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
250
|
+
return here === argv;
|
|
251
|
+
} catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
257
|
+
try {
|
|
258
|
+
process.exit(main());
|
|
259
|
+
} catch (exc) {
|
|
260
|
+
const msg = exc instanceof Error ? exc.message : String(exc);
|
|
261
|
+
process.stderr.write(`design_system_import: internal error: ${msg}\n`);
|
|
262
|
+
process.exit(3);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -642,7 +642,21 @@ function reviewerPrompt(args: {
|
|
|
642
642
|
* Writing the hash of the prompt THIS dispatcher built makes the channel
|
|
643
643
|
* attributable: the artefact now records which prompt produced it, so a review
|
|
644
644
|
* run against a different prompt is distinguishable from one run against the
|
|
645
|
-
* dispatcher's.
|
|
645
|
+
* dispatcher's.
|
|
646
|
+
*
|
|
647
|
+
* `--verify` / `--verify-current` do NOT re-derive it. {@link runVerify}
|
|
648
|
+
* re-derives `scope_hash`, `roadmap_hash` and `ac_hash` only — it re-computes
|
|
649
|
+
* the review's INPUTS from the current repo state, and the prompt is not one of
|
|
650
|
+
* them (it is not reconstructible from the repo; it is an artefact this
|
|
651
|
+
* dispatcher wrote). An earlier revision of this comment claimed otherwise for
|
|
652
|
+
* roughly a year, which is how the field came to be written, parsed, and
|
|
653
|
+
* compared to nothing.
|
|
654
|
+
*
|
|
655
|
+
* What compares it is `check_review_prompt_binding`, against the prompt package
|
|
656
|
+
* this dispatcher commits beside the artefact (`<slug>.review-input/prompt.md`,
|
|
657
|
+
* written below) — a different check with a different scope: corpus-wide rather
|
|
658
|
+
* than current-branch, because a committed prompt and its recorded hash are both
|
|
659
|
+
* immutable.
|
|
646
660
|
*
|
|
647
661
|
* It does not CLOSE the residual and is not sold as doing so: the same host that
|
|
648
662
|
* authors a steered prompt could hash the steered text. The gain is an
|