@ryuenn3123/agentic-senior-core 4.0.1 → 4.0.3

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.
@@ -20,7 +20,8 @@ This contract is a decision scaffold, not a style preset. We guide the agent; we
20
20
  5. Encode `repoEvidence.designEvidenceSummary` when onboarding or detector evidence exists.
21
21
  6. Keep both design docs synchronized after implementation.
22
22
  7. Complete the Section 3-5 gates from `research-design.md` before UI implementation: `conceptualAnchor.categoryCodes.candidateEntries`, `conceptualAnchor.morphologicalExploration` (selected and uncomfortable combinations), and `conceptualAnchor.anchorCandidates.candidates` (exactly five, each with the strengthened rename test recorded).
23
- 8. After agent and user select an anchor, set `researchDossier.metadata.researchVerifiedAt` to today's ISO date and flip `status` from any seed value to `active`. This closes the freshness window for additive UI tasks within `freshnessWindowDays`.
23
+ 8. Set `derivedTokenLogic.tokenContinuityClassification` for each of typography, palette, motion, and spacing. Use `anchor-derived` only when the token choice is causally tied to the anchor's real-world reality. Use `continuity-retained` when the token is kept from a previous design iteration without re-derivation. Use `newly-introduced` when the token is fresh but not anchor-derived. If any token category is `continuity-retained`, the typography, palette, or motion entry in `researchDossier.metadata.antiRepeatLedger` stays as historical record, and the classification declares the retention is intentional with explicit rationale recorded in the matching `derivationSource` field.
24
+ 9. After agent and user select an anchor, set `researchDossier.metadata.researchVerifiedAt` to today's ISO date and flip `status` from any seed value to `active`. This closes the freshness window for additive UI tasks within `freshnessWindowDays`.
24
25
  ## Creative Commitment Gate
25
26
  Before broad compliance review or UI implementation, record an agent-chosen visual direction in both design docs:
26
27
  - one concrete real-world anchor reference
@@ -90,6 +90,23 @@ Self-test: read each category code aloud to someone unfamiliar with the project.
90
90
 
91
91
  Output: at least three category codes per product surface in `categoryCodes`. Each entry must pass the specificity self-test, must include the one-sentence reason that pattern is the default for the category, and must include an explicit one-sentence rejection note ("I will not ship this; here is the trap it sets") so the cliche cannot quietly become the target.
92
92
 
93
+ ### Dimensional split (mandatory)
94
+
95
+ Category codes must be broken down by dimension. Do not collapse multiple dimensions into a single category-level cliche. Each cluster lists the patterns that the product category will default to without intervention.
96
+
97
+ - `typographyClusters`: font family combinations that are the category default. Be explicit about font families. Name the actual trio or pair that this product's category currently defaults to, derived from live portfolio observation for THIS task. Do not anchor on examples from other categories or other timeframes.
98
+ - `paletteClusters`: palette signatures that are the category default.
99
+ - `layoutClusters`: layout patterns that are the category default.
100
+ - `motionClusters`: motion signatures that are the category default.
101
+ - `imageryClusters`: image style or visual treatment that is the category default.
102
+
103
+ Self-check before proceeding to Section 4: do the typography choices the agent is about to commit to in `derivedTokenLogic` (or downstream token sections) overlap with any item in `typographyClusters`? If yes, the agent must either:
104
+
105
+ 1. Flag the typography as a continuity choice with an explicit rationale, set `derivedTokenLogic.tokenContinuityClassification.typography` to `continuity-retained`, and record the reason that font family swap is deferred. The previous typography ledger entry stays as historical record; the classification declares the retention is intentional. OR
106
+ 2. Revise the typography pick to escape the autopilot cluster and set `tokenContinuityClassification.typography` to `anchor-derived` only when the new choice is causally tied to the anchor's real-world reality.
107
+
108
+ This self-check applies to every dimension, not only typography. Do not let an output token match a category-code item from the agent's own list without explicit (1) or (2) treatment per dimension. Pretending continuity is derivation is the failure mode this gate exists to prevent.
109
+
93
110
  ## Section 4 — Morphological Exploration
94
111
 
95
112
  A morphological matrix forces the design space to be explored beyond the first idea.
package/README.md CHANGED
@@ -63,6 +63,8 @@ The intended behavior is agent-led, not offline-template-led:
63
63
  - Modern UI claims: research current-year libraries and patterns when relevant; 2026 work should use 2026 evidence, and future years should update automatically through agent research.
64
64
  - Anti-generic rule: avoid safe dashboard shells, admin panels, card grids, scale-only mobile layouts, and static no-motion interfaces unless the product context explicitly justifies them.
65
65
 
66
+ UI design work runs a research dossier prompt (`.agent-context/prompts/research-design.md`) before the bootstrap prompt. The dossier captures product reading, reference intake, category cliches, a morphological matrix, and five anchor candidates with a strengthened rename test. The contract carries a 90-day `researchVerifiedAt` freshness gate and an anti-repeat ledger seeded from prior anchor, palette, motion, and typography choices on existing projects, so additive UI work within the freshness window skips the research stage while redesigns and stale dossiers re-run it.
67
+
66
68
  ---
67
69
 
68
70
  ## MCP Quick Setup (VS Code)
@@ -15,6 +15,7 @@ import { runMcpServerCommand } from '../lib/cli/commands/mcp.mjs';
15
15
  import { runOptimizeCommand, parseOptimizeArguments } from '../lib/cli/commands/optimize.mjs';
16
16
  import { runInitCommand, parseInitArguments } from '../lib/cli/commands/init.mjs';
17
17
  import { runUpgradeCommand, parseUpgradeArguments } from '../lib/cli/commands/upgrade.mjs';
18
+ import { runDesignAntiRepeatAuditCommand } from '../lib/cli/commands/audit-design-anti-repeat.mjs';
18
19
 
