@hegemonart/get-design-done 1.45.0 → 1.47.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 (35) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +97 -0
  4. package/README.md +4 -0
  5. package/SKILL.md +5 -1
  6. package/dist/claude-code/.claude/skills/figma-extract/SKILL.md +1 -1
  7. package/dist/claude-code/.claude/skills/graphify/SKILL.md +1 -1
  8. package/dist/claude-code/.claude/skills/list-pins/SKILL.md +27 -0
  9. package/dist/claude-code/.claude/skills/live/SKILL.md +98 -0
  10. package/dist/claude-code/.claude/skills/pin/SKILL.md +37 -0
  11. package/dist/claude-code/.claude/skills/unpin/SKILL.md +31 -0
  12. package/package.json +3 -1
  13. package/reference/live-mode-integration.md +80 -0
  14. package/reference/registry.json +14 -0
  15. package/reference/schemas/events.schema.json +1 -1
  16. package/reference/schemas/live-session.schema.json +64 -0
  17. package/reference/skill-metadata.md +117 -0
  18. package/scripts/lib/live/bandit-feed.cjs +64 -0
  19. package/scripts/lib/live/events.cjs +86 -0
  20. package/scripts/lib/live/harness-mode.cjs +93 -0
  21. package/scripts/lib/live/postcheck.cjs +158 -0
  22. package/scripts/lib/live/runtime.cjs +233 -0
  23. package/scripts/lib/live/scope-guard.cjs +145 -0
  24. package/scripts/lib/live/session-store.cjs +364 -0
  25. package/scripts/lib/manifest/schemas/skills.schema.json +42 -1
  26. package/scripts/lib/manifest/skills.json +415 -83
  27. package/scripts/lib/pin/cli.cjs +145 -0
  28. package/scripts/lib/pin/harness-detect.cjs +75 -0
  29. package/scripts/lib/pin/store.cjs +288 -0
  30. package/skills/figma-extract/SKILL.md +1 -1
  31. package/skills/graphify/SKILL.md +1 -1
  32. package/skills/list-pins/SKILL.md +27 -0
  33. package/skills/live/SKILL.md +98 -0
  34. package/skills/pin/SKILL.md +37 -0
  35. package/skills/unpin/SKILL.md +31 -0
