@ryuenn3123/agentic-senior-core 6.2.4 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/.agents/plugins/agentic-senior-core/hooks/constants.cjs +31 -3
  2. package/.agents/plugins/agentic-senior-core/hooks/dedup-gate.js +45 -28
  3. package/.agents/plugins/agentic-senior-core/hooks/ladder-pulse.js +8 -1
  4. package/.agents/plugins/agentic-senior-core/hooks/lib/known-security-patterns.json +67 -4
  5. package/.agents/plugins/agentic-senior-core/hooks/post-edit-enforce.js +9 -2
  6. package/.agents/plugins/agentic-senior-core/hooks/pre-compact-pin.js +37 -0
  7. package/.agents/plugins/agentic-senior-core/hooks/pre-tool-dependency-gate.js +9 -2
  8. package/.agents/plugins/agentic-senior-core/hooks.json +11 -2
  9. package/.agents/plugins/agentic-senior-core/plugin.json +1 -1
  10. package/.agents/plugins/agentic-senior-core/rules/agentic-senior-core.md +8 -4
  11. package/.agents/plugins/agentic-senior-core/skills/asc/SKILL.md +1 -1
  12. package/.agents/plugins/agentic-senior-core/skills/asc-adapter/SKILL.md +3 -1
  13. package/.agents/plugins/agentic-senior-core/skills/asc-audit/SKILL.md +18 -2
  14. package/.agents/plugins/agentic-senior-core/skills/asc-bootstrap/SKILL.md +1 -1
  15. package/.agents/plugins/agentic-senior-core/skills/asc-dedup/SKILL.md +4 -1
  16. package/.agents/plugins/agentic-senior-core/skills/asc-reference/SKILL.md +1 -1
  17. package/.agents/rules/agentic-senior-core.md +1 -0
  18. package/AGENTS.md +8 -4
  19. package/bin/agentic-senior-core.js +15 -8
  20. package/gemini-extension.json +1 -1
  21. package/lib/cli/commands/adapter.mjs +9 -1
  22. package/lib/cli/commands/git-hook-generator.mjs +273 -0
  23. package/lib/cli/commands/git-hook.mjs +24 -0
  24. package/lib/cli/commands/global.mjs +23 -1
  25. package/lib/cli/commands/uninstall.mjs +51 -1
  26. package/lib/core/adaptive-preferences.mjs +178 -0
  27. package/lib/core/bootstrap-wizard.mjs +64 -0
  28. package/lib/core/revert-detector.mjs +35 -0
  29. package/lib/core/rule-compiler.mjs +105 -0
  30. package/package.json +3 -9
  31. package/plugin.yaml +1 -1
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { installGitPreCommitHook } from './git-hook-generator.mjs';
4
5
 
5
6
  const currentFilePath = fileURLToPath(import.meta.url);
6
7
  const currentDirectoryPath = path.dirname(currentFilePath);
@@ -157,5 +158,12 @@ export async function runAdapterCommand(commandArguments) {
157
158
  if (success) successCount++;
158
159
  }
159
160
 
160
- console.log(`\nGenerated ${successCount}/${requestedAdapters.length} adapter file(s).`);
161
+ // Automatically install Git pre-commit hook for non-hook host backstop
162
+ const hookResult = installGitPreCommitHook({ cwd: targetDirectory });
163
+ if (hookResult.installed) {
164
+ const relPath = path.relative(targetDirectory, hookResult.hookPath) || hookResult.hookPath;
165
+ console.log(` Git Pre-Commit Hook: ${relPath} ... OK`);
166
+ }
167
+
168
+ console.log(`\nGenerated ${successCount}/${requestedAdapters.length} adapter file(s) and configured Git pre-commit hook.`);
161
169
  }
