@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.
Files changed (96) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +168 -256
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/reference-repo.md +62 -0
  5. package/dist/agent-src/commands/analyze.md +2 -1
  6. package/dist/agent-src/commands/design-system/capture.md +44 -0
  7. package/dist/agent-src/commands/design-system/generate.md +53 -0
  8. package/dist/agent-src/commands/design-system/import.md +63 -0
  9. package/dist/agent-src/commands/design-system.md +66 -0
  10. package/dist/agent-src/commands/roadmap/create.md +4 -1
  11. package/dist/agent-src/personas/advisors/first-principles.md +6 -0
  12. package/dist/agent-src/rules/active-remediation.md +53 -2
  13. package/dist/agent-src/rules/code-provenance.md +66 -4
  14. package/dist/agent-src/rules/content-quoting-floor.md +6 -0
  15. package/dist/agent-src/rules/design-fidelity.md +49 -3
  16. package/dist/agent-src/skills/design-review/references/verification-automation.md +23 -1
  17. package/dist/agent-src/skills/design-system-capture/SKILL.md +9 -0
  18. package/dist/agent-src/skills/design-system-capture/references/design-system-json.md +70 -0
  19. package/dist/agent-src/skills/fe-design/SKILL.md +27 -3
  20. package/dist/agent-src/skills/judge-synthesis/SKILL.md +30 -0
  21. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +11 -0
  22. package/dist/agent-src/skills/roadmap-writing/SKILL.md +8 -8
  23. package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
  24. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/dist/agent-src/templates/persona.md +26 -0
  26. package/dist/agent-src/templates/roadmaps.md +27 -7
  27. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
  28. package/dist/agent-src/templates/skill.md +35 -1
  29. package/dist/cli/commands/commands.candidates.test.js +257 -0
  30. package/dist/cli/commands/commands.candidates.test.js.map +1 -0
  31. package/dist/cli/commands/commands.js +199 -0
  32. package/dist/cli/commands/commands.js.map +1 -1
  33. package/dist/cli/main.js +1 -0
  34. package/dist/cli/main.js.map +1 -1
  35. package/dist/cli/registry.js +1 -1
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/cli-delegate/{chunk-E4RUZKXB.js → chunk-FV34EGEX.js} +1 -1
  38. package/dist/cli-delegate/chunk-RLN73PR5.js +157 -0
  39. package/dist/cli-delegate/{chunk-I5EAFRII.js → chunk-ZPSXJTLI.js} +2 -154
  40. package/dist/cli-delegate/cmd_conformance.js +672 -214
  41. package/dist/cli-delegate/cmd_doctor.js +1 -1
  42. package/dist/cli-delegate/cmd_handoff.js +8 -6
  43. package/dist/cli-delegate/cmd_session_recycle.js +5 -3
  44. package/dist/discovery/deprecation-report.md +1 -1
  45. package/dist/discovery/discovery-manifest.json +127 -22
  46. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  47. package/dist/discovery/discovery-manifest.summary.md +3 -3
  48. package/dist/discovery/dormancy-report.md +1 -1
  49. package/dist/discovery/orphan-report.md +1 -1
  50. package/dist/discovery/packs.json +9 -5
  51. package/dist/discovery/trust-report.md +2 -2
  52. package/dist/discovery/workspaces.json +8 -4
  53. package/dist/hooks/dispatch.js +1056 -411
  54. package/dist/mcp/registry-manifest.json +3 -3
  55. package/dist/mcp/server.json +2 -2
  56. package/dist/mcp/server.mjs +1103 -593
  57. package/dist/router.json +1 -1
  58. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +54 -0
  59. package/docs/guidelines/design-antipatterns.md +79 -10
  60. package/docs/guidelines/design-fidelity-mechanics.md +120 -2
  61. package/package.json +1 -1
  62. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
  63. package/src/config/gate-coverage.yml +65 -10
  64. package/src/config/gate-violation-baselines.json +6 -0
  65. package/src/config/review-prompt-binding-baseline.json +19 -0
  66. package/src/config/rule-enforcement-baseline.json +0 -1
  67. package/src/scripts/_dispatch.bash +1 -0
  68. package/src/scripts/_lib/design_system_import.ts +987 -0
  69. package/src/scripts/ai_council/clients.ts +15 -1
  70. package/src/scripts/check_ci_local_parity.ts +60 -6
  71. package/src/scripts/check_review_prompt_binding.ts +553 -0
  72. package/src/scripts/check_suppression_hygiene.ts +19 -3
  73. package/src/scripts/design_slop_rules.test.ts +37 -6
  74. package/src/scripts/design_slop_rules.ts +150 -22
  75. package/src/scripts/design_system_import.ts +264 -0
  76. package/src/scripts/dispatch_r2_reviewer.ts +15 -1
  77. package/src/scripts/hook_manifest.yaml +71 -2
  78. package/src/scripts/hooks/concern_registry.ts +4 -0
  79. package/src/scripts/hooks/dispatch_hook.ts +10 -0
  80. package/src/scripts/hooks/host_semantics.ts +2 -0
  81. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +212 -0
  82. package/src/scripts/hooks/subagent_ledger_hook.ts +723 -0
  83. package/src/scripts/hooks/turn_end_gate_hook.ts +39 -2
  84. package/src/scripts/hooks/ui_route_nudge_hook.ts +55 -2
  85. package/src/scripts/lint_design_antipattern_parity.ts +201 -0
  86. package/src/scripts/lint_harvest_provenance.ts +483 -0
  87. package/src/scripts/lint_hook_manifest.ts +2 -0
  88. package/src/scripts/lint_rule_skill_pack_reach.ts +194 -13
  89. package/src/scripts/lint_skill_router_head.ts +254 -0
  90. package/src/scripts/report_consultation_rate.ts +126 -1
  91. package/src/scripts/run_skill_evals.ts +10 -5
  92. package/src/scripts/schemas/persona.schema.json +8 -0
  93. package/src/scripts/schemas/provenance-harvest.schema.json +39 -0
  94. package/src/scripts/sweep_dead_scan_roots.ts +51 -0
  95. package/tests/fixtures/hooks/subagent_start.json +7 -0
  96. 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 &lt;file&gt; [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. `--verify` re-derives it from the same inputs and compares.
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