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.
- package/CHANGELOG.md +117 -8
- package/README.md +34 -0
- package/bin/claude-hooks +19 -0
- package/lib/commands/analyze-diff.js +26 -23
- package/lib/commands/analyze.js +3 -1
- package/lib/commands/back-merge.js +39 -33
- package/lib/commands/bump-version.js +20 -15
- package/lib/commands/close-release.js +25 -19
- package/lib/commands/create-pr.js +30 -1
- package/lib/commands/create-release.js +19 -13
- package/lib/commands/install.js +9 -19
- package/lib/commands/update.js +14 -28
- package/lib/defaults.json +9 -0
- package/lib/hooks/pre-commit.js +112 -32
- package/lib/utils/analysis-engine.js +7 -3
- package/lib/utils/auto-update.js +198 -0
- package/lib/utils/config-registry.js +1 -0
- package/lib/utils/library-resolver.js +50 -0
- package/lib/utils/prompt-builder.js +15 -0
- package/lib/utils/skill-registry/catalogue.js +74 -0
- package/lib/utils/skill-registry/feedback-writer.js +196 -0
- package/lib/utils/skill-registry/index.js +254 -0
- package/lib/utils/skill-registry/parser.js +311 -0
- package/lib/utils/skill-registry/resume.js +81 -0
- package/lib/utils/skill-registry/runner.js +265 -0
- package/lib/utils/version-manager.js +9 -3
- package/package.json +84 -85
- package/templates/CLAUDE_ANALYSIS_PROMPT.md +2 -1
- package/templates/config.advanced.example.json +42 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: auto-update.js
|
|
3
|
+
* Purpose: Centralized self-update logic shared by the manual `update` command,
|
|
4
|
+
* the install-time prompt, and the pre-command guard in bin/claude-hooks.
|
|
5
|
+
*
|
|
6
|
+
* Single source of truth for: resolving current-vs-latest version, performing the
|
|
7
|
+
* `npm install -g` update + hook reinstall, throttling automatic checks, and the
|
|
8
|
+
* silent pre-command auto-update guard.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync } from 'child_process';
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import {
|
|
15
|
+
info,
|
|
16
|
+
warning,
|
|
17
|
+
success,
|
|
18
|
+
getPackageJson,
|
|
19
|
+
getLatestVersion,
|
|
20
|
+
compareVersions
|
|
21
|
+
} from '../commands/helpers.js';
|
|
22
|
+
import { getRepoRoot } from './git-operations.js';
|
|
23
|
+
import { getConfig } from '../config.js';
|
|
24
|
+
import logger from './logger.js';
|
|
25
|
+
|
|
26
|
+
const PACKAGE_NAME = 'claude-git-hooks';
|
|
27
|
+
const CHECK_FILE = '.last-update-check';
|
|
28
|
+
const DEFAULT_INTERVAL_HOURS = 24;
|
|
29
|
+
|
|
30
|
+
// Commands that must NOT trigger the pre-command auto-update guard.
|
|
31
|
+
// Why: avoids recursion (update → install --force), and skips fast/offline
|
|
32
|
+
// commands where a network round-trip would only add latency.
|
|
33
|
+
const EXCLUDED_COMMANDS = new Set([
|
|
34
|
+
'update',
|
|
35
|
+
'install',
|
|
36
|
+
'uninstall',
|
|
37
|
+
'help',
|
|
38
|
+
'version',
|
|
39
|
+
'migrate-config'
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve current vs latest version and derived flags.
|
|
44
|
+
* @returns {Promise<{currentVersion:string, latestVersion:string, comparison:number, isNewer:boolean, isDev:boolean}>}
|
|
45
|
+
*/
|
|
46
|
+
export async function getUpdateStatus() {
|
|
47
|
+
const currentVersion = getPackageJson().version;
|
|
48
|
+
const latestVersion = await getLatestVersion(PACKAGE_NAME);
|
|
49
|
+
const comparison = compareVersions(currentVersion, latestVersion);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
currentVersion,
|
|
53
|
+
latestVersion,
|
|
54
|
+
comparison,
|
|
55
|
+
isNewer: comparison < 0,
|
|
56
|
+
isDev: comparison > 0
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Update the globally-installed package and (optionally) reinstall hooks in the
|
|
62
|
+
* current repo. Implements the approved flow:
|
|
63
|
+
* npm install -g claude-git-hooks@latest
|
|
64
|
+
* → locate repo root; if none, warn and stop; else reinstall hooks (--force)
|
|
65
|
+
*
|
|
66
|
+
* @param {Object} [opts]
|
|
67
|
+
* @param {boolean} [opts.reinstallHooks=true] - reinstall hooks at repo root after update
|
|
68
|
+
* @param {boolean} [opts.silent=false] - suppress sub-process output and progress chatter
|
|
69
|
+
* @returns {Promise<{updated:boolean, hooksReinstalled:boolean}>}
|
|
70
|
+
*/
|
|
71
|
+
export async function performUpdate({ reinstallHooks = true, silent = false } = {}) {
|
|
72
|
+
const stdio = silent ? 'ignore' : 'inherit';
|
|
73
|
+
|
|
74
|
+
if (!silent) {
|
|
75
|
+
info('Updating claude-git-hooks...');
|
|
76
|
+
}
|
|
77
|
+
execSync(`npm install -g ${PACKAGE_NAME}@latest`, { stdio });
|
|
78
|
+
|
|
79
|
+
if (!reinstallHooks) {
|
|
80
|
+
return { updated: true, hooksReinstalled: false };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Locate the repo root: this is the "look for root" step. getRepoRoot()
|
|
84
|
+
// throws (GitError) outside a git repo — treat that as "no root → warn & stop".
|
|
85
|
+
try {
|
|
86
|
+
const root = getRepoRoot();
|
|
87
|
+
logger.debug('auto-update - performUpdate', 'Repo root located for hook reinstall', { root });
|
|
88
|
+
} catch {
|
|
89
|
+
warning(
|
|
90
|
+
'Package updated, but you are not inside a git repository — hooks were not reinstalled.\n' +
|
|
91
|
+
' cd into your repo and run: claude-hooks install --force'
|
|
92
|
+
);
|
|
93
|
+
return { updated: true, hooksReinstalled: false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!silent) {
|
|
97
|
+
info('Reinstalling hooks with the new version...');
|
|
98
|
+
}
|
|
99
|
+
// Dynamic import breaks the static cycle (install.js imports this module).
|
|
100
|
+
const { runInstall } = await import('../commands/install.js');
|
|
101
|
+
await runInstall(silent ? ['--force', '--headless'] : ['--force']);
|
|
102
|
+
|
|
103
|
+
return { updated: true, hooksReinstalled: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether enough time has elapsed since the last auto-update check.
|
|
108
|
+
* Throttle state lives in `<repoRoot>/.claude/.last-update-check` (.claude is gitignored).
|
|
109
|
+
* Fail-open: any FS/parse error returns true (allow the check).
|
|
110
|
+
*
|
|
111
|
+
* @param {number} [intervalHours=24]
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
export function shouldAutoCheck(intervalHours = DEFAULT_INTERVAL_HOURS) {
|
|
115
|
+
try {
|
|
116
|
+
const file = path.join(getRepoRoot(), '.claude', CHECK_FILE);
|
|
117
|
+
if (!fs.existsSync(file)) return true;
|
|
118
|
+
|
|
119
|
+
const last = Number(fs.readFileSync(file, 'utf8').trim());
|
|
120
|
+
if (!Number.isFinite(last)) return true;
|
|
121
|
+
|
|
122
|
+
const elapsedHours = (Date.now() - last) / (1000 * 60 * 60);
|
|
123
|
+
return elapsedHours >= intervalHours;
|
|
124
|
+
} catch {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Record the current time as the last auto-update check. Best-effort (fail-open).
|
|
131
|
+
*/
|
|
132
|
+
export function recordCheckTimestamp() {
|
|
133
|
+
try {
|
|
134
|
+
const dir = path.join(getRepoRoot(), '.claude');
|
|
135
|
+
if (!fs.existsSync(dir)) return;
|
|
136
|
+
fs.writeFileSync(path.join(dir, CHECK_FILE), String(Date.now()), 'utf8');
|
|
137
|
+
} catch {
|
|
138
|
+
// best effort — throttling is an optimization, not a correctness requirement
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Pre-command auto-update guard. Runs SILENTLY before a command in bin/claude-hooks.
|
|
144
|
+
* Returns true when an update was performed (the running binary changed, so the
|
|
145
|
+
* caller should exit and ask the user to re-run their command).
|
|
146
|
+
*
|
|
147
|
+
* Fully fail-open: any error is swallowed (debug-logged) and returns false so the
|
|
148
|
+
* real command always proceeds.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} command - the resolved command name (entry.name)
|
|
151
|
+
* @returns {Promise<boolean>}
|
|
152
|
+
*/
|
|
153
|
+
export async function maybeAutoUpdate(command) {
|
|
154
|
+
// Defensive recursion guard (in-process runInstall does not re-enter the router,
|
|
155
|
+
// but this is cheap insurance against any future re-spawn).
|
|
156
|
+
if (process.env.CLAUDE_HOOKS_UPDATE_IN_PROGRESS) return false;
|
|
157
|
+
if (EXCLUDED_COMMANDS.has(command)) return false;
|
|
158
|
+
|
|
159
|
+
let autoUpdate = { enabled: true, intervalHours: DEFAULT_INTERVAL_HOURS };
|
|
160
|
+
try {
|
|
161
|
+
const config = await getConfig();
|
|
162
|
+
autoUpdate = { ...autoUpdate, ...(config.autoUpdate || {}) };
|
|
163
|
+
} catch {
|
|
164
|
+
// Config load failure → use defaults (enabled)
|
|
165
|
+
}
|
|
166
|
+
if (autoUpdate.enabled === false) return false;
|
|
167
|
+
|
|
168
|
+
// Must be inside a repo: throttle state + hook reinstall target live there.
|
|
169
|
+
try {
|
|
170
|
+
getRepoRoot();
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!shouldAutoCheck(autoUpdate.intervalHours)) return false;
|
|
176
|
+
// Throttle the next attempt regardless of network outcome (avoids offline spam).
|
|
177
|
+
recordCheckTimestamp();
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
process.env.CLAUDE_HOOKS_UPDATE_IN_PROGRESS = '1';
|
|
181
|
+
|
|
182
|
+
const status = await getUpdateStatus();
|
|
183
|
+
if (!status.isNewer) return false;
|
|
184
|
+
|
|
185
|
+
await performUpdate({ reinstallHooks: true, silent: true });
|
|
186
|
+
success(
|
|
187
|
+
`⬆️ Updated claude-git-hooks to v${status.latestVersion} — please re-run your command.`
|
|
188
|
+
);
|
|
189
|
+
return true;
|
|
190
|
+
} catch (e) {
|
|
191
|
+
logger.debug('auto-update - maybeAutoUpdate', 'Auto-update check skipped (non-fatal)', {
|
|
192
|
+
error: e.message
|
|
193
|
+
});
|
|
194
|
+
return false;
|
|
195
|
+
} finally {
|
|
196
|
+
delete process.env.CLAUDE_HOOKS_UPDATE_IN_PROGRESS;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -70,6 +70,7 @@ const SECTION_REMOTE_MAP = {
|
|
|
70
70
|
analysis: _settingsEntry('analysis'),
|
|
71
71
|
commitMessage: _settingsEntry('commitMessage'),
|
|
72
72
|
judge: _settingsEntry('judge'),
|
|
73
|
+
skillRegistry: _settingsEntry('skillRegistry'),
|
|
73
74
|
orchestrator: _settingsEntry('orchestrator'),
|
|
74
75
|
prMetadata: _settingsEntry('prMetadata'),
|
|
75
76
|
linting: _settingsEntry('linting')
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: library-resolver.js
|
|
3
|
+
* Purpose: Resolve the Library (`.library/`) of the repository that claude-hooks
|
|
4
|
+
* is currently running in — never the bundled tool's own copy.
|
|
5
|
+
*
|
|
6
|
+
* Why: claude-git-hooks is a tool. When installed (globally or as a dependency)
|
|
7
|
+
* it intentionally ships WITHOUT a `.library/` (see package.json `files`). The
|
|
8
|
+
* Library that the pipeline maintains always belongs to the repo under work —
|
|
9
|
+
* e.g. running `create-pr` inside `mscope-transactions` maintains that repo's
|
|
10
|
+
* docs. The only time it touches git-hooks' own Library is when dog-fooding
|
|
11
|
+
* (editing git-hooks itself), in which case the working repo IS git-hooks.
|
|
12
|
+
*
|
|
13
|
+
* Consumers must therefore resolve `.library/` from the working repo root, not
|
|
14
|
+
* relative to their own (package) location. This module centralizes that.
|
|
15
|
+
*
|
|
16
|
+
* Cross-platform: getRepoRoot() returns a git path (`/mnt/c/...` under WSL/Linux,
|
|
17
|
+
* `C:/...` under native Windows). `path.join` + `pathToFileURL` produce a valid
|
|
18
|
+
* `file://` URL for the current runtime, so `import()` of an absolute path works
|
|
19
|
+
* everywhere (a bare `import('C:\\...')` is invalid on Windows).
|
|
20
|
+
*
|
|
21
|
+
* Dependencies:
|
|
22
|
+
* - git-operations: getRepoRoot() — the single source of truth for the repo root
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync } from 'fs';
|
|
26
|
+
import path from 'path';
|
|
27
|
+
import { pathToFileURL } from 'url';
|
|
28
|
+
import { getRepoRoot } from './git-operations.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build a file:// URL for a module inside the current repo's `.library/`,
|
|
32
|
+
* suitable for dynamic `import()`.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} subpath - Path inside `.library/` (e.g. 'librarian/index.js')
|
|
35
|
+
* @returns {string} A `file://` URL string
|
|
36
|
+
*/
|
|
37
|
+
export function libraryModuleUrl(subpath) {
|
|
38
|
+
return pathToFileURL(path.join(getRepoRoot(), '.library', subpath)).href;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether the current repo has its own Library to maintain.
|
|
43
|
+
* Cheap pre-check so consumers can skip the pipeline cleanly (no scary warning)
|
|
44
|
+
* in repos that simply have no `.library/`.
|
|
45
|
+
*
|
|
46
|
+
* @returns {boolean} True when `.library/resolver.yaml` exists in the repo root
|
|
47
|
+
*/
|
|
48
|
+
export function hasLibrary() {
|
|
49
|
+
return existsSync(path.join(getRepoRoot(), '.library', 'resolver.yaml'));
|
|
50
|
+
}
|
|
@@ -194,6 +194,8 @@ const formatFileSection = ({ path, diff, content, isNew }) => {
|
|
|
194
194
|
* @param {Object} options.metadata - Additional metadata (repo name, branch, etc.)
|
|
195
195
|
* @param {string|null} options.commonContext - Shared commit overview injected before file diffs (optional)
|
|
196
196
|
* @param {string|null} options.batchRationale - Orchestrator's rationale for this batch (optional)
|
|
197
|
+
* @param {string|null} options.catalogueSection - Rendered platform antipattern catalogue (optional;
|
|
198
|
+
* callers obtain it via skill-registry getCatalogueSection())
|
|
197
199
|
* @returns {Promise<string>} Complete analysis prompt
|
|
198
200
|
*
|
|
199
201
|
* File data object structure:
|
|
@@ -211,6 +213,7 @@ const buildAnalysisPrompt = async ({
|
|
|
211
213
|
metadata = {},
|
|
212
214
|
commonContext = null,
|
|
213
215
|
batchRationale = null,
|
|
216
|
+
catalogueSection = null,
|
|
214
217
|
baseDir = '.claude/prompts'
|
|
215
218
|
} = {}) => {
|
|
216
219
|
logger.debug('prompt-builder - buildAnalysisPrompt', 'Building analysis prompt', {
|
|
@@ -219,6 +222,7 @@ const buildAnalysisPrompt = async ({
|
|
|
219
222
|
fileCount: files.length,
|
|
220
223
|
hasCommonContext: !!commonContext,
|
|
221
224
|
hasBatchRationale: !!batchRationale,
|
|
225
|
+
hasCatalogueSection: !!catalogueSection,
|
|
222
226
|
baseDir
|
|
223
227
|
});
|
|
224
228
|
|
|
@@ -247,6 +251,17 @@ const buildAnalysisPrompt = async ({
|
|
|
247
251
|
prompt += '\n\n=== EVALUATION GUIDELINES ===\n';
|
|
248
252
|
prompt += guidelines;
|
|
249
253
|
|
|
254
|
+
// Add the mscope platform antipattern catalogue if the caller supplied
|
|
255
|
+
// one (rendered via skill-registry getCatalogueSection()). The catalogue
|
|
256
|
+
// lists every JBE-NNN / UIK-NNN / etc. rule with severity; Claude is
|
|
257
|
+
// instructed to populate details[].rule with the catalogue ID when a
|
|
258
|
+
// finding matches. Unmatched findings (rule unset) become skill-gap
|
|
259
|
+
// candidates downstream.
|
|
260
|
+
if (catalogueSection) {
|
|
261
|
+
prompt += '\n\n=== PLATFORM ANTIPATTERN CATALOGUE ===\n';
|
|
262
|
+
prompt += catalogueSection;
|
|
263
|
+
}
|
|
264
|
+
|
|
250
265
|
// Add changes section
|
|
251
266
|
prompt += '\n\n=== CHANGES TO REVIEW ===\n';
|
|
252
267
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: skill-registry/catalogue.js
|
|
3
|
+
* Purpose: Render the parsed JBE/UIK rule index as a compact catalogue
|
|
4
|
+
* string suitable for injection into Claude's analysis prompt.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists (Option E2): when Claude analyses code, we want it to
|
|
7
|
+
* label findings with the canonical rule ID (e.g. `JBE-001`) directly,
|
|
8
|
+
* rather than relying on fuzzy post-processing to guess. We do this by
|
|
9
|
+
* injecting a compact catalogue into the analysis prompt — one line per
|
|
10
|
+
* rule, grouped by scope, ~80 chars per line. The prompt's existing
|
|
11
|
+
* `details[].rule` field (already declared "optional" in the schema)
|
|
12
|
+
* becomes load-bearing: populated = covered; empty = skill-gap candidate.
|
|
13
|
+
*
|
|
14
|
+
* Token budget: ~95 rules × ~80 chars = ~7.6 KB → ~1,900 tokens added to
|
|
15
|
+
* each analysis prompt. Negligible against the 200k context window.
|
|
16
|
+
*
|
|
17
|
+
* Why a separate module: keeps catalogue formatting decisions (one-line
|
|
18
|
+
* vs full, severity tags, scope grouping) in one place where they can be
|
|
19
|
+
* tuned without touching prompt-builder or pre-commit.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Render the rule catalogue as a compact string for prompt injection.
|
|
24
|
+
* @param {Array<object>} rules - From parser.loadRegistry().rules
|
|
25
|
+
* @returns {string} catalogue text (empty string if no rules)
|
|
26
|
+
*/
|
|
27
|
+
export function renderCatalogue(rules) {
|
|
28
|
+
if (!rules || rules.length === 0) return '';
|
|
29
|
+
|
|
30
|
+
const byScope = {};
|
|
31
|
+
for (const r of rules) {
|
|
32
|
+
(byScope[r.scope] ||= []).push(r);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const lines = [];
|
|
36
|
+
lines.push('Each finding MUST be classified against the platform antipattern catalogue below.');
|
|
37
|
+
lines.push('When a finding matches a catalogue entry, populate `details[].rule` with the rule ID');
|
|
38
|
+
lines.push('(e.g. "JBE-001"). Findings that don\'t match any entry leave `rule` empty — those');
|
|
39
|
+
lines.push('are candidate skill-gaps the team will review separately.');
|
|
40
|
+
lines.push('');
|
|
41
|
+
|
|
42
|
+
const SCOPE_LABELS = {
|
|
43
|
+
backend: 'BACKEND (Java / Spring Boot / JPA / SQL Server) — applies to .java and .sql files',
|
|
44
|
+
frontend: 'FRONTEND (React / @mscope/uikit / RHF / TanStack Query) — applies to .jsx/.tsx/.js/.css',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
for (const scope of ['backend', 'frontend']) {
|
|
48
|
+
const list = byScope[scope];
|
|
49
|
+
if (!list || list.length === 0) continue;
|
|
50
|
+
lines.push(SCOPE_LABELS[scope] || scope.toUpperCase());
|
|
51
|
+
|
|
52
|
+
// Sort: id ascending (group prefix together: JBE-001..JBE-NNN, UIK-001..)
|
|
53
|
+
list.sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }));
|
|
54
|
+
|
|
55
|
+
for (const r of list) {
|
|
56
|
+
const sevTag = (`[${ r.severity || 'unknown' }]`).padEnd(11);
|
|
57
|
+
const title = (r.title || '').replace(/`/g, '').slice(0, 110);
|
|
58
|
+
lines.push(` ${r.id.padEnd(9)} ${sevTag} ${title}`);
|
|
59
|
+
}
|
|
60
|
+
lines.push('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
lines.push('SEVERITY → JSON mapping:');
|
|
64
|
+
lines.push(' critical → BLOCKER (commit blocked)');
|
|
65
|
+
lines.push(' high → CRITICAL (commit blocked)');
|
|
66
|
+
lines.push(' medium → MAJOR (warn)');
|
|
67
|
+
lines.push(' low → MINOR (warn)');
|
|
68
|
+
lines.push('');
|
|
69
|
+
lines.push('When you flag a finding, the catalogue entry\'s severity tells you the MIN severity');
|
|
70
|
+
lines.push('to assign — don\'t downgrade. You may upgrade if context warrants (e.g. a JBE-001');
|
|
71
|
+
lines.push('printStackTrace inside a financial calculation path is BLOCKER, not CRITICAL).');
|
|
72
|
+
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: skill-registry/feedback-writer.js
|
|
3
|
+
* Purpose: Option E (bidirectional feedback loop) — take the AI analysis
|
|
4
|
+
* findings AFTER Claude returns them and, for each finding the
|
|
5
|
+
* AI couldn't classify against the catalogue, append a candidate
|
|
6
|
+
* [skill-gap] entry to the appropriate skill's skill-feedback.md.
|
|
7
|
+
*
|
|
8
|
+
* How it works (depends on Option E2's catalogue injection in prompt-builder):
|
|
9
|
+
* - Claude's analysis prompt now contains the rule catalogue.
|
|
10
|
+
* - For each finding, Claude populates `details[].rule` with the matching
|
|
11
|
+
* JBE-NNN / UIK-NNN ID — or leaves it empty if nothing matched.
|
|
12
|
+
* - We treat empty `rule` as "AI saw something real but couldn't tag it"
|
|
13
|
+
* — i.e. the registry has a gap. Those are valuable signal for the
|
|
14
|
+
* `automation-skills retro` loop.
|
|
15
|
+
*
|
|
16
|
+
* Per the maintainer's `feedback_skill_improvements_user_approval` rule,
|
|
17
|
+
* we never auto-merge into improvement-registry.md or promote candidates
|
|
18
|
+
* to rules. We write them to skill-feedback.md as candidates only; the
|
|
19
|
+
* user reviews and approves via `automation-skills retro` later.
|
|
20
|
+
*
|
|
21
|
+
* Dedup: we skip writing if an entry with the same (scope, file, message-hash)
|
|
22
|
+
* already exists in skill-feedback.md. Prevents the same recurring AI
|
|
23
|
+
* finding from spamming the doc on every commit.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
27
|
+
import { join, extname } from 'path';
|
|
28
|
+
import { createHash } from 'crypto';
|
|
29
|
+
import { SCOPE_BY_EXT } from './parser.js';
|
|
30
|
+
|
|
31
|
+
const SKILL_PATHS = {
|
|
32
|
+
backend: 'skills/backend/automation-standards-backend/docs/skill-feedback.md',
|
|
33
|
+
frontend: 'skills/frontend/automation-standards/docs/skill-feedback.md',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Process the AI's analysis details, write skill-gap candidates as needed.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} aiResult - The `result` returned by analysis-engine.runAnalysis
|
|
40
|
+
* (shape: { issues, details: [{file, line, message, severity, rule, type}], ... })
|
|
41
|
+
* @param {object} context - { skillRoot, branch, repoName }
|
|
42
|
+
* @returns {{ written: number, skippedDuplicate: number, skippedClassified: number,
|
|
43
|
+
* candidatesByScope: { backend?: Array, frontend?: Array } }}
|
|
44
|
+
*/
|
|
45
|
+
export function writeSkillGapCandidates(aiResult, context) {
|
|
46
|
+
const { skillRoot, branch, repoName } = context;
|
|
47
|
+
if (!skillRoot) return emptyResult();
|
|
48
|
+
if (!aiResult || !Array.isArray(aiResult.details)) return emptyResult();
|
|
49
|
+
|
|
50
|
+
// Bucket unclassified details by scope.
|
|
51
|
+
const byScope = { backend: [], frontend: [] };
|
|
52
|
+
for (const d of aiResult.details) {
|
|
53
|
+
if (d.rule && /^[A-Z]+-\d{3}$/.test(d.rule)) continue; // already classified
|
|
54
|
+
if (!d.file || !d.message) continue; // can't dedup without these
|
|
55
|
+
const ext = extname(String(d.file)).toLowerCase();
|
|
56
|
+
const scope = SCOPE_BY_EXT[ext];
|
|
57
|
+
if (!scope) continue; // unknown scope, ignore
|
|
58
|
+
byScope[scope].push(d);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const date = today();
|
|
62
|
+
let written = 0;
|
|
63
|
+
let skippedDuplicate = 0;
|
|
64
|
+
let skippedClassified = 0;
|
|
65
|
+
const candidatesByScope = {};
|
|
66
|
+
|
|
67
|
+
// Count classified for the stats output.
|
|
68
|
+
for (const d of aiResult.details) {
|
|
69
|
+
if (d.rule && /^[A-Z]+-\d{3}$/.test(d.rule)) skippedClassified++;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const scope of ['backend', 'frontend']) {
|
|
73
|
+
const candidates = byScope[scope];
|
|
74
|
+
if (candidates.length === 0) continue;
|
|
75
|
+
|
|
76
|
+
const filePath = join(skillRoot, SKILL_PATHS[scope]);
|
|
77
|
+
if (!existsSync(filePath)) continue;
|
|
78
|
+
|
|
79
|
+
const existing = readFileSync(filePath, 'utf8');
|
|
80
|
+
const existingHashes = extractExistingHashes(existing);
|
|
81
|
+
|
|
82
|
+
const toWrite = [];
|
|
83
|
+
for (const detail of candidates) {
|
|
84
|
+
const fp = fingerprint(scope, detail);
|
|
85
|
+
if (existingHashes.has(fp)) {
|
|
86
|
+
skippedDuplicate++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
toWrite.push({ detail, fingerprint: fp });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (toWrite.length === 0) continue;
|
|
93
|
+
|
|
94
|
+
const entry = composeEntry({
|
|
95
|
+
date, scope, repoName, branch, candidates: toWrite,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
appendToFeedback(filePath, entry);
|
|
99
|
+
written += toWrite.length;
|
|
100
|
+
candidatesByScope[scope] = toWrite.map((x) => x.detail);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { written, skippedDuplicate, skippedClassified, candidatesByScope };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Fingerprint a detail so repeat reports on the same file/message don't
|
|
108
|
+
* pile up entries across commits. (scope, file, hash(message-normalised))
|
|
109
|
+
*/
|
|
110
|
+
function fingerprint(scope, detail) {
|
|
111
|
+
const msg = String(detail.message || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
112
|
+
const file = String(detail.file || '').replace(/\\/g, '/');
|
|
113
|
+
const hash = createHash('sha1').update(msg).digest('hex').slice(0, 12);
|
|
114
|
+
return `${scope}::${file}::${hash}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Walk the existing skill-feedback.md and collect fingerprints of entries
|
|
119
|
+
* already present (we embed the fingerprint as an HTML comment so the
|
|
120
|
+
* dedup is durable even after the file is hand-edited).
|
|
121
|
+
*/
|
|
122
|
+
function extractExistingHashes(content) {
|
|
123
|
+
const set = new Set();
|
|
124
|
+
const re = /<!--\s*skill-gap-fp:\s*([^\s>]+)\s*-->/g;
|
|
125
|
+
let m;
|
|
126
|
+
while ((m = re.exec(content)) !== null) {
|
|
127
|
+
set.add(m[1]);
|
|
128
|
+
}
|
|
129
|
+
return set;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Compose a single dated entry covering N candidates from the same scope
|
|
134
|
+
* + branch + commit. Each candidate becomes one bullet.
|
|
135
|
+
*/
|
|
136
|
+
function composeEntry({ date, scope, repoName, branch, candidates }) {
|
|
137
|
+
const title = '[skill-gap candidates from claude-git-hooks AI analysis]';
|
|
138
|
+
const ctx = [
|
|
139
|
+
repoName ? `**Repo:** ${repoName}` : null,
|
|
140
|
+
branch ? `**Branch:** ${branch}` : null,
|
|
141
|
+
].filter(Boolean).join('\n');
|
|
142
|
+
|
|
143
|
+
const bullets = candidates.map(({ detail, fingerprint: fp }) => {
|
|
144
|
+
const file = String(detail.file).replace(/\\/g, '/');
|
|
145
|
+
const line = detail.line ? `:${detail.line}` : '';
|
|
146
|
+
const sev = detail.severity ? ` (${detail.severity})` : '';
|
|
147
|
+
const type = detail.type ? ` [${detail.type}]` : '';
|
|
148
|
+
const msg = String(detail.message).replace(/\n/g, ' ').slice(0, 300);
|
|
149
|
+
return `- **[skill-gap]** ${file}${line}${type}${sev} — ${msg} <!-- skill-gap-fp: ${fp} -->`;
|
|
150
|
+
}).join('\n');
|
|
151
|
+
|
|
152
|
+
return `### ${date} — ${title} (${scope})
|
|
153
|
+
|
|
154
|
+
${ctx}
|
|
155
|
+
|
|
156
|
+
**Source:** Claude AI analysis flagged these findings, but none matched a JBE/UIK rule in the catalogue. They're recorded here for review during the next \`automation-skills retro\`. If a recurring pattern emerges, file a new registry entry.
|
|
157
|
+
|
|
158
|
+
**Candidates:**
|
|
159
|
+
|
|
160
|
+
${bullets}
|
|
161
|
+
|
|
162
|
+
`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Append the entry to skill-feedback.md, after the "How to recognize a
|
|
167
|
+
* skill-caused issue" section and any existing dated entries, but before
|
|
168
|
+
* "How this compounds" (the footer).
|
|
169
|
+
*/
|
|
170
|
+
function appendToFeedback(filePath, entry) {
|
|
171
|
+
let content = readFileSync(filePath, 'utf8');
|
|
172
|
+
|
|
173
|
+
// Replace the placeholder "(none yet — …)" line if present; otherwise
|
|
174
|
+
// insert before "## How this compounds".
|
|
175
|
+
const placeholder = /_\(none yet — the first entry will be appended below\.\)_\n/;
|
|
176
|
+
if (placeholder.test(content)) {
|
|
177
|
+
content = content.replace(placeholder, `${entry.trimEnd() }\n`);
|
|
178
|
+
} else {
|
|
179
|
+
const footer = '## How this compounds';
|
|
180
|
+
const idx = content.indexOf(footer);
|
|
181
|
+
if (idx === -1) {
|
|
182
|
+
content = `${content.trimEnd() }\n\n${ entry}`;
|
|
183
|
+
} else {
|
|
184
|
+
content = `${content.slice(0, idx).trimEnd() }\n\n${ entry }---\n\n${ content.slice(idx)}`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
writeFileSync(filePath, content, 'utf8');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function emptyResult() {
|
|
191
|
+
return { written: 0, skippedDuplicate: 0, skippedClassified: 0, candidatesByScope: {} };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function today() {
|
|
195
|
+
return new Date().toISOString().slice(0, 10);
|
|
196
|
+
}
|