@@ -0,0 +1,273 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { compileAndSaveValidator } from '../../core/rule-compiler.mjs';
4
+
5
+ export const ASC_HOOK_HEADER = '# Agentic Senior Core Git Pre-Commit Hook';
6
+ export const ASC_HOOK_RUNNER_REL_PATH = path.join('.asc', 'hooks', 'pre-commit-runner.cjs').replace(/\\/g, '/');
7
+
8
+ export function generatePreCommitRunnerScript() {
9
+ return `#!/usr/bin/env node
10
+ // # Agentic Senior Core -- Git Pre-Commit Dedup & Quality Gate
11
+ // Auto-generated by Agentic Senior Core (ASC). DO NOT EDIT DIRECTLY.
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const os = require('os');
16
+ const { execSync } = require('child_process');
17
+
18
+ const SOURCE_EXTENSIONS = new Set([
19
+ 'js', 'ts', 'mjs', 'cjs', 'jsx', 'tsx',
20
+ 'py', 'rb', 'go', 'rs', 'java', 'kt', 'swift', 'cs',
21
+ ]);
22
+
23
+ const JSCPD_TIMEOUT_MS = 10000;
24
+
25
+ function getStagedFiles(cwd) {
26
+ try {
27
+ const output = execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8', cwd });
28
+ return output.split('\\n').map(f => f.trim()).filter(Boolean);
29
+ } catch (err) {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ function loadDedupConfig(cwd) {
35
+ const candidates = [
36
+ path.join(cwd, '.asc', 'dedup-config.json'),
37
+ path.join(cwd, '.agents', 'dedup-config.json'),
38
+ ];
39
+ for (let i = 0; i < candidates.length; i++) {
40
+ try {
41
+ if (fs.existsSync(candidates[i])) {
42
+ return JSON.parse(fs.readFileSync(candidates[i], 'utf8'));
43
+ }
44
+ } catch (_) {}
45
+ }
46
+ return { minTokens: 30, ignoreDirs: ['tests', 'migrations', 'generated', 'node_modules'] };
47
+ }
48
+
49
+ function runEslintAutoFix(cwd, stagedFiles) {
50
+ const hasTsConfig = fs.existsSync(path.join(cwd, 'tsconfig.json'));
51
+ if (!hasTsConfig) return;
52
+
53
+ const eslintConfigNames = [
54
+ 'eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts',
55
+ '.eslintrc', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json', '.eslintrc.yaml', '.eslintrc.yml'
56
+ ];
57
+ const hasEslintConfig = eslintConfigNames.some(name => fs.existsSync(path.join(cwd, name)));
58
+ if (!hasEslintConfig) return;
59
+
60
+ const tsJsExts = new Set(['js', 'ts', 'jsx', 'tsx', 'mjs', 'cjs']);
61
+ const stagedTsJsFiles = stagedFiles.filter(file => {
62
+ const ext = path.extname(file).slice(1).toLowerCase();
63
+ return tsJsExts.has(ext);
64
+ });
65
+
66
+ if (stagedTsJsFiles.length === 0) return;
67
+
68
+ try {
69
+ const fileList = stagedTsJsFiles.map(f => \`"\${f}"\`).join(' ');
70
+ execSync(\`npx eslint --fix \${fileList}\`, { stdio: 'ignore', cwd });
71
+ execSync(\`git add \${fileList}\`, { stdio: 'ignore', cwd });
72
+ } catch (_) {
73
+ // Silently ignore ESLint auto-fix errors (never block commit on lint failure alone)
74
+ }
75
+ }
76
+
77
+ function runJscpdScan(scanCmd, cwd, tmpDir) {
78
+ const binaries = ['bunx jscpd', 'npx jscpd@5', 'npx jscpd'];
79
+ for (let i = 0; i < binaries.length; i++) {
80
+ try {
81
+ execSync(binaries[i] + scanCmd, {
82
+ timeout: JSCPD_TIMEOUT_MS,
83
+ stdio: 'pipe',
84
+ cwd,
85
+ });
86
+ return loadReport(tmpDir);
87
+ } catch (_) {
88
+ // Try next binary
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function loadReport(tmpDir) {
95
+ const reportPath = path.join(tmpDir, 'jscpd-report.json');
96
+ if (!fs.existsSync(reportPath)) return null;
97
+ try {
98
+ return JSON.parse(fs.readFileSync(reportPath, 'utf8'));
99
+ } catch (_) {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ function checkForDuplicates(report, stagedSourceFiles, cwd) {
105
+ const duplicates = report.duplicates || [];
106
+ if (duplicates.length === 0) return null;
107
+
108
+ const normalizedStagedMap = new Map();
109
+ for (const f of stagedSourceFiles) {
110
+ const norm = path.resolve(cwd, f).replace(/\\\\/g, '/').toLowerCase();
111
+ normalizedStagedMap.set(norm, f);
112
+ }
113
+
114
+ for (let i = 0; i < duplicates.length; i++) {
115
+ const dup = duplicates[i];
116
+ const firstName = path.resolve(cwd, dup.firstFile.name).replace(/\\\\/g, '/').toLowerCase();
117
+ const secondName = path.resolve(cwd, dup.secondFile.name).replace(/\\\\/g, '/').toLowerCase();
118
+
119
+ const isFirstStaged = normalizedStagedMap.has(firstName);
120
+ const isSecondStaged = normalizedStagedMap.has(secondName);
121
+
122
+ if (isFirstStaged || isSecondStaged) {
123
+ const stagedFile = isFirstStaged ? normalizedStagedMap.get(firstName) : normalizedStagedMap.get(secondName);
124
+ const matchedFile = isFirstStaged ? path.basename(dup.secondFile.name) : path.basename(dup.firstFile.name);
125
+ const lines = dup.lines || 0;
126
+ const totalLines = (report.statistics && report.statistics.total && report.statistics.total.lines) || 1;
127
+ const percent = Math.round((lines / totalLines) * 100);
128
+ return { stagedFile, matchedFile, lines, percent };
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function runPreCommitGate() {
135
+ const cwd = process.cwd();
136
+
137
+ // 0. Run syntactic validator if present
138
+ const validatorPath = path.join(cwd, '.asc', 'hooks', 'pre-commit-validator.cjs');
139
+ if (fs.existsSync(validatorPath)) {
140
+ try {
141
+ execSync(\`node "\${validatorPath}"\`, { stdio: 'inherit', cwd });
142
+ } catch (valErr) {
143
+ process.exit(valErr.status || 1);
144
+ }
145
+ }
146
+
147
+ const stagedFiles = getStagedFiles(cwd);
148
+ if (stagedFiles.length === 0) {
149
+ process.exit(0);
150
+ }
151
+
152
+ // 1. Filter staged files by SOURCE_EXTENSIONS
153
+ const stagedSourceFiles = stagedFiles.filter(f => {
154
+ const ext = path.extname(f).slice(1).toLowerCase();
155
+ return SOURCE_EXTENSIONS.has(ext);
156
+ });
157
+
158
+ if (stagedSourceFiles.length === 0) {
159
+ process.exit(0);
160
+ }
161
+
162
+ // 2. Optional pre-step: ESLint auto-fix
163
+ runEslintAutoFix(cwd, stagedFiles);
164
+
165
+ // Re-fetch staged files after possible eslint auto-fix & git add
166
+ const currentStagedSource = getStagedFiles(cwd).filter(f => {
167
+ const ext = path.extname(f).slice(1).toLowerCase();
168
+ return SOURCE_EXTENSIONS.has(ext);
169
+ });
170
+
171
+ if (currentStagedSource.length === 0) {
172
+ process.exit(0);
173
+ }
174
+
175
+ // 3. Run jscpd dedup scan
176
+ const config = loadDedupConfig(cwd);
177
+ const scanDirs = Array.from(new Set(currentStagedSource.map(f => {
178
+ const resolved = path.resolve(cwd, f);
179
+ return path.dirname(resolved);
180
+ })));
181
+
182
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'asc-git-dedup-'));
183
+ const ignoreFlags = (config.ignoreDirs || ['tests', 'migrations', 'generated', 'node_modules'])
184
+ .map(d => \`--ignore "\${d}"\`).join(' ');
185
+ const minTokens = config.minTokens || 30;
186
+
187
+ let finding = null;
188
+
189
+ for (const scanDir of scanDirs) {
190
+ const scanCmd = \` "\${scanDir}" --min-tokens \${minTokens} --reporters json --silent --output "\${tmpDir}" \${ignoreFlags}\`;
191
+ const report = runJscpdScan(scanCmd, cwd, tmpDir);
192
+ if (report) {
193
+ finding = checkForDuplicates(report, currentStagedSource, cwd);
194
+ if (finding) break;
195
+ }
196
+ }
197
+
198
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
199
+
200
+ if (finding) {
201
+ console.error(\`\\x1b[31m[ASC Dedup]\\x1b[0m \${path.basename(finding.stagedFile)} looks similar to \${finding.matchedFile} (\${finding.percent}% overlap, \${finding.lines} lines)\`);
202
+ console.error(\`\\x1b[33mCommit blocked to prevent duplication. Use 'git commit --no-verify' to bypass if intentional.\\x1b[0m\`);
203
+ process.exit(1);
204
+ }
205
+
206
+ process.exit(0);
207
+ }
208
+
209
+ runPreCommitGate();
210
+ `;
211
+ }
212
+
213
+ /**
214
+ * Installs or updates the Git pre-commit hook in the target repository.
215
+ * @param {Object} options
216
+ * @param {string} [options.cwd] Working directory.
217
+ * @returns {{ installed: boolean, hookPath: string, runnerPath: string, reason?: string }} Installation result.
218
+ */
219
+ export function installGitPreCommitHook({ cwd = process.cwd() } = {}) {
220
+ const gitDir = path.join(cwd, '.git');
221
+ const huskyDir = path.join(cwd, '.husky');
222
+
223
+ if (!fs.existsSync(gitDir) && !fs.existsSync(huskyDir)) {
224
+ return { installed: false, hookPath: '', runnerPath: '', reason: 'Not a git repository' };
225
+ }
226
+
227
+ const ascHooksDir = path.join(cwd, '.asc', 'hooks');
228
+ if (!fs.existsSync(ascHooksDir)) {
229
+ fs.mkdirSync(ascHooksDir, { recursive: true });
230
+ }
231
+
232
+ // Compile syntactic validator script as fallback/compatibility
233
+ try { compileAndSaveValidator({ cwd }); } catch (_) {}
234
+
235
+ const runnerPath = path.join(ascHooksDir, 'pre-commit-runner.cjs');
236
+ const runnerScriptContent = generatePreCommitRunnerScript();
237
+ fs.writeFileSync(runnerPath, runnerScriptContent, { encoding: 'utf8', mode: 0o755 });
238
+
239
+ let hookPath = '';
240
+ const invocationCommand = `node .asc/hooks/pre-commit-runner.cjs`;
241
+
242
+ if (fs.existsSync(huskyDir)) {
243
+ hookPath = path.join(huskyDir, 'pre-commit');
244
+ if (fs.existsSync(hookPath)) {
245
+ const existing = fs.readFileSync(hookPath, 'utf8');
246
+ if (!existing.includes(ASC_HOOK_HEADER) && !existing.includes(ASC_HOOK_RUNNER_REL_PATH)) {
247
+ const updated = existing.trimEnd() + `\n\n${ASC_HOOK_HEADER}\n${invocationCommand}\n`;
248
+ fs.writeFileSync(hookPath, updated, { encoding: 'utf8', mode: 0o755 });
249
+ }
250
+ } else {
251
+ const content = `#!/bin/sh\n${ASC_HOOK_HEADER}\n${invocationCommand}\n`;
252
+ fs.writeFileSync(hookPath, content, { encoding: 'utf8', mode: 0o755 });
253
+ }
254
+ } else {
255
+ const hooksDir = path.join(gitDir, 'hooks');
256
+ if (!fs.existsSync(hooksDir)) {
257
+ fs.mkdirSync(hooksDir, { recursive: true });
258
+ }
259
+ hookPath = path.join(hooksDir, 'pre-commit');
260
+ if (fs.existsSync(hookPath)) {
261
+ const existing = fs.readFileSync(hookPath, 'utf8');
262
+ if (!existing.includes(ASC_HOOK_HEADER) && !existing.includes(ASC_HOOK_RUNNER_REL_PATH)) {
263
+ const updated = existing.trimEnd() + `\n\n${ASC_HOOK_HEADER}\n${invocationCommand}\n`;
264
+ fs.writeFileSync(hookPath, updated, { encoding: 'utf8', mode: 0o755 });
265
+ }
266
+ } else {
267
+ const content = `#!/bin/sh\n${ASC_HOOK_HEADER}\n${invocationCommand}\n`;
268
+ fs.writeFileSync(hookPath, content, { encoding: 'utf8', mode: 0o755 });
269
+ }
270
+ }
271
+
272
+ return { installed: true, hookPath, runnerPath };
273
+ }
@@ -0,0 +1,24 @@
1
+ import path from 'node:path';
2
+ import { installGitPreCommitHook } from './git-hook-generator.mjs';
3
+
4
+ /**
5
+ * Runs the `asc install-git-hook` command to install Git pre-commit hooks.
6
+ * @param {string[]} commandArguments
7
+ */
8
+ export async function runGitHookCommand(commandArguments) {
9
+ const targetDirectory = process.cwd();
10
+ console.log('Agentic Senior Core -- Git Pre-Commit Hook Installer\n');
11
+
12
+ const result = installGitPreCommitHook({ cwd: targetDirectory });
13
+
14
+ if (!result.installed) {
15
+ console.error(`Failed: ${result.reason || 'Could not install pre-commit hook'}`);
16
+ process.exit(1);
17
+ }
18
+
19
+ console.log(` Pre-commit hook: ${result.hookPath} ... OK`);
20
+ if (result.runnerPath) {
21
+ console.log(` Runner script: ${result.runnerPath} ... OK`);
22
+ }
23
+ console.log('\nGit pre-commit hook successfully installed.');
24
+ }
@@ -316,5 +316,27 @@ export async function runGlobalCommand(commandArguments) {
316
316
  if (success) successCount++;
317
317
  }
