claude-git-hooks 2.66.1 → 2.68.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.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * File: skill-registry/index.js
3
+ * Purpose: Public API (facade) for the mscope automation-skills integration.
4
+ *
5
+ * Integration entry points — consumers should only need these three:
6
+ * - runSkillChecks(...) — freshness + registry load + line-level checks
7
+ * + display + blockOn gating decision.
8
+ * - getCatalogueSection(...) — rendered rule catalogue for the analysis
9
+ * prompt (memoized registry load).
10
+ * - recordSkillGaps(...) — append unclassified AI findings to the
11
+ * skill repo's skill-feedback.md.
12
+ *
13
+ * Lower-level building blocks remain exported for tests and advanced use:
14
+ * - loadRegistry() — parse the JBE/UIK rule catalogue.
15
+ * - runRules(...) — line-level pre-commit checks against staged files.
16
+ * - displayFindings(...) — formatted console output.
17
+ * - checkFreshness() — non-blocking call to `automation-skills check`.
18
+ *
19
+ * Designed so the pre-commit hook can do:
20
+ *
21
+ * import * as skill from '../utils/skill-registry/index.js';
22
+ * const check = skill.runSkillChecks(stagedAbsPaths, { repoRoot, config });
23
+ * if (check.shouldBlock) process.exit(1); // exit stays in the hook
24
+ *
25
+ * Failure mode is always "silent skip" — if the skill repo isn't found, or
26
+ * the registry is malformed, the hook continues without these checks. The
27
+ * mscope skill is a value-add, not a hard dependency of this repo.
28
+ *
29
+ * No function in this module calls process.exit() — utils return decisions,
30
+ * the CLI/hook layer acts on them (repo convention).
31
+ */
32
+
33
+ export { resolveSkillRepoRoot, loadRegistry, SCOPE_BY_EXT } from './parser.js';
34
+ export { runRules } from './runner.js';
35
+
36
+ import { spawnSync } from 'child_process';
37
+ import { loadRegistry } from './parser.js';
38
+ import { runRules } from './runner.js';
39
+ import { renderCatalogue } from './catalogue.js';
40
+ import { writeSkillGapCandidates } from './feedback-writer.js';
41
+ import logger from '../logger.js';
42
+
43
+ /**
44
+ * Severity rank used by the blockOn gate. Lower = more severe.
45
+ * 'never' (or any unknown value) disables gating.
46
+ */
47
+ const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
48
+
49
+ /**
50
+ * Process-lifetime cache of registry loads, keyed by the workingRepoRoot
51
+ * used for resolution. Lets runSkillChecks and getCatalogueSection share
52
+ * a single filesystem pass per commit.
53
+ */
54
+ const registryCache = new Map();
55
+
56
+ function loadRegistryCached(workingRepoRoot) {
57
+ const key = workingRepoRoot || '';
58
+ if (!registryCache.has(key)) {
59
+ registryCache.set(key, loadRegistry(null, workingRepoRoot));
60
+ }
61
+ return registryCache.get(key);
62
+ }
63
+
64
+ /**
65
+ * Test hook — clears the memoized registry. Not for production use.
66
+ */
67
+ export function _resetRegistryCache() {
68
+ registryCache.clear();
69
+ }
70
+
71
+ /**
72
+ * One-call integration point for the pre-commit hook: freshness warning,
73
+ * registry load (memoized), rule run, console display, and the blockOn
74
+ * gating decision.
75
+ *
76
+ * Never calls process.exit — returns `shouldBlock` and lets the hook decide.
77
+ * Throws only on unexpected internal errors (callers wrap in try/catch).
78
+ *
79
+ * @param {string[]} absPaths - Absolute paths of staged files to check.
80
+ * @param {object} options
81
+ * @param {string} options.repoRoot - Working repo root (for sibling resolution + relative display).
82
+ * @param {object} options.config - Merged config; reads config.skillRegistry.blockOn.
83
+ * @returns {{ skillRoot: string|null, rules: Array, findings: Array,
84
+ * totalFindings: number, shouldBlock: boolean, triggeringCount: number }}
85
+ */
86
+ export function runSkillChecks(absPaths, { repoRoot, config } = {}) {
87
+ const freshness = checkFreshness();
88
+ if (freshness.status === 'stale') {
89
+ logger.warning(`📚 mscope skill is behind: installed v${freshness.installed}, latest v${freshness.latest}. Run: automation-skills update`);
90
+ }
91
+
92
+ const { rules, skillRoot } = loadRegistryCached(repoRoot);
93
+ if (!skillRoot || rules.length === 0) {
94
+ if (!skillRoot) {
95
+ logger.debug('skill-registry - runSkillChecks', 'Skill registry not found — skipping skill-driven checks');
96
+ }
97
+ return { skillRoot, rules: rules || [], findings: [], totalFindings: 0, shouldBlock: false, triggeringCount: 0 };
98
+ }
99
+
100
+ const result = runRules(rules, absPaths, { repoRoot });
101
+ displayFindings(result);
102
+
103
+ // Optional gating: blockOn = 'critical' | 'high' | 'medium' | 'low' | 'never'
104
+ // (default: 'never' — warn-only for incremental adoption).
105
+ const blockOn = config?.skillRegistry?.blockOn || 'never';
106
+ const threshold = SEVERITY_RANK[blockOn] ?? -1;
107
+ const triggering = threshold >= 0
108
+ ? result.findings.filter((f) => (SEVERITY_RANK[f.severity] ?? 99) <= threshold)
109
+ : [];
110
+
111
+ return {
112
+ skillRoot,
113
+ rules,
114
+ findings: result.findings,
115
+ totalFindings: result.totalFindings,
116
+ shouldBlock: triggering.length > 0,
117
+ triggeringCount: triggering.length,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Rendered `=== PLATFORM ANTIPATTERN CATALOGUE ===` body for injection into
123
+ * the Claude analysis prompt. Uses the memoized registry load, so calling
124
+ * this after runSkillChecks costs nothing.
125
+ *
126
+ * Never throws — the catalogue is value-add; on any failure returns null.
127
+ *
128
+ * @param {object} options
129
+ * @param {string} options.repoRoot - Working repo root for sibling resolution.
130
+ * @returns {string|null} Markdown catalogue, or null if unavailable/empty.
131
+ */
132
+ export function getCatalogueSection({ repoRoot } = {}) {
133
+ try {
134
+ const { rules, skillRoot } = loadRegistryCached(repoRoot);
135
+ if (!skillRoot || rules.length === 0) return null;
136
+ return renderCatalogue(rules) || null;
137
+ } catch (err) {
138
+ logger.debug('skill-registry - getCatalogueSection', 'Catalogue unavailable', { error: err?.message });
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Append skill-gap candidates (AI findings with no catalogue rule ID) to the
145
+ * skill repo's skill-feedback.md. Thin wrapper over writeSkillGapCandidates;
146
+ * throws on write failure (caller decides how loud to be).
147
+ *
148
+ * @param {object} aiResult - Result from analysis-engine.runAnalysis.
149
+ * @param {object} context - { skillRoot, branch, repoName }
150
+ * @returns {{ written: number, skippedDuplicate: number, skippedClassified: number }}
151
+ */
152
+ export function recordSkillGaps(aiResult, context) {
153
+ return writeSkillGapCandidates(aiResult, context);
154
+ }
155
+
156
+ /**
157
+ * Run `automation-skills check --json` to find out if the installed skill
158
+ * is behind the latest. Returns:
159
+ * { status: 'fresh' | 'stale' | 'unknown', installed, latest }
160
+ *
161
+ * Never throws; never blocks the hook. If the CLI isn't installed or the
162
+ * check fails, returns { status: 'unknown' }.
163
+ */
164
+ export function checkFreshness({ timeoutMs = 3000 } = {}) {
165
+ try {
166
+ const res = spawnSync('automation-skills', ['check', '--json'], {
167
+ timeout: timeoutMs,
168
+ stdio: ['ignore', 'pipe', 'pipe'],
169
+ });
170
+ if (res.error || (res.status !== 0 && res.status !== 1)) {
171
+ return { status: 'unknown' };
172
+ }
173
+ const line = (res.stdout || '').toString().trim();
174
+ if (!line) return { status: 'unknown' };
175
+ const parsed = JSON.parse(line);
176
+ return {
177
+ status: parsed.status === 'stale' ? 'stale'
178
+ : parsed.status === 'fresh' ? 'fresh'
179
+ : parsed.status === 'ahead' ? 'ahead'
180
+ : 'unknown',
181
+ installed: parsed.installed,
182
+ latest: parsed.latest,
183
+ };
184
+ } catch {
185
+ return { status: 'unknown' };
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Print a formatted summary of pre-commit findings (from runRules) to
191
+ * stdout. Returns nothing.
192
+ *
193
+ * Format intentionally mirrors the existing hook's `displayResults` style
194
+ * so it slots in naturally.
195
+ *
196
+ * 📚 mscope skill registry — 2 findings (1 high, 1 medium)
197
+ *
198
+ * [JBE-016] medium Wildcard import (`import x.*;`)
199
+ * file.java:12 import jakarta.persistence.*;
200
+ * → see automation-standards-backend/docs/improvement-registry.md#jbe-016
201
+ */
202
+ export function displayFindings({ totalFindings, findings, bySeverity }) {
203
+ if (totalFindings === 0) {
204
+ logger.info('📚 mscope skill registry — no rule violations in staged files.');
205
+ return;
206
+ }
207
+
208
+ const sev = bySeverity || {};
209
+ const sevParts = [];
210
+ if (sev.critical) sevParts.push(`${sev.critical} 🔴 critical`);
211
+ if (sev.high) sevParts.push(`${sev.high} 🟠 high`);
212
+ if (sev.medium) sevParts.push(`${sev.medium} 🟡 medium`);
213
+ if (sev.low) sevParts.push(`${sev.low} 🟢 low`);
214
+
215
+ logger.info(`📚 mscope skill registry — ${totalFindings} finding${totalFindings === 1 ? '' : 's'}${
216
+ sevParts.length ? ` (${sevParts.join(', ')})` : ''}`);
217
+ logger.info('');
218
+
219
+ // Group findings by rule for compact output.
220
+ const byRule = {};
221
+ for (const f of findings) {
222
+ (byRule[f.ruleId] ||= { rule: f, occurrences: [] }).occurrences.push(f);
223
+ }
224
+ const rulesSorted = Object.keys(byRule).sort((a, b) => {
225
+ // High severity first, then by rule id.
226
+ const order = { critical: 0, high: 1, medium: 2, low: 3, unknown: 4 };
227
+ const sa = order[byRule[a].rule.severity] ?? 99;
228
+ const sb = order[byRule[b].rule.severity] ?? 99;
229
+ if (sa !== sb) return sa - sb;
230
+ return a.localeCompare(b);
231
+ });
232
+
233
+ for (const ruleId of rulesSorted) {
234
+ const { rule, occurrences } = byRule[ruleId];
235
+ const sevTag = sevTagFor(rule.severity);
236
+ logger.info(` [${ruleId}] ${sevTag} ${rule.title}`);
237
+ for (const occ of occurrences.slice(0, 5)) {
238
+ logger.info(` ${occ.file}:${occ.line} ${occ.lineText.slice(0, 100)}`);
239
+ }
240
+ if (occurrences.length > 5) {
241
+ logger.info(` … and ${occurrences.length - 5} more occurrence${occurrences.length - 5 === 1 ? '' : 's'}`);
242
+ }
243
+ logger.info(` → see ${rule.scope}/improvement-registry.md → ${ruleId}`);
244
+ logger.info('');
245
+ }
246
+ }
247
+
248
+ function sevTagFor(sev) {
249
+ if (sev === 'critical') return '🔴 critical';
250
+ if (sev === 'high') return '🟠 high';
251
+ if (sev === 'medium') return '🟡 medium';
252
+ if (sev === 'low') return '🟢 low';
253
+ return `⚪ ${ sev}`;
254
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * File: skill-registry/parser.js
3
+ * Purpose: Parse @mscope/automation-skills improvement-registry.md files into
4
+ * a structured rule index that the pre-commit hook can run grep
5
+ * patterns against.
6
+ *
7
+ * Why this exists: the mscope skill repo
8
+ * (https://github.com/mscope-S-L/automation-skills) maintains canonical
9
+ * antipattern catalogues (JBE-NNN for backend, UIK-NNN/STR-NNN/etc. for
10
+ * frontend). Each registry entry includes a `Verify:` grep command that
11
+ * detects the antipattern. We parse those entries here so the pre-commit
12
+ * hook can run them against staged files automatically — turning the
13
+ * skill from descriptive (lives in docs) into prescriptive (blocks bad
14
+ * commits with the documented rule ID + fix).
15
+ *
16
+ * Skill repo location resolution (in order):
17
+ * 1. CLAUDE_HOOKS_SKILL_REPO env var
18
+ * 2. AUTOMATION_SKILLS_REPO env var (matches the skill repo's own convention)
19
+ * 3. ~/.claude/skills/automation-standards/.installed-version → walks back
20
+ * to find the source clone (when installed via `automation-skills install`)
21
+ * 4. Returns null — the caller decides whether to skip silently or warn
22
+ */
23
+
24
+ import { readFileSync, existsSync } from 'fs';
25
+ import { dirname, join } from 'path';
26
+
27
+ const SKILL_PATHS = [
28
+ {
29
+ key: 'backend',
30
+ relPath: 'skills/backend/automation-standards-backend/docs/improvement-registry.md',
31
+ },
32
+ {
33
+ key: 'frontend',
34
+ relPath: 'skills/frontend/automation-standards/docs/improvement-registry.md',
35
+ },
36
+ ];
37
+
38
+ /**
39
+ * Single source of truth for file-extension → skill-scope mapping.
40
+ * Adding a new extension here propagates to every consumer (runner,
41
+ * feedback-writer, future modules). Avoids the duplicate-map drift the
42
+ * reviewer flagged on the v1 PR.
43
+ */
44
+ export const SCOPE_BY_EXT = Object.freeze({
45
+ // Backend (Java + SQL — both owned by the backend skill)
46
+ '.java': 'backend',
47
+ '.sql': 'backend',
48
+ // Frontend (React / styling)
49
+ '.jsx': 'frontend',
50
+ '.tsx': 'frontend',
51
+ '.js': 'frontend',
52
+ '.ts': 'frontend',
53
+ '.css': 'frontend',
54
+ '.scss': 'frontend',
55
+ });
56
+
57
+ /**
58
+ * Resolve the skill repo root.
59
+ *
60
+ * Resolution order:
61
+ * 1. `CLAUDE_HOOKS_SKILL_REPO` env var (most explicit per-machine override).
62
+ * 2. `AUTOMATION_SKILLS_REPO` env var (matches the skill repo's own
63
+ * CLI convention; lets a teammate set one variable for both repos).
64
+ * 3. Sibling of the working repo: if the hook is running on a commit in
65
+ * e.g. `<parent>/mscope-gateway/`, check `<parent>/automation-skills/`.
66
+ * This is the mscope canonical layout (all repos as siblings under one
67
+ * parent, folder name matching the GitHub repo name) and requires no
68
+ * env-var setup for teammates following convention.
69
+ * 4. Return null — silent skip in the hook. The skill is value-add,
70
+ * not a hard dep of claude-git-hooks.
71
+ *
72
+ * No hardcoded absolute paths and no homedir-relative guesses (those
73
+ * leaked personal layouts and never matched on the canonical mscope
74
+ * `<parent>/mscope-*` setup — see PR #180 review comment 4).
75
+ *
76
+ * @param {string} [workingRepoRoot] - Absolute path of the repo whose
77
+ * commit triggered the hook (passed from pre-commit.js).
78
+ * @returns {string|null} Absolute path of the skill repo, or null.
79
+ */
80
+ export function resolveSkillRepoRoot(workingRepoRoot = null) {
81
+ const candidates = [
82
+ process.env.CLAUDE_HOOKS_SKILL_REPO,
83
+ process.env.AUTOMATION_SKILLS_REPO,
84
+ ].filter(Boolean);
85
+
86
+ for (const c of candidates) {
87
+ if (isSkillRepoRoot(c)) return c;
88
+ }
89
+
90
+ if (workingRepoRoot) {
91
+ const parent = dirname(workingRepoRoot);
92
+ const guess = join(parent, 'automation-skills');
93
+ if (isSkillRepoRoot(guess)) return guess;
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ function isSkillRepoRoot(dir) {
100
+ if (!dir) return false;
101
+ try {
102
+ const pkgPath = join(dir, 'package.json');
103
+ if (!existsSync(pkgPath)) return false;
104
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
105
+ return pkg.name === '@mscope/automation-skills';
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Load all rule entries from both registries. Returns:
113
+ * [
114
+ * { id: 'JBE-001', severity: 'high', symptom: '…',
115
+ * fix: '…', verify: 'grep -rn …', scope: 'backend', skillRel: '…' },
116
+ * …
117
+ * ]
118
+ * If the skill repo isn't found, returns []. Caller can decide how to surface.
119
+ */
120
+ export function loadRegistry(skillRepoRoot = null, workingRepoRoot = null) {
121
+ const root = skillRepoRoot || resolveSkillRepoRoot(workingRepoRoot);
122
+ if (!root) return { rules: [], skillRoot: null };
123
+
124
+ const rules = [];
125
+ for (const { key, relPath } of SKILL_PATHS) {
126
+ const fullPath = join(root, relPath);
127
+ if (!existsSync(fullPath)) continue;
128
+ const content = readFileSync(fullPath, 'utf8');
129
+ const parsed = parseRegistryMarkdown(content, key, relPath);
130
+ rules.push(...parsed);
131
+ }
132
+ return { rules, skillRoot: root };
133
+ }
134
+
135
+ /**
136
+ * Parse a single registry markdown file. Returns an array of rule objects.
137
+ * Each registry entry follows the shape:
138
+ *
139
+ * ### <RULE-ID> — <title>
140
+ * **Severity:** <emoji + label>
141
+ * **Symptom:** <description or fenced code>
142
+ * **Fix:** <description or fenced code>
143
+ * **Verify:** <inline command, or fenced code with `grep -rn …`>
144
+ * **Seen in:** …
145
+ * **Established in:** …
146
+ *
147
+ * ---
148
+ *
149
+ * The parser is lenient — missing fields default to null. Entries without a
150
+ * usable Verify command are still indexed (so other features can reference
151
+ * them by ID) but won't fire pre-commit alarms.
152
+ */
153
+ export function parseRegistryMarkdown(content, scope, sourceRel) {
154
+ const sections = content.split(/^### /m).slice(1);
155
+ const out = [];
156
+ for (const sec of sections) {
157
+ const newlineIdx = sec.indexOf('\n');
158
+ const header = newlineIdx === -1 ? sec : sec.slice(0, newlineIdx);
159
+ const body = newlineIdx === -1 ? '' : sec.slice(newlineIdx + 1);
160
+ // Header: "<RULE-ID> — <title>" or "<RULE-ID>: <title>" or "<RULE-ID> - <title>"
161
+ const idMatch = header.match(/^([A-Z][A-Z0-9]{1,7}-\d{3})\s*[—:–-]\s*(.+?)\s*$/);
162
+ if (!idMatch) continue;
163
+ const id = idMatch[1];
164
+ const title = idMatch[2];
165
+
166
+ const fields = extractFields(body);
167
+ if (!fields) continue;
168
+
169
+ const verifies = parseVerifyField(fields.verify);
170
+
171
+ out.push({
172
+ id,
173
+ title,
174
+ severity: classifySeverity(fields.severity),
175
+ severityRaw: fields.severity || '',
176
+ symptom: fields.symptom || null,
177
+ fix: fields.fix || null,
178
+ verifies,
179
+ seenIn: fields.seenIn || null,
180
+ establishedIn: fields.establishedIn || null,
181
+ knownEdgeCases: fields.knownEdgeCases || null,
182
+ scope,
183
+ source: sourceRel,
184
+ });
185
+ }
186
+ return out;
187
+ }
188
+
189
+ /**
190
+ * Extract the standard fields (Severity / Symptom / Fix / Verify / Seen in
191
+ * / Established in / Known edge cases) from a registry-entry body.
192
+ *
193
+ * Each field can be a single line OR span multiple lines including fenced
194
+ * code blocks. We slice on `**FieldName:**` boundaries.
195
+ */
196
+ function extractFields(body) {
197
+ const FIELD_ORDER = [
198
+ ['severity', /\*\*Severity:\*\*/i],
199
+ ['symptom', /\*\*Symptom:\*\*/i],
200
+ ['fix', /\*\*Fix:\*\*/i],
201
+ ['verify', /\*\*Verify:\*\*/i],
202
+ ['seenIn', /\*\*Seen in:\*\*/i],
203
+ ['establishedIn', /\*\*Established in:\*\*/i],
204
+ ['knownEdgeCases', /\*\*Known edge cases:\*\*/i],
205
+ ];
206
+
207
+ // Find each field's start position; the field value runs to the next
208
+ // field start (or to a `---` divider / end of body).
209
+ const found = [];
210
+ for (const [name, re] of FIELD_ORDER) {
211
+ const m = body.match(re);
212
+ if (m) found.push({ name, start: m.index, headerEnd: m.index + m[0].length });
213
+ }
214
+ if (found.length === 0) return null;
215
+ found.sort((a, b) => a.start - b.start);
216
+
217
+ const dividerIdx = body.search(/^---\s*$/m);
218
+ const out = {};
219
+ for (let i = 0; i < found.length; i++) {
220
+ const cur = found[i];
221
+ const next = found[i + 1];
222
+ const stopAt = next
223
+ ? next.start
224
+ : dividerIdx >= 0 && dividerIdx > cur.start
225
+ ? dividerIdx
226
+ : body.length;
227
+ out[cur.name] = body.slice(cur.headerEnd, stopAt).trim();
228
+ }
229
+ return out;
230
+ }
231
+
232
+ /**
233
+ * Extract one-or-more runnable grep/find commands from a Verify field value.
234
+ *
235
+ * Verify field shapes seen in the registry:
236
+ * - Inline: `**Verify:** grep -rn "printStackTrace()" src/main/java/`
237
+ * - Fenced bash:
238
+ * ```bash
239
+ * grep -l "ALTER TABLE" BBDD/*.sql | xargs grep -L "IF NOT EXISTS"
240
+ * ```
241
+ * - Prose ("manual review") — returns []
242
+ *
243
+ * Returns an array of { command, kind } where kind ∈ { 'grep', 'find', 'other' }.
244
+ * Multi-command (piped) verifies are kept as a single entry — the runner shells out.
245
+ */
246
+ function parseVerifyField(raw) {
247
+ if (!raw) return [];
248
+ const out = [];
249
+
250
+ // Fenced code blocks first.
251
+ const fenceRe = /```(?:[a-z]*)?\n([\s\S]*?)\n```/g;
252
+ let m;
253
+ while ((m = fenceRe.exec(raw)) !== null) {
254
+ const block = m[1];
255
+ for (const line of block.split('\n')) {
256
+ const cmd = line.trim();
257
+ if (isRunnableCommand(cmd)) out.push(toRule(cmd));
258
+ }
259
+ }
260
+
261
+ // Inline backtick command (single-line `cmd`).
262
+ const inlineRe = /`([^`\n]+)`/g;
263
+ while ((m = inlineRe.exec(raw)) !== null) {
264
+ const cmd = m[1].trim();
265
+ if (isRunnableCommand(cmd)) out.push(toRule(cmd));
266
+ }
267
+
268
+ // Plain (no backticks) — a single line beginning with grep/find/rg.
269
+ if (out.length === 0) {
270
+ for (const line of raw.split('\n')) {
271
+ const t = line.trim().replace(/^[-*]\s*/, '');
272
+ if (isRunnableCommand(t)) out.push(toRule(t));
273
+ }
274
+ }
275
+
276
+ return dedupe(out);
277
+ }
278
+
279
+ function isRunnableCommand(s) {
280
+ if (!s) return false;
281
+ if (s.length < 5 || s.length > 800) return false;
282
+ return /^(grep|rg|find|git\s+grep)\b/.test(s);
283
+ }
284
+
285
+ function toRule(command) {
286
+ let kind = 'other';
287
+ if (/^grep\b/.test(command)) kind = 'grep';
288
+ else if (/^rg\b/.test(command)) kind = 'grep';
289
+ else if (/^find\b/.test(command)) kind = 'find';
290
+ else if (/^git\s+grep\b/.test(command)) kind = 'grep';
291
+ return { command, kind };
292
+ }
293
+
294
+ function dedupe(arr) {
295
+ const seen = new Set();
296
+ return arr.filter((x) => {
297
+ if (seen.has(x.command)) return false;
298
+ seen.add(x.command);
299
+ return true;
300
+ });
301
+ }
302
+
303
+ function classifySeverity(raw) {
304
+ if (!raw) return 'unknown';
305
+ const lower = raw.toLowerCase();
306
+ if (lower.includes('critical') || lower.includes('🔴')) return 'critical';
307
+ if (lower.includes('high') || lower.includes('🟠')) return 'high';
308
+ if (lower.includes('medium') || lower.includes('🟡')) return 'medium';
309
+ if (lower.includes('low') || lower.includes('🟢')) return 'low';
310
+ return 'unknown';
311
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * File: skill-registry/resume.js
3
+ * Purpose: Lessons-learned handover — invokes `automation-skills resume`
4
+ * so the developer can record what they learned on the branch to
5
+ * the mscope skill repo's implementation-history.md.
6
+ *
7
+ * Invoked from create-pr (knowledge-capture step, alongside gotcha
8
+ * solicitation) at the natural "branch done, about to publish" moment.
9
+ * The mscope `automation-skills resume` command analyses
10
+ * `git log <trunk>..HEAD`, prompts interactively for lessons, and appends
11
+ * them to the skill repo. We hand over stdio so the prompt works.
12
+ *
13
+ * Branch preconditions (feature branch, commits ahead of trunk) are
14
+ * guaranteed by the create-pr context, so no git validation happens here.
15
+ *
16
+ * Never throws and never blocks the caller's flow — the integration is
17
+ * value-add, not a hard dependency. The CLI not being installed is a
18
+ * silent skip (callers can check isResumeCliAvailable() first to decide
19
+ * whether to announce the handover).
20
+ */
21
+
22
+ import { spawnSync } from 'child_process';
23
+ import logger from '../logger.js';
24
+
25
+ /** Process-lifetime cache of the CLI availability probe. */
26
+ let _cliAvailable = null;
27
+
28
+ /**
29
+ * Test hook — clears the memoized CLI availability. Not for production use.
30
+ */
31
+ export function _resetCliAvailabilityCache() {
32
+ _cliAvailable = null;
33
+ }
34
+
35
+ /**
36
+ * Probe whether the `automation-skills` CLI is reachable on PATH (memoized
37
+ * for the process lifetime — one spawn per run). Lets callers announce the
38
+ * interactive handover only when it will actually happen.
39
+ *
40
+ * @returns {boolean}
41
+ */
42
+ export function isResumeCliAvailable() {
43
+ if (_cliAvailable !== null) return _cliAvailable;
44
+ const res = spawnSync('automation-skills', ['--version'], {
45
+ stdio: ['ignore', 'pipe', 'pipe'],
46
+ });
47
+ _cliAvailable = !res.error && res.status === 0;
48
+ return _cliAvailable;
49
+ }
50
+
51
+ /**
52
+ * Hand control to `automation-skills resume [skill] [--trunk <branch>]`
53
+ * with inherited stdio (interactive prompt).
54
+ *
55
+ * @param {object} options
56
+ * @param {string} options.repoRoot - Working repo root (cwd for the subcommand).
57
+ * @param {string|null} options.skill - Optional skill hint (frontend|backend|fe|be).
58
+ * @param {string|null} options.trunk - Optional trunk override passed through.
59
+ * @returns {{ ran: boolean, reason?: string, exitCode?: number }}
60
+ */
61
+ export function runResumeFlow({ repoRoot, skill = null, trunk = null } = {}) {
62
+ if (!isResumeCliAvailable()) {
63
+ logger.debug('skill-registry - runResumeFlow', 'automation-skills CLI not on PATH — skipping lessons capture');
64
+ return { ran: false, reason: 'cli-missing' };
65
+ }
66
+
67
+ const resumeArgs = ['resume'];
68
+ if (skill) resumeArgs.push(skill);
69
+ if (trunk) resumeArgs.push('--trunk', trunk);
70
+
71
+ const proc = spawnSync('automation-skills', resumeArgs, {
72
+ stdio: 'inherit',
73
+ cwd: repoRoot,
74
+ });
75
+
76
+ if (proc.error) {
77
+ logger.debug('skill-registry - runResumeFlow', 'resume spawn failed', { error: proc.error.message });
78
+ return { ran: false, reason: 'spawn-error' };
79
+ }
80
+ return { ran: true, exitCode: proc.status ?? 0 };
81
+ }