@ryuenn3123/agentic-senior-core 6.3.0 → 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.
- package/.agents/plugins/agentic-senior-core/hooks/constants.cjs +31 -3
- package/.agents/plugins/agentic-senior-core/hooks/dedup-gate.js +45 -28
- package/.agents/plugins/agentic-senior-core/plugin.json +1 -1
- package/.agents/plugins/agentic-senior-core/rules/agentic-senior-core.md +1 -0
- package/.agents/plugins/agentic-senior-core/skills/asc-adapter/SKILL.md +3 -1
- package/.agents/rules/agentic-senior-core.md +1 -0
- package/AGENTS.md +1 -0
- package/bin/agentic-senior-core.js +15 -8
- package/gemini-extension.json +1 -1
- package/lib/cli/commands/adapter.mjs +9 -1
- package/lib/cli/commands/git-hook-generator.mjs +273 -0
- package/lib/cli/commands/git-hook.mjs +24 -0
- package/lib/cli/commands/global.mjs +23 -1
- package/lib/cli/commands/uninstall.mjs +51 -1
- package/lib/core/adaptive-preferences.mjs +178 -0
- package/lib/core/bootstrap-wizard.mjs +64 -0
- package/lib/core/revert-detector.mjs +35 -0
- package/lib/core/rule-compiler.mjs +105 -0
- package/package.json +2 -8
- package/plugin.yaml +1 -1
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
// post-edit-enforce.js and dedup-gate.js.
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
4
3
|
|
|
5
4
|
const SOURCE_EXTENSIONS = new Set([
|
|
6
5
|
'js', 'ts', 'mjs', 'cjs', 'jsx', 'tsx',
|
|
@@ -12,10 +11,39 @@ const NEW_FILE_LINE_THRESHOLD = 50;
|
|
|
12
11
|
const SESSION_DRIFT_THRESHOLD = 4;
|
|
13
12
|
const LADDER_PULSE_INTERVAL = 3;
|
|
14
13
|
|
|
14
|
+
function loadDedupConfig(cwd = process.cwd()) {
|
|
15
|
+
const candidates = [
|
|
16
|
+
path.join(cwd, '.asc', 'dedup-config.json'),
|
|
17
|
+
path.join(cwd, '.agents', 'dedup-config.json'),
|
|
18
|
+
];
|
|
19
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
20
|
+
try {
|
|
21
|
+
if (fs.existsSync(candidates[i])) {
|
|
22
|
+
return JSON.parse(fs.readFileSync(candidates[i], 'utf8'));
|
|
23
|
+
}
|
|
24
|
+
} catch (_) {}
|
|
25
|
+
}
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getThresholds(cwd = process.cwd()) {
|
|
30
|
+
const config = loadDedupConfig(cwd);
|
|
31
|
+
return {
|
|
32
|
+
NEW_FILE_LINE_THRESHOLD: typeof config.NEW_FILE_LINE_THRESHOLD === 'number'
|
|
33
|
+
? config.NEW_FILE_LINE_THRESHOLD
|
|
34
|
+
: NEW_FILE_LINE_THRESHOLD,
|
|
35
|
+
LOC_DELTA_THRESHOLD: typeof config.LOC_DELTA_THRESHOLD === 'number'
|
|
36
|
+
? config.LOC_DELTA_THRESHOLD
|
|
37
|
+
: LOC_DELTA_THRESHOLD,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
15
41
|
module.exports = {
|
|
16
42
|
SOURCE_EXTENSIONS,
|
|
17
43
|
LOC_DELTA_THRESHOLD,
|
|
18
44
|
NEW_FILE_LINE_THRESHOLD,
|
|
19
45
|
SESSION_DRIFT_THRESHOLD,
|
|
20
46
|
LADDER_PULSE_INTERVAL,
|
|
47
|
+
getThresholds,
|
|
48
|
+
loadDedupConfig,
|
|
21
49
|
};
|
|
@@ -56,9 +56,9 @@ process.stdin.on('data', chunk => {
|
|
|
56
56
|
const ext = path.extname(filePath).slice(1);
|
|
57
57
|
if (!SOURCE_EXTENSIONS.has(ext)) { process.exit(0); return; }
|
|
58
58
|
|
|
59
|
-
if (!isQualifyingEdit(toolName, toolInput)) { process.exit(0); return; }
|
|
60
|
-
|
|
61
59
|
const config = loadDedupConfig();
|
|
60
|
+
if (!isQualifyingEdit(toolName, toolInput, config)) { process.exit(0); return; }
|
|
61
|
+
|
|
62
62
|
const scanDir = resolveScanDir(filePath, config);
|
|
63
63
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'asc-dedup-'));
|
|
64
64
|
|
|
@@ -101,50 +101,67 @@ function extractToolCallFromTranscript(transcriptPath, stepIdx) {
|
|
|
101
101
|
|
|
102
102
|
// Find the step with matching step_index that contains tool_calls
|
|
103
103
|
for (var i = lines.length - 1; i >= 0; i--) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
var
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
104
|
+
try {
|
|
105
|
+
var step = JSON.parse(lines[i]);
|
|
106
|
+
if (step.step_index !== stepIdx || !step.tool_calls) continue;
|
|
107
|
+
|
|
108
|
+
for (var j = 0; j < step.tool_calls.length; j++) {
|
|
109
|
+
var tc = step.tool_calls[j];
|
|
110
|
+
if (tc.name === 'replace_file_content' || tc.name === 'multi_replace_file_content') {
|
|
111
|
+
return {
|
|
112
|
+
toolName: 'Edit',
|
|
113
|
+
toolInput: {
|
|
114
|
+
file_path: tc.args.TargetFile || '',
|
|
115
|
+
new_string: tc.args.ReplacementContent || '',
|
|
116
|
+
old_string: tc.args.TargetContent || '',
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
} else if (tc.name === 'write_to_file') {
|
|
120
|
+
return {
|
|
121
|
+
toolName: 'Write',
|
|
122
|
+
toolInput: {
|
|
123
|
+
file_path: tc.args.TargetFile || '',
|
|
124
|
+
content: tc.args.CodeContent || '',
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (lineErr) {
|
|
130
|
+
if (process.env.ASC_DEBUG) {
|
|
131
|
+
console.error('[ASC Debug] Transcript line parse failed line ' + i + ':', lineErr.message);
|
|
126
132
|
}
|
|
133
|
+
// Skip malformed line and keep scanning
|
|
127
134
|
}
|
|
128
135
|
}
|
|
129
136
|
return null;
|
|
130
|
-
} catch (
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (process.env.ASC_DEBUG) {
|
|
139
|
+
console.error('[ASC Debug] extractToolCallFromTranscript failed:', err.message);
|
|
140
|
+
}
|
|
131
141
|
return null;
|
|
132
142
|
}
|
|
133
143
|
}
|
|
134
144
|
|
|
135
|
-
function isQualifyingEdit(toolName, toolInput) {
|
|
145
|
+
function isQualifyingEdit(toolName, toolInput, config) {
|
|
136
146
|
var isWrite = ['Write', 'write_to_file', 'write_file'].indexOf(toolName) !== -1;
|
|
137
147
|
var isEdit = ['Edit', 'replace_file_content', 'multi_replace_file_content'].indexOf(toolName) !== -1;
|
|
138
148
|
|
|
149
|
+
var newFileThreshold = (config && typeof config.NEW_FILE_LINE_THRESHOLD === 'number')
|
|
150
|
+
? config.NEW_FILE_LINE_THRESHOLD
|
|
151
|
+
: NEW_FILE_LINE_THRESHOLD;
|
|
152
|
+
var locDeltaThreshold = (config && typeof config.LOC_DELTA_THRESHOLD === 'number')
|
|
153
|
+
? config.LOC_DELTA_THRESHOLD
|
|
154
|
+
: LOC_DELTA_THRESHOLD;
|
|
155
|
+
|
|
139
156
|
if (isWrite) {
|
|
140
157
|
var content = toolInput.content || toolInput.CodeContent || '';
|
|
141
|
-
return content.split('\n').length >
|
|
158
|
+
return content.split('\n').length > newFileThreshold;
|
|
142
159
|
}
|
|
143
160
|
if (isEdit) {
|
|
144
161
|
var newStr = toolInput.new_string || toolInput.ReplacementContent || toolInput.content || '';
|
|
145
162
|
var oldStr = toolInput.old_string || toolInput.TargetContent || '';
|
|
146
163
|
var delta = newStr.split('\n').length - oldStr.split('\n').length;
|
|
147
|
-
return delta >
|
|
164
|
+
return delta > locDeltaThreshold;
|
|
148
165
|
}
|
|
149
166
|
return false;
|
|
150
167
|
}
|
|
@@ -38,6 +38,7 @@ When you pick the minimal option at step 5 or 6, and it isn't obviously trivial:
|
|
|
38
38
|
- Delete code that carries no behavior, safety, or test value.
|
|
39
39
|
- When brevity and readability conflict, readability wins.
|
|
40
40
|
- Prefer named functions over closures/inline lambdas once logic exceeds a trivial expression.
|
|
41
|
+
- Arrow function shorthand (no braces, implicit return) must not return a void-typed expression — e.g. `onClick={() => setCount(count + 1)}` or `arr.forEach(item => sideEffect(item))`. This trips `@typescript-eslint/no-confusing-void-expression` under strict TS lint configs. Not JSX-specific — applies to any callback assignment in `.js`/`.ts`/`.jsx`/`.tsx` where the shorthand body calls a void-returning function. Use braces instead: `onClick={() => { setCount(count + 1); }}`.
|
|
41
42
|
|
|
42
43
|
## Architecture
|
|
43
44
|
|
|
@@ -31,7 +31,8 @@ Adapter hosts (one file per project): Cursor, Devin Desktop, Cline, GitHub Copil
|
|
|
31
31
|
asc status # Show detected hosts
|
|
32
32
|
asc adapter --all # Generate all adapters
|
|
33
33
|
asc adapter --cursor # Generate for specific host
|
|
34
|
-
asc
|
|
34
|
+
asc install-git-hook # Install native Git pre-commit hook (recommended for all hosts)
|
|
35
|
+
asc uninstall # Remove all ASC adapter files and git hooks
|
|
35
36
|
asc uninstall --dry-run # Preview what would be removed
|
|
36
37
|
```
|
|
37
38
|
|
|
@@ -41,3 +42,4 @@ asc uninstall --dry-run # Preview what would be removed
|
|
|
41
42
|
- Cursor uses `.mdc` format with `alwaysApply: true` frontmatter.
|
|
42
43
|
- Windsurf is now Devin Desktop. Use `--devin` for the preferred path, `--windsurf` for legacy.
|
|
43
44
|
- Zed also reads `AGENTS.md` natively, so the adapter is optional.
|
|
45
|
+
- **Git Pre-Commit Hook (`asc install-git-hook`)**: Host plugin runtimes vary — adapter hosts and certain chat surfaces (e.g., Antigravity IDE / Antigravity 2.0 chat interface) do not run agent lifecycle hooks. Installing the native Git pre-commit hook ensures 100% deterministic duplicate code blocking and ESLint auto-fixing directly via Git on all hosts.
|
|
@@ -38,6 +38,7 @@ When you pick the minimal option at step 5 or 6, and it isn't obviously trivial:
|
|
|
38
38
|
- Delete code that carries no behavior, safety, or test value.
|
|
39
39
|
- When brevity and readability conflict, readability wins.
|
|
40
40
|
- Prefer named functions over closures/inline lambdas once logic exceeds a trivial expression.
|
|
41
|
+
- Arrow function shorthand (no braces, implicit return) must not return a void-typed expression — e.g. `onClick={() => setCount(count + 1)}` or `arr.forEach(item => sideEffect(item))`. This trips `@typescript-eslint/no-confusing-void-expression` under strict TS lint configs. Not JSX-specific — applies to any callback assignment in `.js`/`.ts`/`.jsx`/`.tsx` where the shorthand body calls a void-returning function. Use braces instead: `onClick={() => { setCount(count + 1); }}`.
|
|
41
42
|
|
|
42
43
|
## Architecture
|
|
43
44
|
|
package/AGENTS.md
CHANGED
|
@@ -33,6 +33,7 @@ When you pick the minimal option at step 5 or 6, and it isn't obviously trivial:
|
|
|
33
33
|
- Delete code that carries no behavior, safety, or test value.
|
|
34
34
|
- When brevity and readability conflict, readability wins.
|
|
35
35
|
- Prefer named functions over closures/inline lambdas once logic exceeds a trivial expression.
|
|
36
|
+
- Arrow function shorthand (no braces, implicit return) must not return a void-typed expression — e.g. `onClick={() => setCount(count + 1)}` or `arr.forEach(item => sideEffect(item))`. This trips `@typescript-eslint/no-confusing-void-expression` under strict TS lint configs. Not JSX-specific — applies to any callback assignment in `.js`/`.ts`/`.jsx`/`.tsx` where the shorthand body calls a void-returning function. Use braces instead: `onClick={() => { setCount(count + 1); }}`.
|
|
36
37
|
|
|
37
38
|
## Architecture
|
|
38
39
|
|
|
@@ -31,14 +31,15 @@ function printUsage() {
|
|
|
31
31
|
console.log('Adapter install (one file per project):');
|
|
32
32
|
console.log(' asc adapter --cursor --devin --cline --copilot --kiro --continue --zed --aider --kilocode --roo --openhands --windsurf --all\n');
|
|
33
33
|
console.log('Commands:');
|
|
34
|
-
console.log(' adapter
|
|
35
|
-
console.log(' global
|
|
36
|
-
console.log('
|
|
37
|
-
console.log('
|
|
38
|
-
console.log('
|
|
39
|
-
console.log('
|
|
40
|
-
console.log('
|
|
41
|
-
console.log(' --
|
|
34
|
+
console.log(' adapter Generate instruction-tier adapter files');
|
|
35
|
+
console.log(' global Install rules to user-level (global) locations');
|
|
36
|
+
console.log(' install-git-hook Install Git pre-commit hook for duplicate code enforcement');
|
|
37
|
+
console.log(' uninstall Remove ASC adapter files and git hooks from this project');
|
|
38
|
+
console.log(' clean Remove v4 per-project artifacts');
|
|
39
|
+
console.log(' status Show detected IDEs and install hints');
|
|
40
|
+
console.log(' mcp Start MCP stdio server');
|
|
41
|
+
console.log(' --version Show version');
|
|
42
|
+
console.log(' --help Show this help');
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
async function main() {
|
|
@@ -55,6 +56,12 @@ async function main() {
|
|
|
55
56
|
return;
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
if (commandArgument === 'install-git-hook' || commandArgument === 'git-hook') {
|
|
60
|
+
const { runGitHookCommand } = await import('../lib/cli/commands/git-hook.mjs');
|
|
61
|
+
await runGitHookCommand(commandArguments);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
58
65
|
if (commandArgument === 'adapter') {
|
|
59
66
|
const { runAdapterCommand } = await import('../lib/cli/commands/adapter.mjs');
|
|
60
67
|
await runAdapterCommand(commandArguments);
|
package/gemini-extension.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detects recent git file rollbacks or reverts in the workspace to trigger signal denoising.
|
|
5
|
+
* @param {Object} options
|
|
6
|
+
* @param {string} [options.cwd] Working directory.
|
|
7
|
+
* @returns {{ hasReverts: boolean, revertedFiles: Array<string>, prompt: string }} Denoising status payload.
|
|
8
|
+
*/
|
|
9
|
+
export function detectRevertedFiles({ cwd = process.cwd() } = {}) {
|
|
10
|
+
try {
|
|
11
|
+
// Check unstaged or staged diffs showing file deletions/rollbacks
|
|
12
|
+
const statusOutput = execSync('git status --porcelain', { cwd, encoding: 'utf8' });
|
|
13
|
+
const lines = statusOutput.split('\n').map(l => l.trim()).filter(Boolean);
|
|
14
|
+
|
|
15
|
+
// Filter modified or deleted files
|
|
16
|
+
const modifiedOrDeleted = lines
|
|
17
|
+
.filter(line => line.startsWith(' M') || line.startsWith(' D') || line.startsWith('D '))
|
|
18
|
+
.map(line => line.substring(3).trim());
|
|
19
|
+
|
|
20
|
+
if (modifiedOrDeleted.length === 0) {
|
|
21
|
+
return { hasReverts: false, revertedFiles: [], prompt: '' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const firstFile = modifiedOrDeleted[0];
|
|
25
|
+
const prompt = `[ASC Signal Denoising] Revert/edit detected on ${firstFile}. Was this caused by a specific UI/code slop pattern? (Run /asc-learn to log or press Enter to skip)`;
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
hasReverts: true,
|
|
29
|
+
revertedFiles: modifiedOrDeleted,
|
|
30
|
+
prompt
|
|
31
|
+
};
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return { hasReverts: false, revertedFiles: [], prompt: '' };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { resolveActiveRules } from './adaptive-preferences.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Compiles active syntactic preference rules into a standalone Node.js validator script string.
|
|
7
|
+
* @param {Object} options
|
|
8
|
+
* @param {string} [options.cwd] Working directory.
|
|
9
|
+
* @returns {string} Executable Node.js script code.
|
|
10
|
+
*/
|
|
11
|
+
export function generateValidatorScript({ cwd = process.cwd() } = {}) {
|
|
12
|
+
const { syntactic } = resolveActiveRules({ cwd });
|
|
13
|
+
|
|
14
|
+
const rulesJson = JSON.stringify(syntactic, null, 2);
|
|
15
|
+
|
|
16
|
+
return `#!/usr/bin/env node
|
|
17
|
+
// # Agentic Senior Core -- Compiled Pre-Commit Validator
|
|
18
|
+
// Auto-generated by Agentic Senior Core (ASC) Adaptive Preference Compiler
|
|
19
|
+
// DO NOT EDIT DIRECTLY. Compiled from Track A Syntactic Rules.
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const { execSync } = require('child_process');
|
|
24
|
+
|
|
25
|
+
const ACTIVE_SYNTACTIC_RULES = ${rulesJson};
|
|
26
|
+
|
|
27
|
+
function getStagedFiles() {
|
|
28
|
+
try {
|
|
29
|
+
const output = execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8' });
|
|
30
|
+
return output.split('\\n').map(f => f.trim()).filter(Boolean);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function runValidation() {
|
|
37
|
+
if (ACTIVE_SYNTACTIC_RULES.length === 0) {
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const stagedFiles = getStagedFiles();
|
|
42
|
+
if (stagedFiles.length === 0) {
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let violationFound = false;
|
|
47
|
+
|
|
48
|
+
for (const filePath of stagedFiles) {
|
|
49
|
+
if (!fs.existsSync(filePath)) continue;
|
|
50
|
+
// Skip binary / non-text files
|
|
51
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
52
|
+
if (['.png', '.jpg', '.jpeg', '.gif', '.ico', '.pdf', '.zip'].includes(ext)) continue;
|
|
53
|
+
|
|
54
|
+
let content;
|
|
55
|
+
try {
|
|
56
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
57
|
+
} catch (e) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const rule of ACTIVE_SYNTACTIC_RULES) {
|
|
62
|
+
if (!rule.pattern) continue;
|
|
63
|
+
try {
|
|
64
|
+
const regex = new RegExp(rule.pattern, 'i');
|
|
65
|
+
if (regex.test(content)) {
|
|
66
|
+
console.error(\`\\x1b[31m[ASC Rule Violation]\\x1b[0m File \${filePath} violates active syntactic rule:\`);
|
|
67
|
+
console.error(\` Pattern: \${rule.pattern}\`);
|
|
68
|
+
console.error(\` Reason: \${rule.reason || 'Banned pattern'}\`);
|
|
69
|
+
violationFound = true;
|
|
70
|
+
}
|
|
71
|
+
} catch (err) {
|
|
72
|
+
// Invalid regex ignore
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (violationFound) {
|
|
78
|
+
console.error('\\x1b[31m[ASC Gate Abort]\\x1b[0m Commit blocked by ASC compiled syntactic preference check.');
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
runValidation();
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Writes the compiled validator script to disk.
|
|
91
|
+
* @param {Object} options
|
|
92
|
+
* @param {string} [options.cwd] Working directory.
|
|
93
|
+
* @returns {string} Absolute path to compiled script.
|
|
94
|
+
*/
|
|
95
|
+
export function compileAndSaveValidator({ cwd = process.cwd() } = {}) {
|
|
96
|
+
const ascDir = path.join(cwd, '.asc', 'hooks');
|
|
97
|
+
if (!fs.existsSync(ascDir)) {
|
|
98
|
+
fs.mkdirSync(ascDir, { recursive: true });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const scriptContent = generateValidatorScript({ cwd });
|
|
102
|
+
const scriptPath = path.join(ascDir, 'pre-commit-validator.cjs');
|
|
103
|
+
fs.writeFileSync(scriptPath, scriptContent, { encoding: 'utf8', mode: 0o755 });
|
|
104
|
+
return scriptPath;
|
|
105
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryuenn3123/agentic-senior-core",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Agentic Senior Core: Universal AI coding rules and workflows. Write code like a staff engineer, not a junior.",
|
|
6
6
|
"bin": {
|
|
@@ -11,13 +11,7 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
".asc/",
|
|
13
13
|
"bin/",
|
|
14
|
-
"lib/
|
|
15
|
-
"lib/cli/commands/global.mjs",
|
|
16
|
-
"lib/cli/commands/clean.mjs",
|
|
17
|
-
"lib/cli/commands/uninstall.mjs",
|
|
18
|
-
"lib/cli/commands/status.mjs",
|
|
19
|
-
"lib/cli/commands/mcp.mjs",
|
|
20
|
-
"lib/cli/ascx/",
|
|
14
|
+
"lib/",
|
|
21
15
|
".agents/",
|
|
22
16
|
"CONVENTIONS.md",
|
|
23
17
|
"gemini-extension.json",
|
package/plugin.yaml
CHANGED