318
318
 
319
- console.log(`\nInstalled ${successCount}/${requestedTargets.length} global target(s).`);
319
+ // Automatically setup global Git hook runner in ~/.asc/hooks and configure git config --global core.hooksPath
320
+ try {
321
+ const { execSync } = await import('node:child_process');
322
+ const { generatePreCommitRunnerScript } = await import('./git-hook-generator.mjs');
323
+
324
+ const ascGlobalHooksDir = path.join(HOME, '.asc', 'hooks');
325
+ await fs.mkdir(ascGlobalHooksDir, { recursive: true });
326
+
327
+ const globalRunnerPath = path.join(ascGlobalHooksDir, 'pre-commit-runner.cjs');
328
+ const runnerContent = generatePreCommitRunnerScript();
329
+ await fs.writeFile(globalRunnerPath, runnerContent, { encoding: 'utf8', mode: 0o755 });
330
+
331
+ const globalHookPath = path.join(ascGlobalHooksDir, 'pre-commit');
332
+ const hookContent = `#!/bin/sh\n# Agentic Senior Core Global Git Pre-Commit Hook\nnode "${globalRunnerPath.replace(/\\/g, '/')}"\n`;
333
+ await fs.writeFile(globalHookPath, hookContent, { encoding: 'utf8', mode: 0o755 });
334
+
335
+ execSync(`git config --global core.hooksPath "${ascGlobalHooksDir.replace(/\\/g, '/')}"`, { stdio: 'ignore' });
336
+ console.log(` Global Git Pre-Commit Hook: ~/.asc/hooks (via git config --global) ... OK`);
337
+ } catch (err) {
338
+ // Best effort global git hook registration
339
+ }
340
+
341
+ console.log(`\nInstalled ${successCount}/${requestedTargets.length} global target(s) and configured Global Git Hook.`);
320
342
  }