@@ -0,0 +1,117 @@
1
+ ---
2
+ name: skill-metadata
3
+ type: meta-rules
4
+ version: 1.0.0
5
+ phase: 46
6
+ tags: [skill, metadata, frontmatter, single-source-of-truth, generator, description-budget]
7
+ last_updated: 2026-06-02
8
+ ---
9
+
10
+ # Skill Metadata Single Source of Truth
11
+
12
+ `scripts/lib/manifest/skills.json` is the one place skill frontmatter is authored. A
13
+ generator projects it onto every `source/skills/<id>/SKILL.md`, the build step compiles
14
+ those into the shipped trees, and CI gates keep the three copies identical. This doc
15
+ explains the file, the generator, and the description budget. For the structural rules a
16
+ SKILL.md body must follow (line cap, progressive disclosure, frontmatter required fields),
17
+ see `./skill-authoring-contract.md`.
18
+
19
+ ## What skills.json is
20
+
21
+ The file is a JSON object: a `schema_version` integer and a `skills` array. Each array
22
+ element is one skill record. Only `name` is required (it must equal the
23
+ `source/skills/<id>/` directory name); every other field is optional and omitted when it
24
+ has no value.
25
+
26
+ ```json
27
+ {
28
+ "schema_version": 1,
29
+ "skills": [
30
+ {
31
+ "name": "health",
32
+ "description": "Reports .design/ artifact health - staleness, missing files, token drift, broken state transitions.",
33
+ "tools": "Read, Bash, Glob, Grep, mcp__gdd_state__get",
34
+ "disable_model_invocation": true
35
+ }
36
+ ]
37
+ }
38
+ ```
39
+
40
+ Recognized record fields: `name`, `description`, `argument_hint`, `tools`,
41
+ `user_invocable`, `disable_model_invocation`, `frontmatter_name` (the `name:` value when it
42
+ is not the default `gdd-<id>`), `extra_frontmatter` (see below), plus `registered_in_phase`
43
+ and `aliases`, which live only in the manifest and never round-trip to frontmatter. The
44
+ schema at `scripts/lib/manifest/schemas/skills.schema.json` validates the shape.
45
+
46
+ ## The build chain
47
+
48
+ Metadata flows in one direction, and a `*:check` gate guards each hop:
49
+
50
+ ```text
51
+ skills.json
52
+ -> generate-skill-frontmatter (npm run generate:skill-frontmatter)
53
+ -> source/skills/<id>/SKILL.md
54
+ -> build:skills (npm run build:skills)
55
+ -> skills/ + dist/claude-code/
56
+ ```
57
+
58
+ `scripts/generate-skill-frontmatter.cjs` rewrites only the frontmatter block of each
59
+ `SKILL.md`, never the markdown body. It has three modes:
60
+
61
+ - forward (no flag): manifest to frontmatter. The default.
62
+ - `--extract`: the reverse direction, reading current frontmatter back into `skills.json`
63
+ to seed or refresh the source of truth. Idempotent with forward.
64
+ - `--check`: the CI drift gate. It writes nothing and exits non-zero when any committed
65
+ frontmatter differs from what the manifest would generate.
66
+
67
+ `scripts/build-skills.cjs` then propagates `source/skills/` into the committed `skills/`
68
+ tree and `dist/claude-code/`; its own check mode asserts that the built output equals the
69
+ committed output. So the contract is: edit `skills.json`, regenerate, build. Never
70
+ hand-edit a managed frontmatter line in `SKILL.md`, because the drift gate will fail.
71
+
72
+ ## Managed vs preserved fields
73
+
74
+ The generator owns six frontmatter keys and emits them in this canonical order whenever the
75
+ record carries a value:
76
+
77
+ 1. `name`
78
+ 2. `description`
79
+ 3. `argument-hint`
80
+ 4. `tools`
81
+ 5. `user-invocable`
82
+ 6. `disable-model-invocation`
83
+
84
+ String values for `description` and `argument-hint` are always double-quoted on emit, so
85
+ quoting stays uniform across every skill. Anything outside these six keys is not managed.
86
+ The generator carries it verbatim in the record's `extra_frontmatter` array and re-emits it
87
+ after the managed block. The `quality-gate` skill is the working example: its `color`,
88
+ `model`, `default-tier`, `size_budget`, `parallel-safe`, `typical-duration-seconds`,
89
+ `reads-only`, and multi-line `writes:` block all live in `extra_frontmatter` and survive
90
+ every regeneration untouched.
91
+
92
+ ## Description budget
93
+
94
+ Every `description` must be at least 20 and at most 1024 characters. Two validators enforce
95
+ the ceiling in CI:
96
+
97
+ - `scripts/validate-skill-length.cjs` treats `DESC_MAX = 1024` as a blocker (it also blocks
98
+ under 20 characters as under-specification) and is the Phase 28.5 authoring-contract gate.
99
+ - `scripts/lint-agentskills-spec.cjs` rule R4 applies the same 1024-character hard cap from
100
+ the agentskills.io spec angle, and warns past 200 characters.
101
+
102
+ Phase 46 keeps that contract intact and wires `npm run lint:agentskills` in as its own
103
+ explicit CI gate, so the spec lint runs on every change rather than only in-process via the
104
+ doctor aggregator.
105
+
106
+ ## How pin consumes it
107
+
108
+ The `pin`, `unpin`, and `list-pins` skills shipping in this phase read `description`,
109
+ `argument_hint`, and `tools` straight from `skills.json`. That manifest is the pin metadata
110
+ catalogue: the pin surface never live-scrapes a `SKILL.md` or re-derives a description at
111
+ runtime. Reading one structured file keeps pin output consistent with what the generator
112
+ emits, and the `aliases` array is reserved for the pin shortcuts those skills honor.
113
+
114
+ ## See also
115
+
116
+ - `./skill-authoring-contract.md` for the SKILL.md line cap, description-format guidance,
117
+ required frontmatter fields, and progressive-disclosure rule.
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/live/bandit-feed.cjs — Phase 47 (Live Mode) → Phase 38 design-arms bridge.
4
+ *
5
+ * When a user ACCEPTS a generated variant in /gdd:live, that acceptance is a weak,
6
+ * dev-time positive signal for the variant's design pattern. We fold it into the Phase 38
7
+ * `design_arms` posterior store as a WON observation — but at a discounted weight
8
+ * (DEV_TIME_WEIGHT = 0.5) because a developer's pick during authoring is advisory until
9
+ * real production A/B + user-research data arrives (Phase 38 D-03: advisory, never
10
+ * directive). The full posterior math + atomic persistence lives in the design-arms
11
+ * store; we only reuse it (variantKey + observe) and never duplicate the Beta math.
12
+ *
13
+ * Pure, dependency-free CommonJS. Persistence + `baseDir`/`armsPath` test-injection are
14
+ * inherited from the store. Ships in the npm package; requires only the in-repo store.
15
+ */
16
+
17
+ const { variantKey, observe } = require('../ds-arms/design-arms-store.cjs');
18
+
19
+ /**
20
+ * The dev-time acceptance weight. Half a win — a developer's accept is a discounted,
21
+ * advisory signal (Phase 38 D-03) until production user-outcome data shifts the arm.
22
+ */
23
+ const DEV_TIME_WEIGHT = 0.5;
24
+
25
+ /**
26
+ * Record an accepted Live Mode variant as a discounted WON observation on its design arm.
27
+ *
28
+ * @param {object} args
29
+ * @param {string} args.componentType The component class (e.g. 'button', 'card').
30
+ * @param {string|object} args.pattern The variant's design pattern (string or object;
31
+ * hashed to the arm key via the store's variantKey).
32
+ * @param {string} [args.label] Human-readable arm label, stored on first observe.
33
+ * @param {number} [args.weight] Override the dev-time weight (defaults to DEV_TIME_WEIGHT).
34
+ * @param {string} [args.projectRoot] Repo root — forwarded to the store as `baseDir` so the
35
+ * arms file resolves under it (testable / hermetic).
36
+ * @param {string} [args.armsPath] Explicit arms-file path override (forwarded to the store).
37
+ * @returns {object} The updated arm posterior (as returned by the store's observe()).
38
+ */
39
+ function recordAccepted(args = {}) {
40
+ const { componentType, pattern, label, projectRoot, armsPath } = args;
41
+ if (typeof componentType !== 'string' || componentType.length === 0) {
42
+ throw new TypeError('recordAccepted: componentType is required (non-empty string)');
43
+ }
44
+ if (pattern == null) {
45
+ throw new TypeError('recordAccepted: pattern is required');
46
+ }
47
+
48
+ const weight = typeof args.weight === 'number' && args.weight > 0 ? args.weight : DEV_TIME_WEIGHT;
49
+ const hash = variantKey(componentType, pattern);
50
+
51
+ // Forward store options only when provided so the store keeps its own defaults otherwise.
52
+ const opts = {};
53
+ if (projectRoot) opts.baseDir = projectRoot;
54
+ if (armsPath) opts.armsPath = armsPath;
55
+
56
+ return observe(
57
+ componentType,
58
+ hash,
59
+ { won: true, weight, source: 'dev_time', label },
60
+ opts,
61
+ );
62
+ }
63
+
64
+ module.exports = { recordAccepted, DEV_TIME_WEIGHT };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/live/events.cjs — Phase 47 (Live Mode) telemetry emitter.
4
+ *
5
+ * /gdd:live emits six event types across a session — start, pick, generate, accept,
6
+ * discard, end. This module is the single typed entry point: it validates the `type`
7
+ * against the closed LIVE_EVENT_TYPES allow-list (an unknown live type is a programmer
8
+ * error and is rejected) and appends the event via the in-repo event-chain emitter
9
+ * (scripts/lib/event-chain.cjs), the same write path router_pick uses.
10
+ *
11
+ * The emitted row carries the events.schema.json envelope fields verbatim
12
+ * (type / timestamp / sessionId / payload) so that, projected back to
13
+ * {type, timestamp, sessionId, payload}, it validates against the additive `live_*`
14
+ * seed types in reference/schemas/events.schema.json. `agent` + `outcome` are the
15
+ * chain emitter's two required fields; we set agent='live' and outcome=type.
16
+ *
17
+ * Pure, dependency-free CommonJS:
18
+ * - NO top-level clock: the timestamp comes from an injectable `now` (default
19
+ * `Date.now`), so tests are deterministic.
20
+ * - `baseDir` is injectable (the emitter resolves the chain file relative to it),
21
+ * so tests write to a temp project and never touch the repo's real stream.
22
+ *
23
+ * Ships in the npm package; requires only the in-repo event-chain emitter.
24
+ */
25
+
26
+ const { appendChainEvent } = require('../event-chain.cjs');
27
+
28
+ /** The six Live Mode event types. Closed allow-list — an unknown type is rejected. */
29
+ const LIVE_EVENT_TYPES = Object.freeze([
30
+ 'live_session_start',
31
+ 'live_pick',
32
+ 'live_generate',
33
+ 'live_accept',
34
+ 'live_discard',
35
+ 'live_session_end',
36
+ ]);
37
+
38
+ const LIVE_EVENT_TYPE_SET = new Set(LIVE_EVENT_TYPES);
39
+
40
+ /**
41
+ * Emit a Live Mode telemetry event.
42
+ *
43
+ * @param {object} args
44
+ * @param {string} args.projectRoot Repo root — injected as the emitter `baseDir` so the
45
+ * chain file resolves under it (testable / hermetic).
46
+ * @param {string} args.type One of LIVE_EVENT_TYPES. Anything else throws.
47
+ * @param {string} args.sessionId Stable per-session id (correlates the event stream).
48
+ * @param {object} [args.payload] Free-form, event-specific payload (MVP: opaque object).
49
+ * @param {(() => number)} [args.now] Injectable clock returning epoch ms (default Date.now).
50
+ * @returns {{event_id: string, type: string, timestamp: string, sessionId: string, payload: object}}
51
+ * The projected envelope (also the shape that validates against events.schema.json).
52
+ */
53
+ function emitLiveEvent(args = {}) {
54
+ const { projectRoot, type, sessionId, payload } = args;
55
+ const now = typeof args.now === 'function' ? args.now : Date.now;
56
+
57
+ if (!LIVE_EVENT_TYPE_SET.has(type)) {
58
+ throw new Error(
59
+ `emitLiveEvent: unknown live event type "${String(type)}". ` +
60
+ `Expected one of: ${LIVE_EVENT_TYPES.join(', ')}.`,
61
+ );
62
+ }
63
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
64
+ throw new TypeError('emitLiveEvent: sessionId is required (non-empty string)');
65
+ }
66
+
67
+ const timestamp = new Date(now()).toISOString();
68
+ const envelopePayload = payload && typeof payload === 'object' ? payload : {};
69
+
70
+ // Mirror the router_pick emit surface: write a chain row carrying the events-schema
71
+ // envelope fields verbatim. agent/outcome are the chain emitter's required fields.
72
+ const event_id = appendChainEvent({
73
+ baseDir: projectRoot,
74
+ agent: 'live',
75
+ outcome: type,
76
+ // Envelope fields preserved verbatim by appendChainEvent (opaque-extras pass-through):
77
+ type,
78
+ timestamp,
79
+ sessionId,
80
+ payload: envelopePayload,
81
+ });
82
+
83
+ return { event_id, type, timestamp, sessionId, payload: envelopePayload };
84
+ }
85
+
86
+ module.exports = { emitLiveEvent, LIVE_EVENT_TYPES };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/live/harness-mode.cjs — Phase 47 (Live Mode) capability gate.
4
+ *
5
+ * `/gdd:live` drives the Claude Preview MCP at runtime (preview_inspect /
6
+ * preview_click / preview_eval / preview_screenshot). That whole loop is only
7
+ * available on a harness whose capability matrix reports `mcp_support: true`.
8
+ * Harnesses without MCP support cannot inject the runtime or read picks back, so
9
+ * live mode degrades to a screenshot-only experience on them.
10
+ *
11
+ * This module is the single source of truth for that gate. It reads the canonical
12
+ * harness record (scripts/lib/manifest/harnesses.cjs, a typed re-export of
13
+ * harnesses.json) and projects each harness onto one of two live-mode strings:
14
+ *
15
+ * 'puppeteer' — full live mode: the harness has mcp_support, the skill probes
16
+ * Preview, injects scripts/lib/live/runtime.cjs, and hot-swaps
17
+ * variants in place. (Named for the Preview MCP's Playwright /
18
+ * puppeteer-class browser driver, not a bundled dependency: there
19
+ * is NO bundled puppeteer; the skill drives the MCP.)
20
+ * 'degraded' — screenshot-only: no mcp_support, so no live injection. The skill
21
+ * falls back to generating variants and capturing static
22
+ * screenshots, and says so up front.
23
+ *
24
+ * Design constraints (mirror the other scripts/lib/live/* modules):
25
+ * - Pure, dependency-free apart from the manifest re-export. No fs, no network,
26
+ * no Date / Math.random. Cross-platform (no path work, no OS calls).
27
+ * - The harness list is injectable so tests can pass a fixture; it defaults to
28
+ * the real manifest.
29
+ *
30
+ * Exports:
31
+ * - liveModeFor(harnessId, harnesses?) -> 'puppeteer' | 'degraded'
32
+ * - degradedHarnesses(harnesses?) -> string[] of ids in degraded mode
33
+ * - isMcpSupported(harnessId, harnesses?) -> boolean
34
+ * - MODE_FULL / MODE_DEGRADED -> the two mode string constants
35
+ */
36
+
37
+ const MODE_FULL = 'puppeteer';
38
+ const MODE_DEGRADED = 'degraded';
39
+
40
+ /** Lazily resolve the default harness list (the typed re-export of harnesses.json). */
41
+ function defaultHarnesses() {
42
+ return require('../manifest/harnesses.cjs');
43
+ }
44
+
45
+ /** Find a harness record by id in a list. Returns undefined when absent. */
46
+ function findHarness(harnessId, harnesses) {
47
+ const list = Array.isArray(harnesses) ? harnesses : defaultHarnesses();
48
+ return list.find((h) => h && h.id === harnessId);
49
+ }
50
+
51
+ /**
52
+ * True when the named harness reports `capability_matrix.mcp_support === true`.
53
+ * Unknown harness ids and harnesses missing the flag are treated as false (no
54
+ * MCP -> no live injection).
55
+ */
56
+ function isMcpSupported(harnessId, harnesses) {
57
+ const h = findHarness(harnessId, harnesses);
58
+ return Boolean(h && h.capability_matrix && h.capability_matrix.mcp_support === true);
59
+ }
60
+
61
+ /**
62
+ * Live mode for a harness: 'puppeteer' when mcp_support is true, else 'degraded'.
63
+ * An unknown harness id degrades (fail safe to screenshot-only).
64
+ *
65
+ * @param {string} harnessId
66
+ * @param {Array=} harnesses Optional injected list; defaults to the manifest.
67
+ * @returns {'puppeteer'|'degraded'}
68
+ */
69
+ function liveModeFor(harnessId, harnesses) {
70
+ return isMcpSupported(harnessId, harnesses) ? MODE_FULL : MODE_DEGRADED;
71
+ }
72
+
73
+ /**
74
+ * The ids of every harness currently in degraded (screenshot-only) live mode,
75
+ * in manifest order.
76
+ *
77
+ * @param {Array=} harnesses Optional injected list; defaults to the manifest.
78
+ * @returns {string[]}
79
+ */
80
+ function degradedHarnesses(harnesses) {
81
+ const list = Array.isArray(harnesses) ? harnesses : defaultHarnesses();
82
+ return list
83
+ .filter((h) => h && h.id && !isMcpSupported(h.id, list))
84
+ .map((h) => h.id);
85
+ }
86
+
87
+ module.exports = {
88
+ liveModeFor,
89
+ degradedHarnesses,
90
+ isMcpSupported,
91
+ MODE_FULL,
92
+ MODE_DEGRADED,
93
+ };
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/live/postcheck.cjs — Phase 47 (Live Mode) variant post-check.
4
+ *
5
+ * After /gdd:live generates a design variant, we run that variant's source through the
6
+ * in-repo gdd-detect engine (Phase 41) and surface its anti-pattern findings on the
7
+ * variant card. Per the Live Mode spec, an `error`-severity finding is FLAGGED but NOT
8
+ * auto-rejected — the human still chooses; the detector is advisory at dev-time. So
9
+ * `autoReject` is ALWAYS false; we only surface counts + a compact card summary.
10
+ *
11
+ * Pure, dependency-free CommonJS. We reuse the detect engine's `scanContent` +`RULES`
12
+ * (NOT a CLI shell-out, NOT a temp-file write) so post-checking a generated variant is
13
+ * fully in-memory and hermetically testable. The engine is the single source of truth
14
+ * for the finding shape ({ruleId, category, name, severity, file, line, column, match,
15
+ * references}); we never reimplement rule matching.
16
+ *
17
+ * Ships in the npm package (scripts/lib/** is in package.json `files`), so it requires
18
+ * only the in-repo detect engine — no runtime dependency, no network, no optional dep.
19
+ */
20
+
21
+ const path = require('node:path');
22
+ const { scanContent, run, RULES } = require('../detect/engine.cjs');
23
+
24
+ /**
25
+ * Normalize the caller's `files`/`content` input into a list of
26
+ * `{ path, content, ext }` units to scan in-memory.
27
+ *
28
+ * Accepts either:
29
+ * - `content`: a raw string (single anonymous unit), or
30
+ * - `files`: an array of `{ path, content }` (or `{ name, content }`) units, or
31
+ * an object map of `{ "<path>": "<content>" }`.
32
+ *
33
+ * @param {{files?: any, content?: string}} input
34
+ * @returns {{path: string, content: string, ext: string}[]}
35
+ */
36
+ function normalizeUnits(input) {
37
+ const units = [];
38
+ if (input && typeof input.content === 'string') {
39
+ const p = input.path || input.file || 'variant';
40
+ units.push({ path: p, content: input.content, ext: path.extname(p).toLowerCase() });
41
+ }
42
+ const files = input && input.files;
43
+ if (Array.isArray(files)) {
44
+ for (const f of files) {
45
+ if (!f) continue;
46
+ if (typeof f === 'string') continue; // a bare path with no content is not scannable in-memory
47
+ const p = f.path || f.name || f.file || 'variant';
48
+ const content = typeof f.content === 'string' ? f.content : '';
49
+ units.push({ path: p, content, ext: path.extname(p).toLowerCase() });
50
+ }
51
+ } else if (files && typeof files === 'object') {
52
+ for (const p of Object.keys(files)) {
53
+ const content = typeof files[p] === 'string' ? files[p] : '';
54
+ units.push({ path: p, content, ext: path.extname(p).toLowerCase() });
55
+ }
56
+ }
57
+ return units;
58
+ }
59
+
60
+ /**
61
+ * Post-check a generated variant against the gdd-detect anti-pattern rule set.
62
+ *
63
+ * Prefers an in-memory scan of the supplied variant source via the detect engine's
64
+ * `scanContent`. When NO inline content is supplied but a `projectRoot` is given,
65
+ * falls back to the documented programmatic engine form `run(projectRoot)` so the
66
+ * caller can still post-check files already written to disk.
67
+ *
68
+ * @param {object} args
69
+ * @param {string} [args.projectRoot] Repo root — used for the on-disk fallback and as the
70
+ * `cwd` so finding `file` paths are repo-relative.
71
+ * @param {Array|object} [args.files] Variant files: `[{path, content}]` or `{ "<path>": "<content>" }`.
72
+ * @param {string} [args.content] A single raw variant source string.
73
+ * @returns {{findings: object[], errorCount: number, warnCount: number, autoReject: boolean}}
74
+ */
75
+ function postCheckVariant(args = {}) {
76
+ const { projectRoot } = args;
77
+ const units = normalizeUnits(args);
78
+
79
+ let findings = [];
80
+ if (units.length > 0) {
81
+ const cwd = projectRoot || process.cwd();
82
+ for (const u of units) {
83
+ // Make the reported `file` repo-relative when projectRoot is known, mirroring the
84
+ // engine's own relativization in run().
85
+ let rel = u.path;
86
+ if (projectRoot && path.isAbsolute(u.path)) {
87
+ rel = path.relative(cwd, u.path).split(path.sep).join('/') || u.path;
88
+ }
89
+ const hits = scanContent(u.content, { path: rel, ext: u.ext }, RULES);
90
+ findings.push(...hits);
91
+ }
92
+ // Match the engine's deterministic ordering (file, line, column, ruleId).
93
+ findings.sort(
94
+ (a, b) =>
95
+ a.file.localeCompare(b.file) ||
96
+ a.line - b.line ||
97
+ a.column - b.column ||
98
+ a.ruleId.localeCompare(b.ruleId),
99
+ );
100
+ } else if (projectRoot) {
101
+ // No inline content — fall back to the documented programmatic engine form against
102
+ // files already on disk under projectRoot.
103
+ const result = run(projectRoot, { cwd: projectRoot });
104
+ findings = result.findings;
105
+ }
106
+
107
+ let errorCount = 0;
108
+ let warnCount = 0;
109
+ for (const f of findings) {
110
+ if (f.severity === 'error') errorCount += 1;
111
+ else warnCount += 1;
112
+ }
113
+
114
+ // Spec D: error-severity is flagged, NOT auto-rejected. autoReject is always false.
115
+ return { findings, errorCount, warnCount, autoReject: false };
116
+ }
117
+
118
+ /**
119
+ * Compact, single-line summary of a findings array for a variant card.
120
+ * Always returns a string (never null/undefined) so the skill can render it verbatim.
121
+ *
122
+ * [] → "clean — no anti-patterns"
123
+ * [1 error, 2 warn] → "2 issue(s): 1 error, 2 warn — BAN-06, BAN-01, BAN-08"
124
+ *
125
+ * Rule ids are de-duplicated and listed in first-seen order, capped so the card stays compact.
126
+ *
127
+ * @param {object[]} findings
128
+ * @returns {string}
129
+ */
130
+ function summarizeForCard(findings) {
131
+ const list = Array.isArray(findings) ? findings : [];
132
+ if (list.length === 0) return 'clean — no anti-patterns';
133
+
134
+ let errors = 0;
135
+ let warns = 0;
136
+ const ids = [];
137
+ const seen = new Set();
138
+ for (const f of list) {
139
+ if (f && f.severity === 'error') errors += 1;
140
+ else warns += 1;
141
+ const id = f && f.ruleId;
142
+ if (id && !seen.has(id)) {
143
+ seen.add(id);
144
+ ids.push(id);
145
+ }
146
+ }
147
+
148
+ const MAX_IDS = 5;
149
+ const shownIds = ids.slice(0, MAX_IDS);
150
+ const idTail = ids.length > MAX_IDS ? `, +${ids.length - MAX_IDS} more` : '';
151
+ const parts = [];
152
+ if (errors) parts.push(`${errors} error`);
153
+ if (warns) parts.push(`${warns} warn`);
154
+
155
+ return `${list.length} issue(s): ${parts.join(', ')} — ${shownIds.join(', ')}${idTail}`;
156
+ }
157
+
158
+ module.exports = { postCheckVariant, summarizeForCard, normalizeUnits };