19
20
  async function main() {
20
21
  const commandArgument = process.argv[2];
@@ -63,6 +64,11 @@ async function main() {
63
64
  return;
64
65
  }
65
66
 
67
+ if (commandArgument === 'audit:design-anti-repeat') {
68
+ const auditExitCode = await runDesignAntiRepeatAuditCommand(commandArguments);
69
+ exit(auditExitCode);
70
+ }
71
+
66
72
  console.error(`Unknown command: ${commandArgument}`);
67
73
  printUsage();
68
74
  exit(1);
@@ -0,0 +1,156 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Color parsing and distance utilities for the typography/palette
5
+ * anti-repeat audit. OKLCH math is contained here so the main audit module
6
+ * stays focused on file scanning and ledger cross-check.
7
+ */
8
+
9
+ export const HEX_COLOR_PATTERN = /#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
10
+ export const OKLCH_PATTERN = /oklch\(\s*([^)]+)\s*\)/gi;
11
+
12
+ export function expandShortHexColor(rawHexColor) {
13
+ if (rawHexColor.length !== 4) {
14
+ return rawHexColor;
15
+ }
16
+ const expandedChars = [];
17
+ for (let charIndex = 1; charIndex < rawHexColor.length; charIndex += 1) {
18
+ expandedChars.push(rawHexColor[charIndex], rawHexColor[charIndex]);
19
+ }
20
+ return `#${expandedChars.join('')}`.toLowerCase();
21
+ }
22
+
23
+ export function normalizeHexColor(rawHexColor) {
24
+ if (typeof rawHexColor !== 'string' || rawHexColor.length === 0) {
25
+ return null;
26
+ }
27
+ const trimmedHex = rawHexColor.trim().toLowerCase();
28
+ if (!trimmedHex.startsWith('#')) {
29
+ return null;
30
+ }
31
+ if (trimmedHex.length === 4) {
32
+ return expandShortHexColor(trimmedHex);
33
+ }
34
+ if (trimmedHex.length === 7 || trimmedHex.length === 9) {
35
+ return trimmedHex.slice(0, 7);
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function parseOklchNumber(rawNumber, percentScale) {
41
+ const numberString = String(rawNumber || '').trim();
42
+ if (numberString.endsWith('%')) {
43
+ const percentValue = parseFloat(numberString.slice(0, -1));
44
+ if (!Number.isFinite(percentValue)) {
45
+ return Number.NaN;
46
+ }
47
+ return (percentValue / 100) * percentScale;
48
+ }
49
+ return parseFloat(numberString);
50
+ }
51
+
52
+ export function parseOklchTriple(oklchExpression) {
53
+ const componentTokens = String(oklchExpression || '')
54
+ .replace(/\//g, ' ')
55
+ .split(/[\s,]+/)
56
+ .filter((componentToken) => componentToken.length > 0);
57
+ if (componentTokens.length < 3) {
58
+ return null;
59
+ }
60
+ const lightness = parseOklchNumber(componentTokens[0], 1);
61
+ const chroma = parseFloat(componentTokens[1]);
62
+ const hueDegrees = parseOklchNumber(componentTokens[2], 360);
63
+ if (!Number.isFinite(lightness) || !Number.isFinite(chroma) || !Number.isFinite(hueDegrees)) {
64
+ return null;
65
+ }
66
+ return { lightness, chroma, hueDegrees };
67
+ }
68
+
69
+ /**
70
+ * L*C*H-style perceptual distance proxy. OKLCH is perceptually uniform,
71
+ * so a Cartesian-like delta over (L, C, C * Δhue_radians) tracks visible
72
+ * difference well enough for an anti-repeat heuristic.
73
+ */
74
+ export function oklchPerceptualDistance(leftColor, rightColor) {
75
+ const lightnessDelta = leftColor.lightness - rightColor.lightness;
76
+ const chromaDelta = leftColor.chroma - rightColor.chroma;
77
+ const hueDelta = ((leftColor.hueDegrees - rightColor.hueDegrees + 540) % 360) - 180;
78
+ const hueArcRadians = (hueDelta * Math.PI) / 180;
79
+ const hueChromaDelta = leftColor.chroma * hueArcRadians;
80
+ return Math.sqrt(
81
+ lightnessDelta * lightnessDelta
82
+ + chromaDelta * chromaDelta
83
+ + hueChromaDelta * hueChromaDelta,
84
+ );
85
+ }
86
+
87
+ export function extractColorOccurrencesFromText(sourceText) {
88
+ /** @type {{ kind: 'hex' | 'oklch', raw: string, normalized: string | null, oklch: { lightness: number, chroma: number, hueDegrees: number } | null, index: number }[]} */
89
+ const colorOccurrences = [];
90
+
91
+ HEX_COLOR_PATTERN.lastIndex = 0;
92
+ let hexMatch;
93
+ // eslint-disable-next-line no-cond-assign
94
+ while ((hexMatch = HEX_COLOR_PATTERN.exec(sourceText)) !== null) {
95
+ colorOccurrences.push({
96
+ kind: 'hex',
97
+ raw: hexMatch[0],
98
+ normalized: normalizeHexColor(hexMatch[0]),
99
+ oklch: null,
100
+ index: hexMatch.index,
101
+ });
102
+ }
103
+
104
+ OKLCH_PATTERN.lastIndex = 0;
105
+ let oklchMatch;
106
+ // eslint-disable-next-line no-cond-assign
107
+ while ((oklchMatch = OKLCH_PATTERN.exec(sourceText)) !== null) {
108
+ colorOccurrences.push({
109
+ kind: 'oklch',
110
+ raw: oklchMatch[0],
111
+ normalized: oklchMatch[0].toLowerCase(),
112
+ oklch: parseOklchTriple(oklchMatch[1]),
113
+ index: oklchMatch.index,
114
+ });
115
+ }
116
+
117
+ return colorOccurrences;
118
+ }
119
+
120
+ export function extractLedgerColors(antiRepeatLedger) {
121
+ const previousPalettes = Array.isArray(antiRepeatLedger?.previousPalettes)
122
+ ? antiRepeatLedger.previousPalettes
123
+ : [];
124
+
125
+ /** @type {{ kind: 'hex' | 'oklch', normalized: string | null, oklch: { lightness: number, chroma: number, hueDegrees: number } | null, sourceEntry: any }[]} */
126
+ const ledgerColors = [];
127
+ for (const ledgerEntry of previousPalettes) {
128
+ const summaryString = String(ledgerEntry?.summary || '');
129
+ if (!summaryString) {
130
+ continue;
131
+ }
132
+ HEX_COLOR_PATTERN.lastIndex = 0;
133
+ let hexMatch;
134
+ // eslint-disable-next-line no-cond-assign
135
+ while ((hexMatch = HEX_COLOR_PATTERN.exec(summaryString)) !== null) {
136
+ ledgerColors.push({
137
+ kind: 'hex',
138
+ normalized: normalizeHexColor(hexMatch[0]),
139
+ oklch: null,
140
+ sourceEntry: ledgerEntry,
141
+ });
142
+ }
143
+ OKLCH_PATTERN.lastIndex = 0;
144
+ let oklchMatch;
145
+ // eslint-disable-next-line no-cond-assign
146
+ while ((oklchMatch = OKLCH_PATTERN.exec(summaryString)) !== null) {
147
+ ledgerColors.push({
148
+ kind: 'oklch',
149
+ normalized: oklchMatch[0].toLowerCase(),
150
+ oklch: parseOklchTriple(oklchMatch[1]),
151
+ sourceEntry: ledgerEntry,
152
+ });
153
+ }
154
+ }
155
+ return ledgerColors;
156
+ }
@@ -0,0 +1,103 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Filesystem scanning for the typography/palette anti-repeat audit. Walks
5
+ * the repository tree, skips noisy directories (node_modules, build outputs,
6
+ * etc.), and yields scannable CSS-like and token-config file paths.
7
+ */
8
+
9
+ import { readdirSync, statSync, existsSync } from 'node:fs';
10
+ import { extname, join, relative, sep } from 'node:path';
11
+
12
+ export const DEFAULT_CSS_FILE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
13
+ export const TOKEN_CONFIG_FILE_NAMES = new Set([
14
+ 'tailwind.config.js',
15
+ 'tailwind.config.cjs',
16
+ 'tailwind.config.mjs',
17
+ 'tailwind.config.ts',
18
+ 'theme.config.js',
19
+ 'theme.config.ts',
20
+ 'design-tokens.js',
21
+ 'design-tokens.ts',
22
+ ]);
23
+ export const SCAN_SKIP_DIRECTORY_NAMES = new Set([
24
+ 'node_modules',
25
+ '.git',
26
+ '.agentic-backup',
27
+ '.benchmarks',
28
+ 'dist',
29
+ 'build',
30
+ '.next',
31
+ 'out',
32
+ 'coverage',
33
+ '.cache',
34
+ ]);
35
+
36
+ function isInsideSkippedDirectory(absolutePath, repositoryRootPath) {
37
+ const relativePath = relative(repositoryRootPath, absolutePath);
38
+ if (!relativePath || relativePath.startsWith('..')) {
39
+ return true;
40
+ }
41
+ const pathSegments = relativePath.split(sep);
42
+ return pathSegments.some((segmentName) => SCAN_SKIP_DIRECTORY_NAMES.has(segmentName));
43
+ }
44
+
45
+ function* walkDirectoryEntries(currentDirectoryPath, repositoryRootPath) {
46
+ const directoryEntries = readdirSync(currentDirectoryPath, { withFileTypes: true });
47
+ for (const directoryEntry of directoryEntries) {
48
+ const childPath = join(currentDirectoryPath, directoryEntry.name);
49
+ if (directoryEntry.isDirectory()) {
50
+ if (SCAN_SKIP_DIRECTORY_NAMES.has(directoryEntry.name)) {
51
+ continue;
52
+ }
53
+ if (isInsideSkippedDirectory(childPath, repositoryRootPath)) {
54
+ continue;
55
+ }
56
+ yield* walkDirectoryEntries(childPath, repositoryRootPath);
57
+ continue;
58
+ }
59
+ if (directoryEntry.isFile()) {
60
+ yield childPath;
61
+ }
62
+ }
63
+ }
64
+
65
+ export function isScannableFile(absoluteFilePath) {
66
+ const fileExtension = extname(absoluteFilePath).toLowerCase();
67
+ if (DEFAULT_CSS_FILE_EXTENSIONS.has(fileExtension)) {
68
+ return true;
69
+ }
70
+ const baseName = absoluteFilePath.split(/[\\/]/).pop();
71
+ return TOKEN_CONFIG_FILE_NAMES.has(baseName);
72
+ }
73
+
74
+ export function collectScannableFilePaths(repositoryRootPath, scanRoots) {
75
+ const collectedFilePaths = [];
76
+ for (const scanRoot of scanRoots) {
77
+ const absoluteScanRoot = join(repositoryRootPath, scanRoot);
78
+ if (!existsSync(absoluteScanRoot)) {
79
+ continue;
80
+ }
81
+ const scanRootStat = statSync(absoluteScanRoot);
82
+ if (!scanRootStat.isDirectory()) {
83
+ continue;
84
+ }
85
+ for (const candidateFilePath of walkDirectoryEntries(absoluteScanRoot, repositoryRootPath)) {
86
+ if (!isScannableFile(candidateFilePath)) {
87
+ continue;
88
+ }
89
+ collectedFilePaths.push(candidateFilePath);
90
+ }
91
+ }
92
+ return collectedFilePaths.sort();
93
+ }
94
+
95
+ export function lineNumberFromIndex(sourceText, charIndex) {
96
+ let lineNumber = 1;
97
+ for (let scanIndex = 0; scanIndex < charIndex && scanIndex < sourceText.length; scanIndex += 1) {
98
+ if (sourceText[scanIndex] === '\n') {
99
+ lineNumber += 1;
100
+ }
101
+ }
102
+ return lineNumber;
103
+ }
@@ -0,0 +1,70 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Font-family parsing for the typography/palette anti-repeat audit. CSS
5
+ * `font-family:` declarations and `@font-face` blocks are the inputs;
6
+ * normalized lowercase family tokens are the outputs.
7
+ */
8
+
9
+ export const FONT_FAMILY_DECLARATION_PATTERN = /font-family\s*:\s*([^;}]+)[;}]/gi;
10
+ export const FONT_FACE_FAMILY_PATTERN = /@font-face\s*\{[^}]*font-family\s*:\s*([^;]+);/gi;
11
+
12
+ export function normalizeFontFamilyToken(rawFamilyToken) {
13
+ return String(rawFamilyToken || '')
14
+ .replace(/^["']|["']$/g, '')
15
+ .trim()
16
+ .toLowerCase();
17
+ }
18
+
19
+ export function splitFontFamilyDeclaration(declarationValue) {
20
+ return String(declarationValue || '')
21
+ .split(',')
22
+ .map((familyEntry) => normalizeFontFamilyToken(familyEntry))
23
+ .filter((familyEntry) => familyEntry.length > 0 && !familyEntry.startsWith('var('));
24
+ }
25
+
26
+ export function extractFontFamiliesFromText(sourceText) {
27
+ /** @type {{ family: string, index: number }[]} */
28
+ const familyOccurrences = [];
29
+
30
+ for (const matchPattern of [FONT_FAMILY_DECLARATION_PATTERN, FONT_FACE_FAMILY_PATTERN]) {
31
+ matchPattern.lastIndex = 0;
32
+ let regexMatch;
33
+ // eslint-disable-next-line no-cond-assign
34
+ while ((regexMatch = matchPattern.exec(sourceText)) !== null) {
35
+ const declarationValue = regexMatch[1];
36
+ const declarationStartIndex = regexMatch.index;
37
+ for (const familyEntry of splitFontFamilyDeclaration(declarationValue)) {
38
+ familyOccurrences.push({ family: familyEntry, index: declarationStartIndex });
39
+ }
40
+ }
41
+ }
42
+
43
+ return familyOccurrences;
44
+ }
45
+
46
+ export function extractLedgerTypographyFamilies(antiRepeatLedger) {
47
+ const previousTypographyChoices = Array.isArray(antiRepeatLedger?.previousTypographyChoices)
48
+ ? antiRepeatLedger.previousTypographyChoices
49
+ : [];
50
+
51
+ const ledgerFamilies = new Map();
52
+ for (const ledgerEntry of previousTypographyChoices) {
53
+ const summaryString = String(ledgerEntry?.summary || '');
54
+ if (!summaryString) {
55
+ continue;
56
+ }
57
+ // Summaries are emitted as "role: value; role: value". Each value is a
58
+ // font family that the previous design shipped.
59
+ for (const summaryPart of summaryString.split(';')) {
60
+ const colonSplitIndex = summaryPart.indexOf(':');
61
+ const familyValue = colonSplitIndex >= 0 ? summaryPart.slice(colonSplitIndex + 1) : summaryPart;
62
+ const normalizedFamilyValue = normalizeFontFamilyToken(familyValue);
63
+ if (normalizedFamilyValue.length === 0) {
64
+ continue;
65
+ }
66
+ ledgerFamilies.set(normalizedFamilyValue, ledgerEntry);
67
+ }
68
+ }
69
+ return ledgerFamilies;
70
+ }
@@ -0,0 +1,255 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Typography and palette anti-repeat audit.
5
+ *
6
+ * Scans CSS-like files for font-family declarations and color values, then
7
+ * cross-checks them against
8
+ * `docs/design-intent.json`'s
9
+ * `researchDossier.metadata.antiRepeatLedger.previousTypographyChoices` and
10
+ * `previousPalettes` so a redesign cannot leak the previous direction's
11
+ * exact font trio or perceptually-identical palette colors through CSS
12
+ * implementation files alone.
13
+ *
14
+ * Severity model:
15
+ * - Typography matches are EXACT family-name matches and are always
16
+ * blocking (kind: typography.previously-shipped-family,
17
+ * diagnosticCode: BOUNDARY_TYPOGRAPHY_LEDGER_VIOLATION).
18
+ * - Palette matches are HEURISTIC. OKLCH-to-OKLCH distance is perceptually
19
+ * uniform (deltaE < threshold => match). Hex-to-hex matches are exact
20
+ * normalized equality. Cross-type matches (hex declared in CSS vs OKLCH
21
+ * in the ledger, or vice versa) are NOT performed; reporting cross-type
22
+ * would require a color-space conversion that is its own correctness
23
+ * surface (tracked in docs/deep-analysis-and-roadmap-backlog.md).
24
+ * - Palette findings BLOCK by default with kind:
25
+ * palette.previously-shipped-color and diagnosticCode:
26
+ * BOUNDARY_PALETTE_LEDGER_VIOLATION. Pass
27
+ * `treatPaletteAsAdvisory: true` (or `--palette-advisory` on the CLI)
28
+ * to opt a project into advisory-only palette reporting; severity drops
29
+ * to "advisory" and the diagnostic code becomes
30
+ * BOUNDARY_PALETTE_LEDGER_ADVISORY. Use the advisory mode only when a
31
+ * project knowingly accepts the false-positive risk of OKLCH distance
32
+ * matching at its chosen threshold.
33
+ */
34
+
35
+ import { existsSync, readFileSync } from 'node:fs';
36
+ import { join, relative } from 'node:path';
37
+
38
+ import {
39
+ collectScannableFilePaths,
40
+ lineNumberFromIndex,
41
+ } from './typography-palette-anti-repeat/file-scanner.mjs';
42
+ import {
43
+ extractFontFamiliesFromText,
44
+ extractLedgerTypographyFamilies,
45
+ } from './typography-palette-anti-repeat/typography-utils.mjs';
46
+ import {
47
+ extractColorOccurrencesFromText,
48
+ extractLedgerColors,
49
+ oklchPerceptualDistance,
50
+ } from './typography-palette-anti-repeat/color-utils.mjs';
51
+
52
+ const DEFAULT_OKLCH_DISTANCE_THRESHOLD = 0.04;
53
+
54
+ function findTypographyViolationsForFile({ relativeFilePath, sourceText, ledgerFamilies }) {
55
+ const violations = [];
56
+ const fontFamilyOccurrences = extractFontFamiliesFromText(sourceText);
57
+ for (const familyOccurrence of fontFamilyOccurrences) {
58
+ const ledgerEntry = ledgerFamilies.get(familyOccurrence.family);
59
+ if (!ledgerEntry) {
60
+ continue;
61
+ }
62
+ violations.push({
63
+ file: relativeFilePath,
64
+ line: lineNumberFromIndex(sourceText, familyOccurrence.index),
65
+ kind: 'typography.previously-shipped-family',
66
+ severity: 'blocking',
67
+ diagnosticCode: 'BOUNDARY_TYPOGRAPHY_LEDGER_VIOLATION',
68
+ detail: `CSS declares font-family "${familyOccurrence.family}" which appears in researchDossier.metadata.antiRepeatLedger.previousTypographyChoices ("${ledgerEntry.summary}"). Either escape the previously-shipped trio, or set derivedTokenLogic.tokenContinuityClassification.typography to continuity-retained with explicit rationale.`,
69
+ });
70
+ }
71
+ return violations;
72
+ }
73
+
74
+ function buildHexAdvisory({ relativeFilePath, sourceText, colorOccurrence, ledgerColor, paletteSeverity, paletteDiagnosticCode }) {
75
+ return {
76
+ file: relativeFilePath,
77
+ line: lineNumberFromIndex(sourceText, colorOccurrence.index),
78
+ kind: 'palette.previously-shipped-color',
79
+ severity: paletteSeverity,
80
+ diagnosticCode: paletteDiagnosticCode,
81
+ detail: `CSS hex "${colorOccurrence.raw}" matches a hex color in researchDossier.metadata.antiRepeatLedger.previousPalettes ("${ledgerColor.sourceEntry.summary}"). Either escape the previously-shipped palette or set derivedTokenLogic.tokenContinuityClassification.palette to continuity-retained with explicit rationale.`,
82
+ };
83
+ }
84
+
85
+ function buildOklchAdvisory({ relativeFilePath, sourceText, colorOccurrence, ledgerColor, perceptualDistance, oklchDistanceThreshold, paletteSeverity, paletteDiagnosticCode }) {
86
+ return {
87
+ file: relativeFilePath,
88
+ line: lineNumberFromIndex(sourceText, colorOccurrence.index),
89
+ kind: 'palette.previously-shipped-color',
90
+ severity: paletteSeverity,
91
+ diagnosticCode: paletteDiagnosticCode,
92
+ detail: `CSS OKLCH "${colorOccurrence.raw}" is within ${perceptualDistance.toFixed(4)} of an OKLCH color in researchDossier.metadata.antiRepeatLedger.previousPalettes ("${ledgerColor.sourceEntry.summary}") (threshold ${oklchDistanceThreshold}). Heuristic match on perceptually-uniform OKLCH distance; either escape the previously-shipped palette, raise the threshold via --threshold, or set derivedTokenLogic.tokenContinuityClassification.palette to continuity-retained with explicit rationale.`,
93
+ };
94
+ }
95
+
96
+ function findPaletteFindingsForFile({ relativeFilePath, sourceText, ledgerColors, oklchDistanceThreshold, paletteSeverity, paletteDiagnosticCode }) {
97
+ const findings = [];
98
+ const colorOccurrences = extractColorOccurrencesFromText(sourceText);
99
+ for (const colorOccurrence of colorOccurrences) {
100
+ for (const ledgerColor of ledgerColors) {
101
+ if (colorOccurrence.kind === 'hex' && ledgerColor.kind === 'hex') {
102
+ if (
103
+ colorOccurrence.normalized
104
+ && ledgerColor.normalized
105
+ && colorOccurrence.normalized === ledgerColor.normalized
106
+ ) {
107
+ findings.push(buildHexAdvisory({
108
+ relativeFilePath, sourceText, colorOccurrence, ledgerColor,
109
+ paletteSeverity, paletteDiagnosticCode,
110
+ }));
111
+ }
112
+ continue;
113
+ }
114
+ if (colorOccurrence.kind === 'oklch' && ledgerColor.kind === 'oklch' && colorOccurrence.oklch && ledgerColor.oklch) {
115
+ const perceptualDistance = oklchPerceptualDistance(colorOccurrence.oklch, ledgerColor.oklch);
116
+ if (perceptualDistance <= oklchDistanceThreshold) {
117
+ findings.push(buildOklchAdvisory({
118
+ relativeFilePath, sourceText, colorOccurrence, ledgerColor, perceptualDistance, oklchDistanceThreshold,
119
+ paletteSeverity, paletteDiagnosticCode,
120
+ }));
121
+ }
122
+ }
123
+ // Cross-type (hex declared vs OKLCH ledger or vice versa) is intentionally
124
+ // skipped here; reporting cross-type would require a color-space conversion
125
+ // pass that is its own correctness surface. Tracked in
126
+ // docs/deep-analysis-and-roadmap-backlog.md.
127
+ }
128
+ }
129
+ return findings;
130
+ }
131
+
132
+ function loadDesignIntentContract(repositoryRootPath, designIntentRelativePath) {
133
+ const designIntentAbsolutePath = join(repositoryRootPath, designIntentRelativePath);
134
+ if (!existsSync(designIntentAbsolutePath)) {
135
+ return { contract: null, reason: 'design-intent-file-absent' };
136
+ }
137
+ let contract;
138
+ try {
139
+ contract = JSON.parse(readFileSync(designIntentAbsolutePath, 'utf8'));
140
+ } catch (parseError) {
141
+ const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
142
+ return { contract: null, reason: `design-intent-file-not-valid-json: ${errorMessage}` };
143
+ }
144
+ return { contract, reason: 'ok' };
145
+ }
146
+
147
+ function buildSkippedReport(reportShell, reason) {
148
+ return {
149
+ ...reportShell,
150
+ passed: true,
151
+ skipped: true,
152
+ reason,
153
+ filesScanned: 0,
154
+ typographyViolationCount: 0,
155
+ paletteFindingCount: 0,
156
+ typographyViolations: [],
157
+ paletteFindings: [],
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Runs the typography and palette anti-repeat audit.
163
+ *
164
+ * @param {object} [auditOptions]
165
+ * @param {string} [auditOptions.repositoryRootPath]
166
+ * @param {string[]} [auditOptions.scanRoots]
167
+ * @param {string} [auditOptions.designIntentRelativePath]
168
+ * @param {number} [auditOptions.oklchDistanceThreshold]
169
+ * @param {boolean} [auditOptions.treatPaletteAsAdvisory]
170
+ */
171
+ export function runTypographyPaletteAntiRepeatAudit(auditOptions = {}) {
172
+ const repositoryRootPath = auditOptions.repositoryRootPath || process.cwd();
173
+ const scanRoots = Array.isArray(auditOptions.scanRoots) && auditOptions.scanRoots.length > 0
174
+ ? auditOptions.scanRoots
175
+ : ['.'];
176
+ const designIntentRelativePath = auditOptions.designIntentRelativePath || 'docs/design-intent.json';
177
+ const oklchDistanceThreshold = typeof auditOptions.oklchDistanceThreshold === 'number'
178
+ ? auditOptions.oklchDistanceThreshold
179
+ : DEFAULT_OKLCH_DISTANCE_THRESHOLD;
180
+ const treatPaletteAsAdvisory = auditOptions.treatPaletteAsAdvisory === true;
181
+ const paletteSeverity = treatPaletteAsAdvisory ? 'advisory' : 'blocking';
182
+ const paletteDiagnosticCode = treatPaletteAsAdvisory
183
+ ? 'BOUNDARY_PALETTE_LEDGER_ADVISORY'
184
+ : 'BOUNDARY_PALETTE_LEDGER_VIOLATION';
185
+
186
+ const reportShell = {
187
+ auditName: 'audit-typography-palette-anti-repeat',
188
+ reportVersion: '2.0.0',
189
+ generatedAt: new Date().toISOString(),
190
+ designIntentRelativePath,
191
+ paletteSeverity,
192
+ paletteDiagnosticCode,
193
+ oklchDistanceThreshold,
194
+ scope: {
195
+ typographyMatching: 'exact-family-name-after-normalization',
196
+ paletteHexMatching: 'exact-hex-after-7-digit-normalization',
197
+ paletteOklchMatching: 'l*c*h-distance-under-threshold',
198
+ paletteCrossTypeMatching: 'not-supported-color-conversion-out-of-scope',
199
+ paletteCrossTypeMatchingTrackedIn: 'docs/deep-analysis-and-roadmap-backlog.md',
200
+ },
201
+ };
202
+
203
+ const designIntentLoad = loadDesignIntentContract(repositoryRootPath, designIntentRelativePath);
204
+ if (!designIntentLoad.contract) {
205
+ return buildSkippedReport(reportShell, designIntentLoad.reason);
206
+ }
207
+
208
+ const antiRepeatLedger = designIntentLoad.contract?.researchDossier?.metadata?.antiRepeatLedger;
209
+ if (!antiRepeatLedger || typeof antiRepeatLedger !== 'object') {
210
+ return buildSkippedReport(reportShell, 'anti-repeat-ledger-absent');
211
+ }
212
+
213
+ const ledgerFamilies = extractLedgerTypographyFamilies(antiRepeatLedger);
214
+ const ledgerColors = extractLedgerColors(antiRepeatLedger);
215
+ const scannableFilePaths = collectScannableFilePaths(repositoryRootPath, scanRoots);
216
+
217
+ /** @type {any[]} */
218
+ const typographyViolations = [];
219
+ /** @type {any[]} */
220
+ const paletteFindings = [];
221
+
222
+ for (const absoluteFilePath of scannableFilePaths) {
223
+ const relativeFilePath = relative(repositoryRootPath, absoluteFilePath).replace(/\\/g, '/');
224
+ let sourceText;
225
+ try {
226
+ sourceText = readFileSync(absoluteFilePath, 'utf8');
227
+ } catch {
228
+ continue;
229
+ }
230
+ if (ledgerFamilies.size > 0) {
231
+ typographyViolations.push(...findTypographyViolationsForFile({
232
+ relativeFilePath, sourceText, ledgerFamilies,
233
+ }));
234
+ }
235
+ if (ledgerColors.length > 0) {
236
+ paletteFindings.push(...findPaletteFindingsForFile({
237
+ relativeFilePath, sourceText, ledgerColors, oklchDistanceThreshold,
238
+ paletteSeverity, paletteDiagnosticCode,
239
+ }));
240
+ }
241
+ }
242
+
243
+ const blockingViolationCount = typographyViolations.length + (treatPaletteAsAdvisory ? 0 : paletteFindings.length);
244
+
245
+ return {
246
+ ...reportShell,
247
+ passed: blockingViolationCount === 0,
248
+ skipped: false,
249
+ filesScanned: scannableFilePaths.length,
250
+ typographyViolationCount: typographyViolations.length,
251
+ paletteFindingCount: paletteFindings.length,
252
+ typographyViolations,
253
+ paletteFindings,
254
+ };
255
+ }
@@ -0,0 +1,198 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * `agentic-senior-core audit:design-anti-repeat`
5
+ *
6
+ * User-facing CLI subcommand that runs the typography/palette anti-repeat
7
+ * audit against a user project's working directory. The default scan root is
8
+ * `process.cwd()` so `npx @ryuenn3123/agentic-senior-core audit:design-anti-repeat`
9
+ * inside a user project scans that project (not the package install).
10
+ *
11
+ * Skip behavior is friendly: missing design-intent.json or missing ledger
12
+ * print actionable next steps and exit 0. Typography violations and palette
13
+ * findings (when palette is blocking, which is the default) exit 1.
14
+ */
15
+
16
+ import { resolve } from 'node:path';
17
+
18
+ import { runTypographyPaletteAntiRepeatAudit } from '../audits/typography-palette-anti-repeat-audit.mjs';
19
+
20
+ const HELP_TEXT_LINES = [
21
+ 'agentic-senior-core audit:design-anti-repeat',
22
+ '',
23
+ 'Scan CSS, SCSS, SASS, LESS, and Tailwind/theme/design-token config files in',
24
+ 'the current project for typography or palette values that match the',
25
+ 'anti-repeat ledger in docs/design-intent.json. Catches redesigns that leak',
26
+ "the previous direction's font trio or palette through CSS implementation",
27
+ 'even when the JSON design contract claims a fresh anchor.',
28
+ '',
29
+ 'Usage:',
30
+ ' agentic-senior-core audit:design-anti-repeat',
31
+ ' agentic-senior-core audit:design-anti-repeat [target-directory]',
32
+ ' agentic-senior-core audit:design-anti-repeat --json',
33
+ ' agentic-senior-core audit:design-anti-repeat --palette-advisory',
34
+ ' agentic-senior-core audit:design-anti-repeat --threshold 0.05',
35
+ '',
36
+ 'Defaults:',
37
+ ' Target directory: current working directory',
38
+ ' Typography matches: blocking (BOUNDARY_TYPOGRAPHY_LEDGER_VIOLATION)',
39
+ ' Palette matches: blocking (BOUNDARY_PALETTE_LEDGER_VIOLATION)',
40
+ ' OKLCH distance: 0.04 in L*C*H space',
41
+ '',
42
+ 'Options:',
43
+ ' --json Print the full report as JSON only (no human summary).',
44
+ ' --palette-advisory Opt out of blocking palette severity. Findings still',
45
+ ' print but do not fail the run. Typography stays blocking.',
46
+ ' --threshold <num> Override the OKLCH perceptual distance threshold.',
47
+ ' Higher values are more permissive.',
48
+ ' --help Show this help.',
49
+ '',
50
+ 'Skip behavior (exit 0):',
51
+ ' - docs/design-intent.json absent. Run `npx @ryuenn3123/agentic-senior-core init`',
52
+ ' in a fresh project, or `... upgrade` in an existing project to seed the',
53
+ ' design contract.',
54
+ ' - docs/design-intent.json present but researchDossier metadata absent.',
55
+ " Run `... upgrade` to migrate the contract, then run `.agent-context/prompts/research-design.md`",
56
+ ' so the dossier and anti-repeat ledger get populated before the next UI work.',
57
+ '',
58
+ 'Exit codes:',
59
+ ' 0 = no blocking violations (or audit skipped with a friendly note).',
60
+ ' 1 = at least one BOUNDARY_TYPOGRAPHY_LEDGER_VIOLATION or, in default mode,',
61
+ ' BOUNDARY_PALETTE_LEDGER_VIOLATION.',
62
+ ];
63
+
64
+ function buildHelpText() {
65
+ return HELP_TEXT_LINES.join('\n');
66
+ }
67
+
68
+ function readNumericFlag(commandLineArgs, flagName) {
69
+ const flagIndex = commandLineArgs.indexOf(flagName);
70
+ if (flagIndex === -1) {
71
+ return null;
72
+ }
73
+ const flagValue = commandLineArgs[flagIndex + 1];
74
+ if (typeof flagValue !== 'string' || flagValue.length === 0) {
75
+ return null;
76
+ }
77
+ const numericValue = Number(flagValue);
78
+ return Number.isFinite(numericValue) ? numericValue : null;
79
+ }
80
+
81
+ const VALUE_FLAGS = new Set(['--threshold']);
82
+
83
+ function extractTargetDirectoryArgument(commandLineArgs) {
84
+ for (let argumentIndex = 0; argumentIndex < commandLineArgs.length; argumentIndex += 1) {
85
+ const candidateArgument = commandLineArgs[argumentIndex];
86
+ if (VALUE_FLAGS.has(candidateArgument)) {
87
+ argumentIndex += 1;
88
+ continue;
89
+ }
90
+ if (!candidateArgument.startsWith('--')) {
91
+ return candidateArgument;
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ function buildSkipMessage(reason) {
98
+ if (reason === 'design-intent-file-absent') {
99
+ return [
100
+ 'Audit skipped: docs/design-intent.json is missing.',
101
+ 'Next step:',
102
+ ' - Fresh project: run `npx @ryuenn3123/agentic-senior-core init` to seed the design contract.',
103
+ ' - Existing project: run `npx @ryuenn3123/agentic-senior-core upgrade` to seed it without touching app code.',
104
+ 'Then re-run this audit.',
105
+ ];
106
+ }
107
+ if (reason === 'anti-repeat-ledger-absent') {
108
+ return [
109
+ 'Audit skipped: docs/design-intent.json is present but researchDossier metadata is absent.',
110
+ 'Next step:',
111
+ ' - Run `npx @ryuenn3123/agentic-senior-core upgrade` to migrate the contract; the upgrade injects',
112
+ ' `researchDossier.metadata` and seeds the anti-repeat ledger from existing anchor, palette,',
113
+ ' motion, and typography fields without overwriting them.',
114
+ " - Then run the design-research dossier prompt at `.agent-context/prompts/research-design.md`",
115
+ ' so the ledger and `researchVerifiedAt` get populated before the next UI work.',
116
+ 'After that, re-run this audit to enforce the ledger against your CSS.',
117
+ ];
118
+ }
119
+ if (typeof reason === 'string' && reason.startsWith('design-intent-file-not-valid-json')) {
120
+ return [
121
+ 'Audit skipped: docs/design-intent.json is present but cannot be parsed as JSON.',
122
+ ` ${reason}`,
123
+ 'Next step: fix the JSON syntax, then re-run this audit.',
124
+ ];
125
+ }
126
+ return [`Audit skipped: ${reason || 'unknown reason'}`];
127
+ }
128
+
129
+ function printHumanReport(auditReport) {
130
+ console.log('===============================================');
131
+ console.log(' audit:design-anti-repeat');
132
+ console.log('===============================================');
133
+ console.log(` Target directory: ${auditReport.targetDirectoryPath}`);
134
+ console.log(` Files scanned: ${auditReport.filesScanned}`);
135
+ console.log(` Typography violations: ${auditReport.typographyViolationCount} (blocking)`);
136
+ console.log(` Palette findings: ${auditReport.paletteFindingCount} (${auditReport.paletteSeverity})`);
137
+ console.log(` OKLCH distance threshold: ${auditReport.oklchDistanceThreshold}`);
138
+ console.log('');
139
+
140
+ if (auditReport.skipped) {
141
+ for (const skipLine of buildSkipMessage(auditReport.reason)) {
142
+ console.log(` ${skipLine}`);
143
+ }
144
+ return;
145
+ }
146
+
147
+ if (auditReport.typographyViolations.length > 0) {
148
+ console.log(' Typography violations (blocking):');
149
+ for (const violation of auditReport.typographyViolations) {
150
+ console.log(` [${violation.kind}] ${violation.file}:${violation.line} ${violation.detail}`);
151
+ }
152
+ console.log('');
153
+ }
154
+
155
+ if (auditReport.paletteFindings.length > 0) {
156
+ console.log(` Palette findings (${auditReport.paletteSeverity}):`);
157
+ for (const finding of auditReport.paletteFindings) {
158
+ console.log(` [${finding.kind}] ${finding.file}:${finding.line} ${finding.detail}`);
159
+ }
160
+ console.log('');
161
+ }
162
+
163
+ if (auditReport.passed) {
164
+ console.log(' No blocking violations against the anti-repeat ledger.');
165
+ return;
166
+ }
167
+
168
+ const blockingPaletteCount = auditReport.paletteSeverity === 'blocking' ? auditReport.paletteFindingCount : 0;
169
+ console.log(` ${auditReport.typographyViolationCount} typography violation(s) and ${blockingPaletteCount} palette violation(s) found; release blocked.`);
170
+ }
171
+
172
+ export async function runDesignAntiRepeatAuditCommand(commandLineArgs = []) {
173
+ if (commandLineArgs.includes('--help') || commandLineArgs.includes('-h')) {
174
+ console.log(buildHelpText());
175
+ return 0;
176
+ }
177
+
178
+ const shouldOutputJsonOnly = commandLineArgs.includes('--json');
179
+ const shouldTreatPaletteAsAdvisory = commandLineArgs.includes('--palette-advisory');
180
+ const oklchDistanceThreshold = readNumericFlag(commandLineArgs, '--threshold');
181
+ const targetDirectoryArgument = extractTargetDirectoryArgument(commandLineArgs);
182
+ const targetDirectoryPath = resolve(targetDirectoryArgument || process.cwd());
183
+
184
+ const auditReport = runTypographyPaletteAntiRepeatAudit({
185
+ repositoryRootPath: targetDirectoryPath,
186
+ treatPaletteAsAdvisory: shouldTreatPaletteAsAdvisory,
187
+ ...(typeof oklchDistanceThreshold === 'number' ? { oklchDistanceThreshold } : {}),
188
+ });
189
+ const auditReportWithRoot = { ...auditReport, targetDirectoryPath };
190
+
191
+ if (shouldOutputJsonOnly) {
192
+ process.stdout.write(`${JSON.stringify(auditReportWithRoot, null, 2)}\n`);
193
+ return auditReportWithRoot.passed ? 0 : 1;
194
+ }
195
+
196
+ printHumanReport(auditReportWithRoot);
197
+ return auditReportWithRoot.passed ? 0 : 1;
198
+ }
@@ -464,6 +464,7 @@ export async function runUpgradeCommand(targetDirectoryArgument, upgradeOptions
464
464
  }
465
465
 
466
466
  console.log('\nRefreshed files: AGENTS.md, CLAUDE.md, GEMINI.md, .agent-context/, and .agent-context/state/onboarding-report.json');
467
+ console.log('\nNext-step suggestion (UI scope): run `npx @ryuenn3123/agentic-senior-core audit:design-anti-repeat` to scan CSS, SCSS, SASS, LESS, Tailwind config, and design-token files in this project for typography or palette values that match the anti-repeat ledger in docs/design-intent.json. Add it to your CI alongside `npm test` once the design dossier is populated.');
467
468
  } catch (error) {
468
469
  console.error('\n[FATAL] An error occurred during upgrade. Attempting automatic rollback...');
469
470
  try {
@@ -84,6 +84,28 @@ function buildPreviousMotionEntry(designIntentContract) {
84
84
  }];
85
85
  }
86
86
 
87
+ function buildPreviousTypographyEntry(designIntentContract) {
88
+ if (!designIntentContract || typeof designIntentContract !== 'object') {
89
+ return [];
90
+ }
91
+ const tokenSystem = designIntentContract?.tokenSystem;
92
+ const typographyTokens = tokenSystem && typeof tokenSystem === 'object' ? tokenSystem.typographyTokens : null;
93
+ if (!typographyTokens || typeof typographyTokens !== 'object') {
94
+ return [];
95
+ }
96
+ const tokenEntries = Object.entries(typographyTokens)
97
+ .filter(([, value]) => typeof value === 'string' && value.trim().length > 0)
98
+ .map(([role, value]) => `${role}: ${value.trim()}`);
99
+ if (tokenEntries.length === 0) {
100
+ return [];
101
+ }
102
+ return [{
103
+ summary: tokenEntries.join('; '),
104
+ source: 'migrated-from-existing-design-intent',
105
+ blockedBecause: 'previously-shipped-typography-trio',
106
+ }];
107
+ }
108
+
87
109
  export function buildResearchDossierMetadata({
88
110
  designIntentContract = null,
89
111
  populateLedgerFromExistingContract = false,
@@ -99,6 +121,7 @@ export function buildResearchDossierMetadata({
99
121
  previousAnchors: [],
100
122
  previousPalettes: [],
101
123
  previousMotionSignatures: [],
124
+ previousTypographyChoices: [],
102
125
  },
103
126
  userExplicitRedesignBypassesFreshness: true,
104
127
  statusAwareValidation: {
@@ -116,6 +139,7 @@ export function buildResearchDossierMetadata({
116
139
  metadata.antiRepeatLedger.previousAnchors = buildPreviousAnchorEntry(designIntentContract).slice(0, metadata.antiRepeatLedger.ledgerMaxEntriesPerCategory);
117
140
  metadata.antiRepeatLedger.previousPalettes = buildPreviousPaletteEntry(designIntentContract).slice(0, metadata.antiRepeatLedger.ledgerMaxEntriesPerCategory);
118
141
  metadata.antiRepeatLedger.previousMotionSignatures = buildPreviousMotionEntry(designIntentContract).slice(0, metadata.antiRepeatLedger.ledgerMaxEntriesPerCategory);
142
+ metadata.antiRepeatLedger.previousTypographyChoices = buildPreviousTypographyEntry(designIntentContract).slice(0, metadata.antiRepeatLedger.ledgerMaxEntriesPerCategory);
119
143
  }
120
144
 
121
145
  return metadata;
@@ -7,6 +7,15 @@
7
7
 
8
8
  import { hasNonEmptyString } from './helpers.mjs';
9
9
 
10
+ const SEED_STATUSES = new Set([
11
+ 'seed-needs-design-synthesis',
12
+ 'seed-generated-during-init',
13
+ 'seed-generated-during-upgrade',
14
+ ]);
15
+ const REQUIRED_TOKEN_CATEGORIES = ['typography', 'palette', 'motion', 'spacing'];
16
+ const ACTIVE_CLASSIFICATION_VALUES = new Set(['anchor-derived', 'continuity-retained', 'newly-introduced']);
17
+ const SEED_CLASSIFICATION_VALUES = new Set(['anchor-derived', 'continuity-retained', 'newly-introduced', 'pending-research']);
18
+
10
19
  export function validateDesignContractCompleteness(designIntentContract) {
11
20
  const validationIssues = [];
12
21
  const conceptualAnchor = designIntentContract?.conceptualAnchor;
@@ -47,6 +56,35 @@ export function validateDesignContractCompleteness(designIntentContract) {
47
56
  ) {
48
57
  validationIssues.push('designIntent.derivedTokenLogic.validationRule must require traceability to anchorReference.');
49
58
  }
59
+
60
+ const tokenContinuityClassification = derivedTokenLogic.tokenContinuityClassification;
61
+ const isSeedStatus = SEED_STATUSES.has(designIntentContract?.status);
62
+ const allowedClassificationValues = isSeedStatus ? SEED_CLASSIFICATION_VALUES : ACTIVE_CLASSIFICATION_VALUES;
63
+ if (!tokenContinuityClassification || typeof tokenContinuityClassification !== 'object') {
64
+ validationIssues.push('designIntent.derivedTokenLogic.tokenContinuityClassification must exist. Each token category (typography, palette, motion, spacing) must declare anchor-derived, continuity-retained, or newly-introduced (pending-research is allowed only on seed contracts).');
65
+ } else {
66
+ if (
67
+ !Array.isArray(tokenContinuityClassification.validValues)
68
+ || !tokenContinuityClassification.validValues.includes('anchor-derived')
69
+ || !tokenContinuityClassification.validValues.includes('continuity-retained')
70
+ || !tokenContinuityClassification.validValues.includes('newly-introduced')
71
+ || !tokenContinuityClassification.validValues.includes('pending-research')
72
+ ) {
73
+ validationIssues.push('designIntent.derivedTokenLogic.tokenContinuityClassification.validValues must include anchor-derived, continuity-retained, newly-introduced, and pending-research.');
74
+ }
75
+ if (!hasNonEmptyString(tokenContinuityClassification.rule)) {
76
+ validationIssues.push('designIntent.derivedTokenLogic.tokenContinuityClassification.rule must explain when each value applies.');
77
+ }
78
+ for (const tokenCategoryName of REQUIRED_TOKEN_CATEGORIES) {
79
+ const classifiedValue = tokenContinuityClassification[tokenCategoryName];
80
+ if (!allowedClassificationValues.has(classifiedValue)) {
81
+ const allowedLabel = isSeedStatus
82
+ ? 'anchor-derived, continuity-retained, newly-introduced, or pending-research'
83
+ : 'anchor-derived, continuity-retained, or newly-introduced';
84
+ validationIssues.push(`designIntent.derivedTokenLogic.tokenContinuityClassification.${tokenCategoryName} must be one of: ${allowedLabel}. Got: "${classifiedValue}".`);
85
+ }
86
+ }
87
+ }
50
88
  }
51
89
 
52
90
  if (!['verified', 'pending-verification', 'no-external-library-needed'].includes(designIntentContract?.libraryResearchStatus)) {
@@ -12,7 +12,7 @@ const REQUIRED_SEED_STATUSES = [
12
12
  'seed-generated-during-init',
13
13
  'seed-generated-during-upgrade',
14
14
  ];
15
- const ANTI_REPEAT_LEDGER_CATEGORIES = ['previousAnchors', 'previousPalettes', 'previousMotionSignatures'];
15
+ const ANTI_REPEAT_LEDGER_CATEGORIES = ['previousAnchors', 'previousPalettes', 'previousMotionSignatures', 'previousTypographyChoices'];
16
16
 
17
17
  function validateMetadataBaseShape(metadata, validationErrors) {
18
18
  if (
@@ -139,6 +139,14 @@ function buildDesignIntentContractObject({
139
139
  typeScaleMethod: 'Prefer fluid clamp() type scales when supported; name ratio, role contrast, balance/wrap behavior, and numeric typography needs.',
140
140
  motionBudget: 'Name micro, layout, entrance, easing, stagger, and reduced-motion budgets; prefer transform/opacity for high-frequency motion.',
141
141
  validationRule: 'Every semantic token role must trace to anchorReference; keep exact primitive values flexible unless locked by repo evidence, accessibility validation, implementation constraints, or explicit user approval.',
142
+ tokenContinuityClassification: {
143
+ typography: 'pending-research',
144
+ palette: 'pending-research',
145
+ motion: 'pending-research',
146
+ spacing: 'pending-research',
147
+ validValues: ['anchor-derived', 'continuity-retained', 'newly-introduced', 'pending-research'],
148
+ rule: 'For each token category, classify whether the choice is anchor-derived (causally tied to anchorReference real-world reality), continuity-retained (kept from a previous design iteration without re-derivation), or newly-introduced (fresh choice not tied to anchor). Continuity-retained is acceptable; pretending continuity is derivation is not.',
149
+ },
142
150
  },
143
151
  motionPaletteDecision: buildMotionPaletteDecisionSection(),
144
152
  aiSafeUiAudit: buildAiSafeUiAuditSection({ projectName }),
package/lib/cli/utils.mjs CHANGED
@@ -58,6 +58,7 @@ export function printUsage() {
58
58
  console.log(' agentic-senior-core optimize [target-directory] [--agent <copilot|claude|cursor|windsurf|gemini|codex|cline>] [--enable|--disable] [--show]');
59
59
  console.log(' agentic-senior-core mcp');
60
60
  console.log(' agentic-senior-core rollback [target-directory]');
61
+ console.log(' agentic-senior-core audit:design-anti-repeat [target-directory] [--json] [--palette-advisory] [--threshold <number>]');
61
62
  console.log(' agentic-senior-core --version');
62
63
  console.log('');
63
64
  console.log('Options:');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryuenn3123/agentic-senior-core",
3
- "version": "4.0.1",
3
+ "version": "4.0.3",
4
4
  "type": "module",
5
5
  "description": "Force your AI Agent to code like a Staff Engineer, not a Junior.",
6
6
  "bin": {
@@ -59,6 +59,7 @@
59
59
  "audit:cache-layer-contract": "node ./scripts/audit-cache-layer-contract.mjs",
60
60
  "audit:reflection-citations": "node ./scripts/audit-reflection-citations.mjs",
61
61
  "audit:caching-scope-hygiene": "node ./scripts/audit-caching-scope-hygiene.mjs",
62
+ "audit:typography-palette-anti-repeat": "node ./scripts/audit-typography-palette-anti-repeat.mjs",
62
63
  "audit:release-bundle": "node ./scripts/audit-release-bundle.mjs",
63
64
  "audit:file-size": "node ./scripts/audit-file-size.mjs",
64
65
  "audit:rule-id-uniqueness": "node ./scripts/audit-rule-id-uniqueness.mjs",
@@ -83,7 +84,7 @@
83
84
  "report:governance-weekly": "node ./scripts/governance-weekly-report.mjs",
84
85
  "clean:local": "node ./scripts/clean-local-artifacts.mjs",
85
86
  "validate": "node ./scripts/validate.mjs",
86
- "test": "node --test ./tests/cli-smoke.test.mjs ./tests/mcp-server.test.mjs ./tests/llm-judge.test.mjs ./tests/ui-rubric-calibration.test.mjs ./tests/operations.test.mjs ./tests/knowledge-injection.test.mjs ./tests/migrate-rule-format.test.mjs ./tests/audit-caching-scope-hygiene.test.mjs ./tests/research-dossier-migration.test.mjs ./benchmarks/token-usage/lib/token-counter.test.mjs ./benchmarks/token-usage/lib/provider-cache-matrix.test.mjs ./benchmarks/token-usage/lib/cache-layer-contract.test.mjs ./benchmarks/token-usage/lib/cache-economics.test.mjs"
87
+ "test": "node --test ./tests/cli-smoke.test.mjs ./tests/mcp-server.test.mjs ./tests/llm-judge.test.mjs ./tests/ui-rubric-calibration.test.mjs ./tests/operations.test.mjs ./tests/knowledge-injection.test.mjs ./tests/migrate-rule-format.test.mjs ./tests/audit-caching-scope-hygiene.test.mjs ./tests/research-dossier-migration.test.mjs ./tests/typography-palette-anti-repeat-audit.test.mjs ./tests/audit-design-anti-repeat-command.test.mjs ./benchmarks/token-usage/lib/token-counter.test.mjs ./benchmarks/token-usage/lib/provider-cache-matrix.test.mjs ./benchmarks/token-usage/lib/cache-layer-contract.test.mjs ./benchmarks/token-usage/lib/cache-economics.test.mjs"
87
88
  },
88
89
  "devDependencies": {
89
90
  "@anthropic-ai/sdk": "^0.96.0",
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+
4
+ /**
5
+ * audit-typography-palette-anti-repeat.mjs
6
+ *
7
+ * Thin CLI wrapper around lib/cli/audits/typography-palette-anti-repeat-audit.mjs.
8
+ * The wrapper defaults to scanning the current working directory so it works
9
+ * for both repo-internal `npm run audit:typography-palette-anti-repeat` (npm
10
+ * sets cwd to the repo root) and direct invocation from a user project. The
11
+ * user-facing entry point is the `audit:design-anti-repeat` subcommand on
12
+ * the published bin (see lib/cli/commands/audit-design-anti-repeat.mjs).
13
+ *
14
+ * Usage:
15
+ * node scripts/audit-typography-palette-anti-repeat.mjs
16
+ * node scripts/audit-typography-palette-anti-repeat.mjs --json
17
+ * node scripts/audit-typography-palette-anti-repeat.mjs --palette-advisory
18
+ * node scripts/audit-typography-palette-anti-repeat.mjs --threshold 0.05
19
+ * node scripts/audit-typography-palette-anti-repeat.mjs --root /path/to/project
20
+ *
21
+ * Default severity:
22
+ * - Typography matches always block.
23
+ * - Palette matches block by default. Pass --palette-advisory to opt a
24
+ * project into advisory-only palette reporting (release stays unblocked
25
+ * by palette findings; typography still blocks).
26
+ *
27
+ * Exit codes:
28
+ * 0 = no blocking violations
29
+ * 1 = at least one BOUNDARY_TYPOGRAPHY_LEDGER_VIOLATION or, in default
30
+ * mode, BOUNDARY_PALETTE_LEDGER_VIOLATION
31
+ */
32
+
33
+ import { resolve } from 'node:path';
34
+
35
+ import { runTypographyPaletteAntiRepeatAudit } from '../lib/cli/audits/typography-palette-anti-repeat-audit.mjs';
36
+
37
+ const COMMAND_LINE_ARGS = process.argv.slice(2);
38
+ const SHOULD_OUTPUT_JSON_ONLY = COMMAND_LINE_ARGS.includes('--json');
39
+ const SHOULD_TREAT_PALETTE_AS_ADVISORY = COMMAND_LINE_ARGS.includes('--palette-advisory');
40
+
41
+ function readStringFlag(flagName) {
42
+ const flagIndex = COMMAND_LINE_ARGS.indexOf(flagName);
43
+ if (flagIndex === -1) {
44
+ return null;
45
+ }
46
+ const flagValue = COMMAND_LINE_ARGS[flagIndex + 1];
47
+ if (typeof flagValue !== 'string' || flagValue.length === 0 || flagValue.startsWith('--')) {
48
+ return null;
49
+ }
50
+ return flagValue;
51
+ }
52
+
53
+ function readNumericFlag(flagName) {
54
+ const stringValue = readStringFlag(flagName);
55
+ if (stringValue === null) {
56
+ return null;
57
+ }
58
+ const numericValue = Number(stringValue);
59
+ return Number.isFinite(numericValue) ? numericValue : null;
60
+ }
61
+
62
+ function main() {
63
+ const oklchDistanceThreshold = readNumericFlag('--threshold');
64
+ const explicitRootPath = readStringFlag('--root');
65
+ const repositoryRootPath = resolve(explicitRootPath || process.cwd());
66
+
67
+ const auditReport = runTypographyPaletteAntiRepeatAudit({
68
+ repositoryRootPath,
69
+ treatPaletteAsAdvisory: SHOULD_TREAT_PALETTE_AS_ADVISORY,
70
+ ...(typeof oklchDistanceThreshold === 'number' ? { oklchDistanceThreshold } : {}),
71
+ });
72
+
73
+ if (SHOULD_OUTPUT_JSON_ONLY) {
74
+ process.stdout.write(`${JSON.stringify({ ...auditReport, repositoryRootPath }, null, 2)}\n`);
75
+ process.exit(auditReport.passed ? 0 : 1);
76
+ }
77
+
78
+ console.log('===============================================');
79
+ console.log(' audit:typography-palette-anti-repeat');
80
+ console.log('===============================================');
81
+ console.log(` Target directory: ${repositoryRootPath}`);
82
+ console.log(` Files scanned: ${auditReport.filesScanned}`);
83
+ console.log(` Typography violations: ${auditReport.typographyViolationCount} (blocking)`);
84
+ console.log(` Palette findings: ${auditReport.paletteFindingCount} (${auditReport.paletteSeverity})`);
85
+ console.log(` OKLCH distance threshold: ${auditReport.oklchDistanceThreshold}`);
86
+ console.log('');
87
+
88
+ if (auditReport.skipped) {
89
+ console.log(` Audit skipped: ${auditReport.reason}`);
90
+ process.exit(0);
91
+ }
92
+
93
+ if (auditReport.typographyViolations.length > 0) {
94
+ console.log(' Typography violations (blocking):');
95
+ for (const violation of auditReport.typographyViolations) {
96
+ console.log(` [${violation.kind}] ${violation.file}:${violation.line} ${violation.detail}`);
97
+ }
98
+ console.log('');
99
+ }
100
+
101
+ if (auditReport.paletteFindings.length > 0) {
102
+ console.log(` Palette findings (${auditReport.paletteSeverity}):`);
103
+ for (const finding of auditReport.paletteFindings) {
104
+ console.log(` [${finding.kind}] ${finding.file}:${finding.line} ${finding.detail}`);
105
+ }
106
+ console.log('');
107
+ }
108
+
109
+ if (auditReport.passed) {
110
+ console.log(' No blocking violations against the anti-repeat ledger.');
111
+ process.exit(0);
112
+ }
113
+ const blockingPaletteCount = auditReport.paletteSeverity === 'blocking' ? auditReport.paletteFindingCount : 0;
114
+ console.log(` ${auditReport.typographyViolationCount} typography violation(s) and ${blockingPaletteCount} palette violation(s) found; release blocked.`);
115
+ process.exit(1);
116
+ }
117
+
118
+ if (process.argv[1] && (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}` || process.argv[1].endsWith('audit-typography-palette-anti-repeat.mjs'))) {
119
+ main();
120
+ }
@@ -74,6 +74,19 @@ const BOUNDARY_RULES = [
74
74
  'README.md',
75
75
  ],
76
76
  trigger(filePath) {
77
+ // Path-based exclusion: design-contract JSON shape lives under
78
+ // lib/cli/project-scaffolder/. Files like research-dossier-migration.mjs
79
+ // describe agent-prompt JSON contract migrations, not database schema
80
+ // or persistent-store migrations, so they must not trigger this boundary
81
+ // even when the filename contains the words "schema" or "migration".
82
+ // Tests for those design-contract migrations are excluded for the same
83
+ // reason.
84
+ if (filePath.startsWith('lib/cli/project-scaffolder/')) {
85
+ return false;
86
+ }
87
+ if (filePath.startsWith('tests/') && /research-dossier|design-contract/i.test(filePath)) {
88
+ return false;
89
+ }
77
90
  return !isDocumentationFilePath(filePath)
78
91
  && /(database|schema|migration|repository|sql|prisma|typeorm|knex)/i.test(filePath);
79
92
  },
@@ -79,7 +79,12 @@ export function runStaticReleaseChecks(results, diagnostics) {
79
79
  stackFileName: 'agent-decision-runtime.md',
80
80
  blueprintFileName: 'agent-decision-architecture.md',
81
81
  },
82
- status: 'release-gate-seed-validation',
82
+ // Seed status so `tokenContinuityClassification: pending-research`
83
+ // is a valid placeholder. This probe asserts seed-shape completeness,
84
+ // not active-contract behavior; an agent or user fills the contract
85
+ // with anchor-derived / continuity-retained / newly-introduced once
86
+ // the dossier is filled.
87
+ status: 'seed-needs-design-synthesis',
83
88
  }));
84
89
  const designContractIssues = validateDesignContractCompleteness(designIntentSeed);
85
90
 
@@ -376,6 +376,13 @@ export const REQUIRED_UI_DESIGN_AUTOMATION_SNIPPETS = [
376
376
  'Anti-Repeat Ledger Gate',
377
377
  'previousAnchors',
378
378
  'previousMotionSignatures',
379
+ 'Dimensional split',
380
+ 'typographyClusters',
381
+ 'paletteClusters',
382
+ 'layoutClusters',
383
+ 'motionClusters',
384
+ 'imageryClusters',
385
+ 'tokenContinuityClassification',
379
386
  ],
380
387
  },
381
388
  {
@@ -20,6 +20,7 @@ import { fileURLToPath } from 'node:url';
20
20
  import { ALLOWED_SEVERITIES } from './validate/config.mjs';
21
21
  import { runCacheLayerContractAudit } from './audit-cache-layer-contract.mjs';
22
22
  import { runCachingScopeHygieneAudit } from './audit-caching-scope-hygiene.mjs';
23
+ import { runTypographyPaletteAntiRepeatAudit } from '../lib/cli/audits/typography-palette-anti-repeat-audit.mjs';
23
24
  import { runAuditFileSize } from './audit-file-size.mjs';
24
25
  import { runReflectionCitationAudit } from './audit-reflection-citations.mjs';
25
26
  import { runReleaseBundleAudit } from './audit-release-bundle.mjs';
@@ -153,6 +154,9 @@ async function validateRequiredFiles() {
153
154
  'scripts/explain-on-demand-audit.mjs',
154
155
  'scripts/single-source-lazy-loading-audit.mjs',
155
156
  'scripts/audit-cache-layer-contract.mjs',
157
+ 'scripts/audit-typography-palette-anti-repeat.mjs',
158
+ 'lib/cli/audits/typography-palette-anti-repeat-audit.mjs',
159
+ 'lib/cli/commands/audit-design-anti-repeat.mjs',
156
160
  'scripts/sync-thin-adapters.mjs',
157
161
  'scripts/v3-purge-audit.mjs',
158
162
  'scripts/release-gate.mjs',
@@ -569,6 +573,34 @@ async function validateCachingScopeHygieneAudit() {
569
573
  }
570
574
  }
571
575
 
576
+ async function validateTypographyPaletteAntiRepeatAudit() {
577
+ console.log('\nChecking typography and palette anti-repeat ledger (audit:typography-palette-anti-repeat)...');
578
+ const report = runTypographyPaletteAntiRepeatAudit({ repositoryRootPath: ROOT_DIR });
579
+
580
+ if (report.skipped) {
581
+ pass(`Typography/palette anti-repeat audit skipped: ${report.reason}`);
582
+ return;
583
+ }
584
+
585
+ if (report.passed) {
586
+ pass(`Typography/palette anti-repeat audit clean: ${report.filesScanned} CSS/token file(s) scanned, 0 blocking typography violation(s), ${report.paletteFindingCount} palette finding(s) (${report.paletteSeverity})`);
587
+ return;
588
+ }
589
+
590
+ for (const violation of report.typographyViolations) {
591
+ fail(`Typography ledger violation [${violation.kind}] in ${violation.file}:${violation.line}: ${violation.detail}`);
592
+ }
593
+ if (report.paletteSeverity === 'blocking') {
594
+ for (const finding of report.paletteFindings) {
595
+ fail(`Palette ledger violation [${finding.kind}] in ${finding.file}:${finding.line}: ${finding.detail}`);
596
+ }
597
+ } else {
598
+ for (const finding of report.paletteFindings) {
599
+ warn(`Palette ledger advisory [${finding.kind}] in ${finding.file}:${finding.line}: ${finding.detail}`);
600
+ }
601
+ }
602
+ }
603
+
572
604
  async function validateReleaseBundleAudit() {
573
605
  console.log('\nChecking release benchmark bundle (audit:release-bundle)...');
574
606
  const report = runReleaseBundleAudit();
@@ -666,6 +698,7 @@ async function main() {
666
698
  await validateCacheLayerContractAudit();
667
699
  await validateReflectionCitationAudit();
668
700
  await validateCachingScopeHygieneAudit();
701
+ await validateTypographyPaletteAntiRepeatAudit();
669
702
  await validateReleaseBundleAudit();
670
703
  await validateFileSizeAudit();
671
704
  await validateRuleIdUniquenessAudit();