@@ -18,6 +18,12 @@ const ADAPTER_FILES = [
18
18
  { label: 'Roo Code', path: '.roo/rules/agentic-senior-core.md' },
19
19
  { label: 'OpenHands', path: '.openhands/microagents/agentic-senior-core.md' },
20
20
  { label: 'ASC Compiled Validator', path: '.asc/hooks/pre-commit-validator.cjs' },
21
+ { label: 'ASC Git Pre-Commit Runner', path: '.asc/hooks/pre-commit-runner.cjs' },
22
+ ];
23
+
24
+ const GIT_HOOK_PATHS = [
25
+ { label: 'Husky Pre-Commit Hook', path: '.husky/pre-commit' },
26
+ { label: 'Git Pre-Commit Hook', path: '.git/hooks/pre-commit' },
21
27
  ];
22
28
 
23
29
  async function pathExists(filePath) {
@@ -32,7 +38,7 @@ async function pathExists(filePath) {
32
38
  async function isAscFile(filePath) {
33
39
  try {
34
40
  const content = await fs.readFile(filePath, 'utf8');
35
- return content.includes(ASC_SIGNATURE);
41
+ return content.includes(ASC_SIGNATURE) || content.includes('pre-commit-runner.cjs');
36
42
  } catch {
37
43
  return false;
38
44
  }
@@ -69,6 +75,50 @@ export async function runUninstallCommand(commandArguments) {
69
75
  }
70
76
  }
71
77
 
78
+ for (const hookItem of GIT_HOOK_PATHS) {
79
+ const fullPath = path.join(targetDirectory, hookItem.path);
80
+ if (!(await pathExists(fullPath))) continue;
81
+
82
+ try {
83
+ const content = await fs.readFile(fullPath, 'utf8');
84
+ if (!content.includes('Agentic Senior Core') && !content.includes('pre-commit-runner.cjs')) {
85
+ continue;
86
+ }
87
+
88
+ found++;
89
+
90
+ // Check if file contains ONLY ASC lines or if it has extra user commands
91
+ const lines = content.split('\n');
92
+ const nonAscLines = lines.filter(l => {
93
+ const trimmed = l.trim();
94
+ return trimmed && !trimmed.startsWith('#!') && !trimmed.includes('Agentic Senior Core') && !trimmed.includes('pre-commit-runner.cjs') && !trimmed.includes('pre-commit-validator.cjs');
95
+ });
96
+
97
+ if (nonAscLines.length === 0) {
98
+ // Safe to remove completely
99
+ if (dryRun) {
100
+ console.log(` would remove: ${hookItem.path} (${hookItem.label})`);
101
+ } else {
102
+ await fs.rm(fullPath, { force: true });
103
+ console.log(` removed: ${hookItem.path} (${hookItem.label})`);
104
+ removed++;
105
+ }
106
+ } else {
107
+ // File has user modifications / additional commands — preserve user commands, remove ASC block
108
+ if (dryRun) {
109
+ console.log(` would clean ASC block from: ${hookItem.path} (${hookItem.label})`);
110
+ } else {
111
+ const cleanedLines = lines.filter(l => !l.includes('Agentic Senior Core') && !l.includes('pre-commit-runner.cjs') && !l.includes('pre-commit-validator.cjs'));
112
+ await fs.writeFile(fullPath, cleanedLines.join('\n').trimEnd() + '\n', 'utf8');
113
+ console.log(` cleaned ASC lines from: ${hookItem.path} (${hookItem.label})`);
114
+ removed++;
115
+ }
116
+ }
117
+ } catch (err) {
118
+ console.log(` warn: could not process ${hookItem.path}: ${err.message}`);
119
+ }
120
+ }
121
+
72
122
  if (found === 0) {
73
123
  console.log('No ASC adapter files found in this project.');
74
124
  return;
@@ -0,0 +1,178 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+
5
+ export const DEFAULT_RULE_CAP = 25;
6
+
7
+ /**
8
+ * Resolves the global user configuration directory path.
9
+ * @returns {string} Absolute path to global config directory.
10
+ */
11
+ export function getUserConfigDir() {
12
+ return path.join(os.homedir(), '.gemini', 'config');
13
+ }
14
+
15
+ /**
16
+ * Resolves the project-local configuration directory path.
17
+ * @param {string} cwd Working directory of the project.
18
+ * @returns {string} Absolute path to project config directory.
19
+ */
20
+ export function getProjectConfigDir(cwd = process.cwd()) {
21
+ return path.join(cwd, '.agents');
22
+ }
23
+
24
+ /**
25
+ * Resolves the JSON preferences file path for a given scope.
26
+ * @param {Object} options
27
+ * @param {string} [options.cwd] Project working directory.
28
+ * @param {'user'|'project'} options.scope Preference scope.
29
+ * @returns {string} Absolute path to preferences JSON file.
30
+ */
31
+ export function getPreferencesFilePath({ cwd = process.cwd(), scope = 'project' } = {}) {
32
+ const baseDir = scope === 'user' ? getUserConfigDir() : getProjectConfigDir(cwd);
33
+ return path.join(baseDir, 'adaptive_preferences.json');
34
+ }
35
+
36
+ /**
37
+ * Initializes an empty preferences structure.
38
+ * @returns {Object} Fresh preferences data object.
39
+ */
40
+ export function createEmptyPreferences() {
41
+ return {
42
+ version: '1.0.0',
43
+ updatedAt: new Date().toISOString(),
44
+ rules: []
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Loads preference rules from storage for a specific scope.
50
+ * @param {Object} options
51
+ * @param {string} [options.cwd] Project working directory.
52
+ * @param {'user'|'project'} [options.scope] Target scope.
53
+ * @returns {Object} Preferences data object.
54
+ */
55
+ export function loadPreferences({ cwd = process.cwd(), scope = 'project' } = {}) {
56
+ const filePath = getPreferencesFilePath({ cwd, scope });
57
+ if (!fs.existsSync(filePath)) {
58
+ return createEmptyPreferences();
59
+ }
60
+
61
+ try {
62
+ const raw = fs.readFileSync(filePath, 'utf8');
63
+ const parsed = JSON.parse(raw);
64
+ if (!parsed || !Array.isArray(parsed.rules)) {
65
+ return createEmptyPreferences();
66
+ }
67
+ return parsed;
68
+ } catch (err) {
69
+ return createEmptyPreferences();
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Saves preference rules to storage for a specific scope.
75
+ * @param {Object} options
76
+ * @param {string} [options.cwd] Project working directory.
77
+ * @param {'user'|'project'} [options.scope] Target scope.
78
+ * @param {Object} options.preferences Preferences payload to write.
79
+ */
80
+ export function savePreferences({ cwd = process.cwd(), scope = 'project', preferences } = {}) {
81
+ const filePath = getPreferencesFilePath({ cwd, scope });
82
+ const dirPath = path.dirname(filePath);
83
+
84
+ if (!fs.existsSync(dirPath)) {
85
+ fs.mkdirSync(dirPath, { recursive: true });
86
+ }
87
+
88
+ const payload = {
89
+ ...createEmptyPreferences(),
90
+ ...preferences,
91
+ updatedAt: new Date().toISOString()
92
+ };
93
+
94
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8');
95
+ return payload;
96
+ }
97
+
98
+ /**
99
+ * Adds or updates a rule within the specified scope, enforcing deduplication and max rule cap.
100
+ * @param {Object} options
101
+ * @param {string} [options.cwd] Project working directory.
102
+ * @param {'user'|'project'} [options.scope] Target scope.
103
+ * @param {Object} options.rule Rule definition object.
104
+ * @param {number} [options.maxCap] Maximum allowed rules per scope.
105
+ * @returns {{ preferences: Object, added: boolean, capExceeded: boolean }} Result summary.
106
+ */
107
+ export function addRule({ cwd = process.cwd(), scope = 'project', rule, maxCap = DEFAULT_RULE_CAP } = {}) {
108
+ if (!rule || !rule.pattern) {
109
+ throw new Error('Rule pattern is required');
110
+ }
111
+
112
+ const prefs = loadPreferences({ cwd, scope });
113
+ const normalizedPattern = String(rule.pattern).trim().toLowerCase();
114
+
115
+ const existingIndex = prefs.rules.findIndex(
116
+ r => String(r.pattern).trim().toLowerCase() === normalizedPattern
117
+ );
118
+
119
+ const formattedRule = {
120
+ id: rule.id || `rule_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
121
+ type: rule.type === 'syntactic' ? 'syntactic' : 'taste',
122
+ pattern: String(rule.pattern).trim(),
123
+ reason: String(rule.reason || '').trim(),
124
+ source: rule.source || 'learn',
125
+ createdAt: rule.createdAt || new Date().toISOString()
126
+ };
127
+
128
+ let added = false;
129
+ if (existingIndex >= 0) {
130
+ // Update existing rule
131
+ prefs.rules[existingIndex] = {
132
+ ...prefs.rules[existingIndex],
133
+ ...formattedRule
134
+ };
135
+ } else {
136
+ // Append new rule
137
+ prefs.rules.push(formattedRule);
138
+ added = true;
139
+ }
140
+
141
+ let capExceeded = false;
142
+ if (prefs.rules.length > maxCap) {
143
+ capExceeded = true;
144
+ // Trim oldest rules exceeding cap
145
+ prefs.rules = prefs.rules.slice(prefs.rules.length - maxCap);
146
+ }
147
+
148
+ const saved = savePreferences({ cwd, scope, preferences: prefs });
149
+ return { preferences: saved, added, capExceeded };
150
+ }
151
+
152
+ /**
153
+ * Resolves active combined rules from both user and project scopes.
154
+ * User scope rules come first, project scope rules override or extend.
155
+ * @param {Object} options
156
+ * @param {string} [options.cwd] Project working directory.
157
+ * @returns {{ syntactic: Array<Object>, taste: Array<Object> }} Active rules grouped by track.
158
+ */
159
+ export function resolveActiveRules({ cwd = process.cwd() } = {}) {
160
+ const userPrefs = loadPreferences({ cwd, scope: 'user' });
161
+ const projectPrefs = loadPreferences({ cwd, scope: 'project' });
162
+
163
+ const combinedRules = [...userPrefs.rules, ...projectPrefs.rules];
164
+
165
+ // Deduplicate combined by pattern (project scope overrides user scope if identical)
166
+ const ruleMap = new Map();
167
+ for (const r of combinedRules) {
168
+ const key = String(r.pattern).trim().toLowerCase();
169
+ ruleMap.set(key, r);
170
+ }
171
+
172
+ const allActive = Array.from(ruleMap.values());
173
+
174
+ return {
175
+ syntactic: allActive.filter(r => r.type === 'syntactic'),
176
+ taste: allActive.filter(r => r.type === 'taste')
177
+ };
178
+ }
@@ -0,0 +1,64 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { addRule } from './adaptive-preferences.mjs';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ /**
10
+ * Loads the curated catalog of known UI/code slop patterns.
11
+ * @returns {Array<{ id: string, regex: string, message: string }>} Catalog patterns.
12
+ */
13
+ export function getSlopCatalog() {
14
+ const catalogPath = path.resolve(
15
+ __dirname,
16
+ '../../.agents/plugins/agentic-senior-core/hooks/lib/known-ui-slop-patterns.json'
17
+ );
18
+
19
+ if (!fs.existsSync(catalogPath)) {
20
+ return [];
21
+ }
22
+
23
+ try {
24
+ const content = fs.readFileSync(catalogPath, 'utf8');
25
+ const parsed = JSON.parse(content);
26
+ return parsed.patterns || [];
27
+ } catch (err) {
28
+ return [];
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Seeds selected slop pattern IDs into the target preference scope (Khroma Elicitation pattern).
34
+ * @param {Object} options
35
+ * @param {string} [options.cwd] Working directory.
36
+ * @param {'user'|'project'} [options.scope] Target scope.
37
+ * @param {Array<string>} options.selectedIds List of pattern IDs selected by the user.
38
+ * @returns {Array<Object>} List of added rule results.
39
+ */
40
+ export function bootstrapPreferences({ cwd = process.cwd(), scope = 'project', selectedIds = [] } = {}) {
41
+ const catalog = getSlopCatalog();
42
+ const results = [];
43
+
44
+ for (const id of selectedIds) {
45
+ const matched = catalog.find(item => item.id === id);
46
+ if (!matched) continue;
47
+
48
+ const res = addRule({
49
+ cwd,
50
+ scope,
51
+ rule: {
52
+ id: `bootstrap_${matched.id}`,
53
+ type: 'syntactic',
54
+ pattern: matched.regex,
55
+ reason: matched.message,
56
+ source: 'bootstrap'
57
+ }
58
+ });
59
+
60
+ results.push(res);
61
+ }
62
+
63
+ return results;
64
+ }