first commit
This commit is contained in:
843
文档润色流和知识库构建流/claude-scholar/hooks/hook-common.js
Normal file
843
文档润色流和知识库构建流/claude-scholar/hooks/hook-common.js
Normal file
@@ -0,0 +1,843 @@
|
||||
/**
|
||||
* Cross-platform Hook shared utility library
|
||||
* Provides cross-platform compatible shared functions for Claude Code Hooks
|
||||
*
|
||||
* @module hook-common
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
/**
|
||||
* Get Git status information
|
||||
* @param {string} cwd - Current working directory
|
||||
* @returns {Object} Git info object
|
||||
*/
|
||||
function getGitInfo(cwd) {
|
||||
try {
|
||||
// Check if this is a Git repository
|
||||
execSync('git rev-parse --git-dir', {
|
||||
cwd,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
// Get branch name
|
||||
let branch = 'unknown';
|
||||
try {
|
||||
branch = execSync('git branch --show-current', {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe'
|
||||
}).trim();
|
||||
} catch {
|
||||
branch = 'unknown';
|
||||
}
|
||||
|
||||
// Get changed files
|
||||
let changes = '';
|
||||
try {
|
||||
changes = execSync('git status --porcelain', {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe'
|
||||
});
|
||||
} catch {
|
||||
changes = '';
|
||||
}
|
||||
|
||||
const changeList = changes.trim().split('\n').filter(Boolean);
|
||||
const hasChanges = changeList.length > 0;
|
||||
|
||||
return {
|
||||
is_repo: true,
|
||||
branch,
|
||||
changes_count: changeList.length,
|
||||
has_changes: hasChanges,
|
||||
changes: changeList
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
is_repo: false,
|
||||
branch: 'unknown',
|
||||
changes_count: 0,
|
||||
has_changes: false,
|
||||
changes: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get todo item information
|
||||
* @param {string} cwd - Current working directory
|
||||
* @returns {Object} Todo item information
|
||||
*/
|
||||
function getTodoInfo(cwd) {
|
||||
const todoFiles = [
|
||||
path.join(cwd, 'docs', 'todo.md'),
|
||||
path.join(cwd, 'TODO.md'),
|
||||
path.join(cwd, '.claude', 'todos.md'),
|
||||
path.join(cwd, 'TODO'),
|
||||
path.join(cwd, 'notes', 'todo.md')
|
||||
];
|
||||
|
||||
for (const file of todoFiles) {
|
||||
if (fs.existsSync(file)) {
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const totalMatches = content.match(/^[\-\*] \[[ x]\]/gi) || [];
|
||||
const total = totalMatches.length;
|
||||
|
||||
const doneMatches = content.match(/^[\-\*] \[x\]/gi) || [];
|
||||
const done = doneMatches.length;
|
||||
|
||||
const pending = total - done;
|
||||
|
||||
return {
|
||||
found: true,
|
||||
file: path.basename(file),
|
||||
path: file,
|
||||
total,
|
||||
done,
|
||||
pending
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
found: false,
|
||||
file: null,
|
||||
path: null,
|
||||
total: 0,
|
||||
done: 0,
|
||||
pending: 0
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resolve repository root when available
|
||||
* @param {string} cwd - Current working directory
|
||||
* @returns {string} Repository root or cwd fallback
|
||||
*/
|
||||
function getRepoRoot(cwd) {
|
||||
try {
|
||||
return execSync('git rev-parse --show-toplevel', {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe'
|
||||
}).trim();
|
||||
} catch {
|
||||
return cwd;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse simple YAML frontmatter from a Markdown file.
|
||||
* Supports the project-memory binding metadata shape.
|
||||
* @param {string} filePath
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
function parseFrontmatterFile(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) return null;
|
||||
|
||||
const data = {};
|
||||
for (const line of match[1].split('\n')) {
|
||||
const parts = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
|
||||
if (!parts) continue;
|
||||
const key = parts[1];
|
||||
let value = parts[2].trim();
|
||||
|
||||
if (value === 'true') value = true;
|
||||
else if (value === 'false') value = false;
|
||||
else value = value.replace(/^['"]|['"]$/g, '');
|
||||
|
||||
data[key] = value;
|
||||
}
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the current repository is bound to Obsidian project memory
|
||||
* @param {string} cwd - Current working directory
|
||||
* @returns {Object} Binding info
|
||||
*/
|
||||
function getProjectMemoryBinding(cwd) {
|
||||
const repoRoot = getRepoRoot(cwd);
|
||||
const projectMemoryDir = path.join(repoRoot, '.claude', 'project-memory');
|
||||
const registryPath = path.join(projectMemoryDir, 'registry.yaml');
|
||||
|
||||
if (!fs.existsSync(registryPath)) {
|
||||
return {
|
||||
bound: false,
|
||||
repoRoot,
|
||||
registryPath,
|
||||
projectId: null,
|
||||
status: null,
|
||||
autoSync: false,
|
||||
vaultRoot: null,
|
||||
hubNote: null,
|
||||
memoryPath: null
|
||||
};
|
||||
}
|
||||
|
||||
let project = null;
|
||||
let memoryPath = null;
|
||||
|
||||
if (fs.existsSync(projectMemoryDir)) {
|
||||
const memoryFiles = fs.readdirSync(projectMemoryDir)
|
||||
.filter(name => name.endsWith('.md'))
|
||||
.map(name => path.join(projectMemoryDir, name));
|
||||
|
||||
for (const candidate of memoryFiles) {
|
||||
const frontmatter = parseFrontmatterFile(candidate);
|
||||
if (!frontmatter) continue;
|
||||
if (frontmatter.repo_root && (frontmatter.repo_root === repoRoot || cwd.startsWith(frontmatter.repo_root))) {
|
||||
project = frontmatter;
|
||||
memoryPath = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!memoryPath && memoryFiles.length > 0) {
|
||||
memoryPath = memoryFiles[0];
|
||||
project = parseFrontmatterFile(memoryPath) || null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bound: true,
|
||||
repoRoot,
|
||||
registryPath,
|
||||
projectId: project ? project.project_id || null : null,
|
||||
status: project ? project.status || null : null,
|
||||
autoSync: Boolean(project && project.auto_sync),
|
||||
vaultRoot: project ? project.vault_root || null : null,
|
||||
hubNote: project ? project.hub_note || null : null,
|
||||
memoryPath
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristically detect whether a directory looks like a research repo
|
||||
* @param {string} cwd - Current working directory
|
||||
* @returns {Object} Detection result
|
||||
*/
|
||||
function detectResearchProject(cwd) {
|
||||
const repoRoot = getRepoRoot(cwd);
|
||||
const markers = [
|
||||
'.git',
|
||||
'README.md',
|
||||
'docs',
|
||||
'notes',
|
||||
'plan',
|
||||
'results',
|
||||
'outputs',
|
||||
'src',
|
||||
'scripts'
|
||||
];
|
||||
|
||||
const hits = markers.filter(marker => fs.existsSync(path.join(repoRoot, marker)));
|
||||
const candidate = hits.length >= 3 || (hits.includes('.git') && hits.includes('README.md') && hits.length >= 2);
|
||||
|
||||
return {
|
||||
repoRoot,
|
||||
candidate,
|
||||
markers: hits
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a user prompt looks research-related
|
||||
* @param {string} prompt - User prompt
|
||||
* @returns {boolean} Whether the prompt looks research-related
|
||||
*/
|
||||
function promptLooksResearchRelated(prompt) {
|
||||
return /\b(obsidian|zotero|paper|papers|literature|review|experiment|results?|finding|analysis|research|plan|todo|daily|meeting|writing|draft|proposal|rebuttal|claim|method)\b|文献|实验|结果|研究|计划|待办|知识库|论文|综述|笔记|项目记忆|obsidian|zotero/i.test(prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Git change details
|
||||
* @param {string} cwd - Current working directory
|
||||
* @returns {Object} Change statistics
|
||||
*/
|
||||
function getChangesDetails(cwd) {
|
||||
try {
|
||||
let added = 0;
|
||||
let modified = 0;
|
||||
let deleted = 0;
|
||||
|
||||
// 解析 name-status 输出,每行格式: "A\tfilename" 或 "M\tfilename"
|
||||
const parseNameStatus = (output) => {
|
||||
for (const line of output.trim().split('\n').filter(Boolean)) {
|
||||
const status = line.charAt(0);
|
||||
if (status === 'A') added++;
|
||||
else if (status === 'M') modified++;
|
||||
else if (status === 'D') deleted++;
|
||||
}
|
||||
};
|
||||
|
||||
// 工作区变更(unstaged)
|
||||
const unstaged = execSync('git diff --name-status', {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe'
|
||||
});
|
||||
parseNameStatus(unstaged);
|
||||
|
||||
// 暂存区变更(staged)
|
||||
const staged = execSync('git diff --cached --name-status', {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe'
|
||||
});
|
||||
parseNameStatus(staged);
|
||||
|
||||
// 未跟踪文件算作 added
|
||||
const untracked = execSync('git status --porcelain', {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe'
|
||||
});
|
||||
for (const line of untracked.trim().split('\n').filter(Boolean)) {
|
||||
if (line.startsWith('??')) added++;
|
||||
}
|
||||
|
||||
return { added, modified, deleted };
|
||||
} catch {
|
||||
return { added: 0, modified: 0, deleted: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze changes by file type
|
||||
* @param {string} cwd - Current working directory
|
||||
* @returns {Object} File type statistics
|
||||
*/
|
||||
function analyzeChangesByType(cwd) {
|
||||
const gitInfo = getGitInfo(cwd);
|
||||
|
||||
if (!gitInfo.is_repo || !gitInfo.has_changes) {
|
||||
return {
|
||||
test_files: 0,
|
||||
docs_files: 0,
|
||||
sql_files: 0,
|
||||
config_files: 0,
|
||||
service_files: 0
|
||||
};
|
||||
}
|
||||
|
||||
// 逐行匹配文件路径(git status --porcelain 格式: "XY filename")
|
||||
const files = gitInfo.changes.map(line => line.substring(3).trim());
|
||||
|
||||
const testRegex = /(?:^|[\/\\])tests?[\/\\]|[\/\\._]test[_.]|\.test\.|_test\./i;
|
||||
const docsRegex = /\.(md|txt|rst)$/i;
|
||||
const sqlRegex = /\.sql$/i;
|
||||
const configRegex = /\.(json|yaml|yml|toml|ini|conf)$/i;
|
||||
const serviceRegex = /(service|controller)/i;
|
||||
|
||||
let testFiles = 0;
|
||||
let docsFiles = 0;
|
||||
let sqlFiles = 0;
|
||||
let configFiles = 0;
|
||||
let serviceFiles = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (testRegex.test(file)) testFiles++;
|
||||
if (docsRegex.test(file)) docsFiles++;
|
||||
if (sqlRegex.test(file)) sqlFiles++;
|
||||
if (configRegex.test(file)) configFiles++;
|
||||
if (serviceRegex.test(file)) serviceFiles++;
|
||||
}
|
||||
|
||||
return {
|
||||
test_files: testFiles,
|
||||
docs_files: docsFiles,
|
||||
sql_files: sqlFiles,
|
||||
config_files: configFiles,
|
||||
service_files: serviceFiles
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect temporary files
|
||||
* @param {string} cwd - Current working directory
|
||||
* @returns {Object} Temporary file information
|
||||
*/
|
||||
function detectTempFiles(cwd) {
|
||||
const tempFiles = new Set();
|
||||
const gitInfo = getGitInfo(cwd);
|
||||
|
||||
// Find temp files from Git untracked files
|
||||
if (gitInfo.is_repo) {
|
||||
for (const change of gitInfo.changes) {
|
||||
if (change.startsWith('??')) {
|
||||
const file = change.substring(3).trim();
|
||||
if (/plan|draft|tmp|temp|scratch/i.test(file)) {
|
||||
tempFiles.add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check known temporary directories
|
||||
const tempDirs = ['plan', 'docs/plans', '.claude/temp', 'tmp', 'temp'];
|
||||
for (const dir of tempDirs) {
|
||||
const dirPath = path.join(cwd, dir);
|
||||
if (fs.existsSync(dirPath)) {
|
||||
try {
|
||||
const files = getAllFiles(dirPath);
|
||||
for (const file of files) {
|
||||
tempFiles.add(path.relative(cwd, file));
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
files: Array.from(tempFiles),
|
||||
count: tempFiles.size
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively get all files in a directory
|
||||
* @param {string} dirPath - Directory path
|
||||
* @returns {Array<string>} List of file paths
|
||||
*/
|
||||
function getAllFiles(dirPath) {
|
||||
const files = [];
|
||||
const items = fs.readdirSync(dirPath);
|
||||
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dirPath, item);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...getAllFiles(fullPath));
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate smart recommendations
|
||||
* @param {string} cwd - Current working directory
|
||||
* @param {Object} gitInfo - Git information
|
||||
* @returns {Array<string>} List of recommendations
|
||||
*/
|
||||
function generateRecommendations(cwd, gitInfo) {
|
||||
const recommendations = [];
|
||||
|
||||
if (gitInfo.is_repo && gitInfo.has_changes) {
|
||||
const changesDetails = getChangesDetails(cwd);
|
||||
const typeAnalysis = analyzeChangesByType(cwd);
|
||||
|
||||
if (changesDetails.added > 0 || changesDetails.modified > 0) {
|
||||
recommendations.push('git add . && git commit -m "feat: xxx"');
|
||||
}
|
||||
|
||||
if (typeAnalysis.test_files > 0) {
|
||||
recommendations.push('Run test suite to verify changes');
|
||||
}
|
||||
if (typeAnalysis.docs_files > 0) {
|
||||
recommendations.push('Check documentation is in sync with code');
|
||||
}
|
||||
if (typeAnalysis.sql_files > 0) {
|
||||
recommendations.push('Update all related database scripts');
|
||||
}
|
||||
if (typeAnalysis.config_files > 0) {
|
||||
recommendations.push('Check if environment variables need updating');
|
||||
}
|
||||
if (typeAnalysis.service_files > 0) {
|
||||
recommendations.push('Update API documentation');
|
||||
}
|
||||
}
|
||||
|
||||
// Todo reminder
|
||||
const todoInfo = getTodoInfo(cwd);
|
||||
if (todoInfo.found && todoInfo.pending > 0) {
|
||||
recommendations.push(`Check todos: ${todoInfo.file} (${todoInfo.pending} items remaining)`);
|
||||
}
|
||||
|
||||
// Non-repo environment reminder
|
||||
if (!gitInfo.is_repo) {
|
||||
recommendations.push('Remember to back up important files to a git repo or cloud storage');
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of enabled plugins
|
||||
* @param {string} homeDir - User home directory
|
||||
* @returns {Array<Object>} Plugin list
|
||||
*/
|
||||
function getEnabledPlugins(homeDir) {
|
||||
const settingsFile = path.join(homeDir, '.claude', 'settings.json');
|
||||
|
||||
if (!fs.existsSync(settingsFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
||||
const enabledPlugins = settings.enabledPlugins || {};
|
||||
|
||||
const plugins = [];
|
||||
for (const [pluginId, enabled] of Object.entries(enabledPlugins)) {
|
||||
if (enabled) {
|
||||
const pluginName = pluginId.split('@')[0];
|
||||
plugins.push({
|
||||
id: pluginId,
|
||||
name: pluginName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return plugins;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available commands
|
||||
* @param {string} homeDir - User home directory
|
||||
* @returns {Array<Object>} Command list
|
||||
*/
|
||||
function getAvailableCommands(homeDir) {
|
||||
const commands = [];
|
||||
|
||||
// Only collect local commands, not plugin commands
|
||||
const localCommandsDir = path.join(homeDir, '.claude', 'commands');
|
||||
if (fs.existsSync(localCommandsDir)) {
|
||||
const commandFiles = fs.readdirSync(localCommandsDir)
|
||||
.filter(f => f.endsWith('.md'));
|
||||
|
||||
for (const cmdFile of commandFiles) {
|
||||
const cmdName = cmdFile.replace('.md', '');
|
||||
commands.push({
|
||||
plugin: 'local',
|
||||
name: cmdName,
|
||||
path: path.join(localCommandsDir, cmdFile)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get command description
|
||||
* @param {string} cmdPath - Command file path
|
||||
* @returns {string} Command description
|
||||
*/
|
||||
function getCommandDescription(cmdPath) {
|
||||
try {
|
||||
const content = fs.readFileSync(cmdPath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Try to get description from frontmatter
|
||||
let inFrontmatter = false;
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '---') {
|
||||
if (!inFrontmatter) {
|
||||
inFrontmatter = true;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inFrontmatter && line.trim().startsWith('description:')) {
|
||||
const match = line.match(/description:\s*["']?(.+?)["']?$/);
|
||||
if (match) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get from heading
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^#+\s*(.+)$/);
|
||||
if (match) {
|
||||
return match[1].trim().substring(0, 50);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect local skills
|
||||
* @param {string} homeDir - User home directory
|
||||
* @returns {Array<Object>} Skill list
|
||||
*/
|
||||
function collectLocalSkills(homeDir) {
|
||||
const skills = [];
|
||||
const skillsDir = path.join(homeDir, '.claude', 'skills');
|
||||
|
||||
if (!fs.existsSync(skillsDir)) {
|
||||
return skills;
|
||||
}
|
||||
|
||||
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name);
|
||||
|
||||
for (const skillName of skillDirs) {
|
||||
const skillFile = path.join(skillsDir, skillName, 'SKILL.md');
|
||||
let description = '';
|
||||
|
||||
if (fs.existsSync(skillFile)) {
|
||||
try {
|
||||
const content = fs.readFileSync(skillFile, 'utf8');
|
||||
const match = content.match(/description:\s*(.+)$/im);
|
||||
if (match) {
|
||||
description = match[1].trim();
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
skills.push({
|
||||
name: skillName,
|
||||
description,
|
||||
type: 'local'
|
||||
});
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect plugin skills
|
||||
* @param {string} homeDir - User home directory
|
||||
* @returns {Array<Object>} Skill list
|
||||
*/
|
||||
function collectPluginSkills(homeDir) {
|
||||
const skills = [];
|
||||
const pluginsCache = path.join(homeDir, '.claude', 'plugins', 'cache');
|
||||
|
||||
if (!fs.existsSync(pluginsCache)) {
|
||||
return skills;
|
||||
}
|
||||
|
||||
const marketplaces = fs.readdirSync(pluginsCache, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name);
|
||||
|
||||
for (const marketplace of marketplaces) {
|
||||
// Skip ai-research-skills
|
||||
if (marketplace === 'ai-research-skills') continue;
|
||||
|
||||
const marketplacePath = path.join(pluginsCache, marketplace);
|
||||
const plugins = fs.readdirSync(marketplacePath, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && !d.name.startsWith('.'))
|
||||
.map(d => d.name);
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const pluginPath = path.join(marketplacePath, plugin);
|
||||
const versions = fs.readdirSync(pluginPath, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name)
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
if (versions.length === 0) continue;
|
||||
|
||||
const latestVersion = versions[0];
|
||||
const pluginRoot = path.join(pluginPath, latestVersion);
|
||||
const skillsDir = path.join(pluginRoot, 'skills');
|
||||
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name);
|
||||
|
||||
for (const skillName of skillDirs) {
|
||||
skills.push({
|
||||
name: `${plugin}:${skillName}`,
|
||||
plugin,
|
||||
skill: skillName,
|
||||
type: 'plugin'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date and time
|
||||
* @param {Date} date - Date object
|
||||
* @returns {string} Formatted date-time string
|
||||
*/
|
||||
function formatDateTime(date = new Date()) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLAUDE.md needs updating
|
||||
* @param {string} homeDir - User home directory
|
||||
* @returns {Object} Check result
|
||||
*/
|
||||
function checkClaudeMdUpdate(homeDir) {
|
||||
const claudeMdPath = path.join(homeDir, '.claude', 'CLAUDE.md');
|
||||
const lastSyncPath = path.join(homeDir, '.claude', '.last-memory-sync');
|
||||
|
||||
// If CLAUDE.md does not exist, return immediately
|
||||
if (!fs.existsSync(claudeMdPath)) {
|
||||
return { needsUpdate: false, reason: 'CLAUDE.md not found' };
|
||||
}
|
||||
|
||||
// Get CLAUDE.md modification time
|
||||
const claudeMdMtime = fs.statSync(claudeMdPath).mtimeMs;
|
||||
|
||||
// Get last sync time (use CLAUDE.md creation time if not available)
|
||||
let lastSyncMtime = claudeMdMtime;
|
||||
if (fs.existsSync(lastSyncPath)) {
|
||||
try {
|
||||
lastSyncMtime = parseInt(fs.readFileSync(lastSyncPath, 'utf8').trim(), 10);
|
||||
} catch {
|
||||
lastSyncMtime = claudeMdMtime;
|
||||
}
|
||||
}
|
||||
|
||||
const referenceTime = Math.max(claudeMdMtime, lastSyncMtime);
|
||||
|
||||
// Define source file directories to monitor
|
||||
const sourceDirs = [
|
||||
{ dir: path.join(homeDir, '.claude', 'skills'), pattern: /SKILL\.md$/, type: 'skill' },
|
||||
{ dir: path.join(homeDir, '.claude', 'commands'), pattern: /\.md$/, type: 'command' },
|
||||
{ dir: path.join(homeDir, '.claude', 'agents'), pattern: /\.md$/, type: 'agent' },
|
||||
{ dir: path.join(homeDir, '.claude', 'hooks'), pattern: /\.(js|json)$/, type: 'hook' }
|
||||
];
|
||||
|
||||
const changedFiles = [];
|
||||
let totalSkills = 0;
|
||||
let totalCommands = 0;
|
||||
let totalAgents = 0;
|
||||
let totalHooks = 0;
|
||||
|
||||
// Scan each source directory
|
||||
for (const { dir, pattern, type } of sourceDirs) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
|
||||
const files = getAllFiles(dir).filter(f => pattern.test(f));
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const mtime = fs.statSync(file).mtimeMs;
|
||||
|
||||
// Count totals
|
||||
if (type === 'skill') totalSkills++;
|
||||
else if (type === 'command') totalCommands++;
|
||||
else if (type === 'agent') totalAgents++;
|
||||
else if (type === 'hook') totalHooks++;
|
||||
|
||||
// Check if newer than reference time
|
||||
if (mtime > referenceTime) {
|
||||
changedFiles.push({
|
||||
path: file,
|
||||
type,
|
||||
relativePath: path.relative(homeDir, file),
|
||||
mtime: new Date(mtime).toLocaleString('en-US')
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore inaccessible files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
needsUpdate: changedFiles.length > 0,
|
||||
changedFiles,
|
||||
stats: {
|
||||
skills: totalSkills,
|
||||
commands: totalCommands,
|
||||
agents: totalAgents,
|
||||
hooks: totalHooks
|
||||
},
|
||||
claudeMdMtime: new Date(claudeMdMtime).toLocaleString('en-US'),
|
||||
lastSyncMtime: new Date(lastSyncMtime).toLocaleString('en-US')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sync timestamp
|
||||
* @param {string} homeDir - User home directory
|
||||
*/
|
||||
function updateSyncTimestamp(homeDir) {
|
||||
const lastSyncPath = path.join(homeDir, '.claude', '.last-memory-sync');
|
||||
try {
|
||||
fs.writeFileSync(lastSyncPath, Date.now().toString(), 'utf8');
|
||||
} catch {
|
||||
// Ignore write errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary file
|
||||
* @param {string} prefix - Filename prefix
|
||||
* @returns {string} Temporary file path
|
||||
*/
|
||||
function createTempFile(prefix = 'claude-temp') {
|
||||
const os = require('os');
|
||||
const tmpDir = os.tmpdir();
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 8);
|
||||
return path.join(tmpDir, `${prefix}-${randomSuffix}.tmp`);
|
||||
}
|
||||
|
||||
// Export all functions
|
||||
module.exports = {
|
||||
getGitInfo,
|
||||
getTodoInfo,
|
||||
getRepoRoot,
|
||||
getProjectMemoryBinding,
|
||||
detectResearchProject,
|
||||
promptLooksResearchRelated,
|
||||
getChangesDetails,
|
||||
analyzeChangesByType,
|
||||
detectTempFiles,
|
||||
generateRecommendations,
|
||||
getEnabledPlugins,
|
||||
getAvailableCommands,
|
||||
getCommandDescription,
|
||||
collectLocalSkills,
|
||||
collectPluginSkills,
|
||||
formatDateTime,
|
||||
createTempFile,
|
||||
getAllFiles,
|
||||
checkClaudeMdUpdate,
|
||||
updateSyncTimestamp
|
||||
};
|
||||
65
文档润色流和知识库构建流/claude-scholar/hooks/hooks.json
Normal file
65
文档润色流和知识库构建流/claude-scholar/hooks/hooks.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"description": "Oh My Claude 自动化工作流钩子 - 跨平台 JavaScript 实现",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/security-guard.js",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session-summary.js",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session-start.js",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/stop-summary.js",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/skill-forced-eval.js",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
169
文档润色流和知识库构建流/claude-scholar/hooks/security-guard.js
Normal file
169
文档润色流和知识库构建流/claude-scholar/hooks/security-guard.js
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PreToolUse Hook: Security guard layer (cross-platform version)
|
||||
*
|
||||
* Event: PreToolUse
|
||||
* Two-tier security: Block (exit 2) | Confirm (exit 0 + ask user)
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const common = require('./hook-common');
|
||||
|
||||
// Read stdin input
|
||||
let input = {};
|
||||
try {
|
||||
const stdinData = require('fs').readFileSync(0, 'utf8');
|
||||
if (stdinData.trim()) {
|
||||
input = JSON.parse(stdinData);
|
||||
}
|
||||
} catch {
|
||||
// Use default empty object
|
||||
}
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const cwd = input.cwd || process.cwd();
|
||||
|
||||
let decision = 'allow';
|
||||
let reason = '';
|
||||
let confirmLabel = '';
|
||||
|
||||
function isPathInside(basePath, targetPath) {
|
||||
const relative = path.relative(basePath, targetPath);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
// === Bash command security check ===
|
||||
if (toolName === 'Bash') {
|
||||
const command = input.tool_input?.command || '';
|
||||
|
||||
// Tier 1: Block — catastrophic, no recovery
|
||||
const blockPatterns = [
|
||||
/rm\s+-rf\s+\/(\s|$)/, // rm -rf /
|
||||
/rm\s+--no-preserve-root/, // rm --no-preserve-root
|
||||
/dd\s+if=\/dev\/(zero|random)/, // dd from /dev/zero or /dev/random
|
||||
/>\s*\/dev\/(sd|nvme|vda)/, // Write to block devices
|
||||
/mkfs\./, // Format filesystem
|
||||
/rm\s+-rf?\s+\/(etc|usr|bin|sbin)(\/|\s|$)/, // Remove system dirs
|
||||
/rm\s+-rf\s+\/home\/[^\/\s]*\/?(\s|$)/, // Remove user home dir
|
||||
/rm\s+-rf\s+\/Users\/[^\/\s]*\/?(\s|$)/, // Remove macOS user dir
|
||||
];
|
||||
|
||||
for (const pattern of blockPatterns) {
|
||||
if (pattern.test(command)) {
|
||||
decision = 'deny';
|
||||
reason = 'Catastrophic command detected';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: Confirm — dangerous but sometimes legitimate
|
||||
if (decision === 'allow') {
|
||||
const confirmPatterns = [
|
||||
{ pattern: /git\s+push\s+.*(-f|--force)/, label: 'git push --force (overwrites remote history)' },
|
||||
{ pattern: /git\s+reset\s+--hard/, label: 'git reset --hard (discards all uncommitted changes)' },
|
||||
{ pattern: /git\s+clean\s+-[a-z]*f/, label: 'git clean -f (permanently deletes untracked files)' },
|
||||
{ pattern: /git\s+(checkout|restore)\s+\./, label: 'git checkout/restore . (discards all working tree changes)' },
|
||||
{ pattern: /rm\s+-[rf]/, label: 'rm -rf (recursive/force delete)' },
|
||||
{ pattern: /chmod\s+(-R\s+)?777/, label: 'chmod 777 (world-writable permissions)' },
|
||||
{ pattern: /npm\s+publish/, label: 'npm publish (publishes package to registry)' },
|
||||
{ pattern: /pip\s+upload|twine\s+upload/, label: 'pip/twine upload (publishes package to PyPI)' },
|
||||
{ pattern: /docker\s+system\s+prune/, label: 'docker system prune (removes all unused resources)' },
|
||||
{ pattern: /DROP\s+(DATABASE|TABLE)/i, label: 'SQL DROP (destroys database/table)' },
|
||||
{ pattern: /DELETE\s+FROM\s+(?!.*WHERE)/i, label: 'DELETE without WHERE (deletes all rows)' },
|
||||
{ pattern: /UPDATE\s+\S+\s+SET\s+(?!.*WHERE)/i, label: 'UPDATE without WHERE (updates all rows)' },
|
||||
{ pattern: /TRUNCATE\s+TABLE/i, label: 'SQL TRUNCATE (empties entire table)' },
|
||||
];
|
||||
|
||||
for (const { pattern, label } of confirmPatterns) {
|
||||
if (pattern.test(command)) {
|
||||
confirmLabel = label;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === File write security check ===
|
||||
} else if (toolName === 'Write' || toolName === 'Edit') {
|
||||
const filePath = input.tool_input?.file_path || '';
|
||||
const homeDir = os.homedir();
|
||||
const repoRoot = common.getRepoRoot(cwd);
|
||||
const resolved = path.resolve(cwd, filePath);
|
||||
|
||||
// Tier 1: Block — system paths
|
||||
const sensitivePaths = [
|
||||
'/etc/', '/usr/bin/', '/usr/sbin/',
|
||||
'/bin/', '/sbin/', '/System/',
|
||||
'/dev/', '/proc/', '/sys/'
|
||||
];
|
||||
|
||||
for (const sp of sensitivePaths) {
|
||||
if (filePath.startsWith(sp)) {
|
||||
decision = 'deny';
|
||||
reason = `Writing to system path denied: ${sp}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Block — outside repo and home
|
||||
if (decision === 'allow') {
|
||||
const insideRepo = isPathInside(repoRoot, resolved);
|
||||
const insideHome = isPathInside(homeDir, resolved);
|
||||
|
||||
if (!insideRepo && !insideHome) {
|
||||
decision = 'deny';
|
||||
reason = 'Path traversal attack detected: resolved path is outside the repository and home directory';
|
||||
} else if (!insideRepo && insideHome) {
|
||||
const relativeHomePath = path.relative(homeDir, resolved) || path.basename(resolved);
|
||||
confirmLabel = `home directory path outside repo (~/${relativeHomePath})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: Confirm — sensitive files
|
||||
if (decision === 'allow') {
|
||||
const fileName = path.basename(filePath);
|
||||
|
||||
if (fileName.startsWith('.env')) {
|
||||
confirmLabel = `.env file (${fileName})`;
|
||||
}
|
||||
|
||||
if (!confirmLabel) {
|
||||
const sensitiveFiles = ['credentials.json', 'key.pem', 'key.json', 'id_rsa'];
|
||||
for (const sf of sensitiveFiles) {
|
||||
if (fileName === sf) {
|
||||
confirmLabel = `sensitive file (${sf})`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!confirmLabel) {
|
||||
const sensitivePathPatterns = ['.aws/credentials', '.npmrc'];
|
||||
for (const sp of sensitivePathPatterns) {
|
||||
if (filePath.includes(sp)) {
|
||||
confirmLabel = `sensitive path (${sp})`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Build output ===
|
||||
if (decision === 'deny') {
|
||||
const errorOutput = {
|
||||
hookSpecificOutput: { permissionDecision: 'deny' },
|
||||
systemMessage: `🛑 Blocked: ${reason}\n\nTo perform this operation, run it manually in the terminal.`
|
||||
};
|
||||
console.error(JSON.stringify(errorOutput));
|
||||
process.exit(2);
|
||||
} else {
|
||||
const result = { continue: true };
|
||||
|
||||
if (confirmLabel) {
|
||||
result.systemMessage = `⚠️ CONFIRM REQUIRED: ${confirmLabel}\n\nYou MUST ask the user for explicit confirmation before executing this operation. Do NOT proceed without user approval.`;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result));
|
||||
process.exit(0);
|
||||
}
|
||||
185
文档润色流和知识库构建流/claude-scholar/hooks/session-start.js
Normal file
185
文档润色流和知识库构建流/claude-scholar/hooks/session-start.js
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* SessionStart Hook: Display project status (cross-platform version)
|
||||
*
|
||||
* Event: SessionStart
|
||||
* Function: Display project status, Git info, todos, plugins, and commands at session start
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
|
||||
// Import shared utility library
|
||||
const common = require('./hook-common');
|
||||
|
||||
// Import package manager detection
|
||||
const { getPackageManager, getSelectionPrompt } = require('../scripts/lib/package-manager');
|
||||
|
||||
// Read stdin input
|
||||
let input = {};
|
||||
try {
|
||||
const stdinData = require('fs').readFileSync(0, 'utf8');
|
||||
if (stdinData.trim()) {
|
||||
input = JSON.parse(stdinData);
|
||||
}
|
||||
} catch {
|
||||
// Use default empty object
|
||||
}
|
||||
|
||||
const cwd = input.cwd || process.cwd();
|
||||
const projectName = path.basename(cwd);
|
||||
const homeDir = os.homedir();
|
||||
const binding = common.getProjectMemoryBinding(cwd);
|
||||
const researchCandidate = common.detectResearchProject(cwd);
|
||||
|
||||
// Build output
|
||||
let output = '';
|
||||
|
||||
// Session start info
|
||||
output += `🚀 ${projectName} Session started\n`;
|
||||
output += `▸ Time: ${common.formatDateTime()}\n`;
|
||||
output += `▸ Directory: ${cwd}\n\n`;
|
||||
|
||||
// Git status
|
||||
const gitInfo = common.getGitInfo(cwd);
|
||||
|
||||
if (gitInfo.is_repo) {
|
||||
output += `▸ Git branch: ${gitInfo.branch}\n\n`;
|
||||
|
||||
if (gitInfo.has_changes) {
|
||||
output += `⚠️ Uncommitted changes (${gitInfo.changes_count} files):\n`;
|
||||
|
||||
// Show change list (up to 5)
|
||||
const statusIcons = {
|
||||
'M': '📝', // Modified
|
||||
'A': '➕', // Added
|
||||
'D': '❌', // Deleted
|
||||
'R': '🔄', // Renamed
|
||||
'??': '❓' // Untracked
|
||||
};
|
||||
|
||||
for (let i = 0; i < Math.min(gitInfo.changes.length, 5); i++) {
|
||||
const change = gitInfo.changes[i];
|
||||
const status = change.substring(0, 2).trim();
|
||||
const file = change.substring(3).trim();
|
||||
|
||||
const icon = statusIcons[status] || '•';
|
||||
output += ` ${icon} ${file}\n`;
|
||||
}
|
||||
|
||||
if (gitInfo.changes_count > 5) {
|
||||
output += ` ... (${gitInfo.changes_count - 5} more files)\n`;
|
||||
}
|
||||
} else {
|
||||
output += `✅ Working directory clean\n`;
|
||||
}
|
||||
output += '\n';
|
||||
} else {
|
||||
output += `▸ Git: Not a repository\n\n`;
|
||||
}
|
||||
|
||||
if (binding.bound) {
|
||||
output += '🧠 Obsidian project KB: bound\n';
|
||||
output += ` - Project: ${binding.projectId || 'unknown'}\n`;
|
||||
output += ` - Status: ${binding.status || 'unknown'}\n`;
|
||||
output += ` - Auto-sync: ${binding.autoSync ? 'on' : 'off'}\n`;
|
||||
if (binding.vaultRoot) {
|
||||
output += ` - Vault root: ${binding.vaultRoot}\n`;
|
||||
}
|
||||
output += ' - Suggested commands: /kb-status, /kb-sync, /kb-lint\n\n';
|
||||
} else if (researchCandidate.candidate) {
|
||||
output += '🧠 Obsidian project KB: research repo candidate\n';
|
||||
output += ` - Detected markers: ${researchCandidate.markers.join(', ')}\n`;
|
||||
output += ' - Suggested command: /kb-init\n\n';
|
||||
}
|
||||
|
||||
// Package manager detection
|
||||
try {
|
||||
const pm = getPackageManager();
|
||||
output += `📦 Package manager: ${pm.name} (${pm.source})\n`;
|
||||
|
||||
// If detected via fallback, suggest setup
|
||||
if (pm.source === 'fallback' || pm.source === 'default') {
|
||||
output += `💡 Run /setup-pm to configure preferred package manager\n`;
|
||||
}
|
||||
} catch (err) {
|
||||
// Package manager detection failed, silently ignore
|
||||
}
|
||||
|
||||
output += '\n';
|
||||
|
||||
// Todos
|
||||
output += `📋 Todos:\n`;
|
||||
const todoInfo = common.getTodoInfo(cwd);
|
||||
|
||||
if (todoInfo.found) {
|
||||
output += ` - ${todoInfo.pending} pending / ${todoInfo.done} completed\n`;
|
||||
|
||||
// Show top 5 pending items
|
||||
if (fs.existsSync(todoInfo.path)) {
|
||||
try {
|
||||
const content = fs.readFileSync(todoInfo.path, 'utf8');
|
||||
const pendingItems = content.match(/^[\-\*] \[ \].+$/gm) || [];
|
||||
|
||||
if (pendingItems.length > 0) {
|
||||
output += `\n Recent todos:\n`;
|
||||
for (let i = 0; i < Math.min(5, pendingItems.length); i++) {
|
||||
const item = pendingItems[i].replace(/^[\-\*] \[ \]\s*/, '').substring(0, 60);
|
||||
output += ` - ${item}\n`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output += ` No todo file found (TODO.md, docs/todo.md etc)\n`;
|
||||
}
|
||||
|
||||
output += '\n';
|
||||
|
||||
// Enabled plugins
|
||||
output += `🔌 Enabled plugins:\n`;
|
||||
const enabledPlugins = common.getEnabledPlugins(homeDir);
|
||||
|
||||
if (enabledPlugins.length > 0) {
|
||||
for (let i = 0; i < Math.min(5, enabledPlugins.length); i++) {
|
||||
output += ` - ${enabledPlugins[i].name}\n`;
|
||||
}
|
||||
if (enabledPlugins.length > 5) {
|
||||
output += ` ... and ${enabledPlugins.length - 5} more plugins\n`;
|
||||
}
|
||||
} else {
|
||||
output += ` None\n`;
|
||||
}
|
||||
|
||||
output += '\n';
|
||||
|
||||
// Available commands
|
||||
output += `💡 Available commands:\n`;
|
||||
const availableCommands = common.getAvailableCommands(homeDir);
|
||||
|
||||
if (availableCommands.length > 0) {
|
||||
for (const cmd of availableCommands.slice(0, 5)) {
|
||||
const description = common.getCommandDescription(cmd.path) || `${cmd.plugin} command`;
|
||||
const truncatedDesc = description.length > 40 ? description.substring(0, 40) + '...' : description;
|
||||
output += ` /${cmd.name.padEnd(20)} ${truncatedDesc}\n`;
|
||||
}
|
||||
|
||||
if (availableCommands.length > 5) {
|
||||
output += ` ... and ${availableCommands.length - 5} more commands, use /help to list all\n`;
|
||||
}
|
||||
} else {
|
||||
output += ` No commands found\n`;
|
||||
}
|
||||
|
||||
// Output JSON
|
||||
const result = {
|
||||
continue: true,
|
||||
systemMessage: output
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(result));
|
||||
|
||||
process.exit(0);
|
||||
240
文档润色流和知识库构建流/claude-scholar/hooks/session-summary.js
Normal file
240
文档润色流和知识库构建流/claude-scholar/hooks/session-summary.js
Normal file
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* SessionEnd Hook: Work Log + Smart Suggestions (Cross-platform)
|
||||
*
|
||||
* Event: SessionEnd
|
||||
* Purpose: Create work log, record changes and generate smart suggestions
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const common = require('./hook-common');
|
||||
|
||||
// Read stdin input
|
||||
let input = {};
|
||||
try {
|
||||
const stdinData = require('fs').readFileSync(0, 'utf8');
|
||||
if (stdinData.trim()) {
|
||||
input = JSON.parse(stdinData);
|
||||
}
|
||||
} catch {
|
||||
// Use default empty object
|
||||
}
|
||||
|
||||
const cwd = input.cwd || process.cwd();
|
||||
const sessionId = input.session_id || 'unknown';
|
||||
const transcriptPath = input.transcript_path || '';
|
||||
const binding = common.getProjectMemoryBinding(cwd);
|
||||
const MAX_LOGGED_GIT_CHANGES = 20;
|
||||
const MAX_LOGGED_MEMORY_CHANGES = 5;
|
||||
|
||||
// Create work log directory
|
||||
const logDir = path.join(cwd, '.claude', 'logs');
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
|
||||
// Generate log filename
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().split('T')[0].replace(/-/g, '');
|
||||
const logFile = path.join(logDir, `session-${dateStr}-${sessionId.substring(0, 8)}.md`);
|
||||
|
||||
// Get project info
|
||||
const projectName = path.basename(cwd);
|
||||
|
||||
// Build log content
|
||||
let logContent = '';
|
||||
|
||||
logContent += `# 📝 Work Log - ${projectName}\n`;
|
||||
logContent += `\n`;
|
||||
logContent += `**Session ID**: ${sessionId}\n`;
|
||||
logContent += `**Time**: ${common.formatDateTime(now)}\n`;
|
||||
logContent += `**Directory**: ${cwd}\n`;
|
||||
logContent += `\n`;
|
||||
|
||||
// Git change statistics
|
||||
logContent += `## 📊 Session Changes\n`;
|
||||
const gitInfo = common.getGitInfo(cwd);
|
||||
const changesDetails = gitInfo.is_repo ? common.getChangesDetails(cwd) : { added: 0, modified: 0, deleted: 0 };
|
||||
|
||||
if (gitInfo.is_repo) {
|
||||
logContent += `**Branch**: ${gitInfo.branch}\n`;
|
||||
logContent += `\n`;
|
||||
const shownGitChanges = gitInfo.changes.slice(0, MAX_LOGGED_GIT_CHANGES);
|
||||
logContent += '```\n';
|
||||
|
||||
if (gitInfo.has_changes) {
|
||||
for (const change of shownGitChanges) {
|
||||
logContent += `${change}\n`;
|
||||
}
|
||||
if (gitInfo.changes.length > MAX_LOGGED_GIT_CHANGES) {
|
||||
logContent += `... (${gitInfo.changes.length - MAX_LOGGED_GIT_CHANGES} more changes omitted)\n`;
|
||||
}
|
||||
} else {
|
||||
logContent += 'No changes\n';
|
||||
}
|
||||
|
||||
logContent += '```\n';
|
||||
|
||||
// Change statistics
|
||||
logContent += `\n`;
|
||||
logContent += '| Type | Count |\n';
|
||||
logContent += '|------|------|\n';
|
||||
logContent += `| Added | ${changesDetails.added} |\n`;
|
||||
logContent += `| Modified | ${changesDetails.modified} |\n`;
|
||||
logContent += `| Deleted | ${changesDetails.deleted} |\n`;
|
||||
} else {
|
||||
logContent += 'Not a Git repository\n';
|
||||
}
|
||||
|
||||
logContent += `\n`;
|
||||
|
||||
if (binding.bound) {
|
||||
logContent += `## 🧠 Obsidian Project Memory\n`;
|
||||
logContent += `\n`;
|
||||
logContent += `- Project: ${binding.projectId || 'unknown'}\n`;
|
||||
logContent += `- Status: ${binding.status || 'unknown'}\n`;
|
||||
logContent += `- Auto-sync: ${binding.autoSync ? 'on' : 'off'}\n`;
|
||||
if (binding.vaultRoot) {
|
||||
logContent += `- Vault root: ${binding.vaultRoot}\n`;
|
||||
}
|
||||
logContent += `- Minimum write-back to verify after research-state turns:\n`;
|
||||
logContent += ` - Daily/YYYY-MM-DD.md\n`;
|
||||
logContent += ` - ${binding.memoryPath || '.claude/project-memory/<project_id>.md'}\n`;
|
||||
logContent += ` - 00-Hub.md (only when top-level project status changes)\n`;
|
||||
logContent += `\n`;
|
||||
}
|
||||
|
||||
// Read transcript to extract key operations (if available)
|
||||
if (transcriptPath && fs.existsSync(transcriptPath)) {
|
||||
try {
|
||||
const transcript = fs.readFileSync(transcriptPath, 'utf8');
|
||||
const toolMatches = transcript.match(/Tool used: [A-Z][a-z]*/g) || [];
|
||||
|
||||
if (toolMatches.length > 0) {
|
||||
// Count tool usage
|
||||
const toolCounts = {};
|
||||
for (const match of toolMatches) {
|
||||
const tool = match.replace('Tool used: ', '');
|
||||
toolCounts[tool] = (toolCounts[tool] || 0) + 1;
|
||||
}
|
||||
|
||||
// Sort and take top 10
|
||||
const sortedTools = Object.entries(toolCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10);
|
||||
|
||||
if (sortedTools.length > 0) {
|
||||
logContent += `## 🔧 Key Operations\n`;
|
||||
logContent += `\n`;
|
||||
|
||||
for (const [tool, count] of sortedTools) {
|
||||
logContent += `| ${tool} | ${count} times |\n`;
|
||||
}
|
||||
|
||||
logContent += `\n`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Next steps
|
||||
logContent += `## 🎯 Next Steps\n`;
|
||||
logContent += `\n`;
|
||||
|
||||
if (gitInfo.has_changes) {
|
||||
logContent += `- ⚠️ Uncommitted changes detected (${gitInfo.changes_count} files)\n`;
|
||||
} else {
|
||||
logContent += '- ✅ Working directory clean\n';
|
||||
}
|
||||
|
||||
// Todo reminders
|
||||
const todoInfo = common.getTodoInfo(cwd);
|
||||
if (todoInfo.found) {
|
||||
logContent += `- Update todos: ${todoInfo.file} (${todoInfo.pending} pending)\n`;
|
||||
}
|
||||
|
||||
// CLAUDE.md memory update check
|
||||
const homeDir = os.homedir();
|
||||
const claudeMdCheck = common.checkClaudeMdUpdate(homeDir);
|
||||
|
||||
if (claudeMdCheck.needsUpdate) {
|
||||
logContent += `- ⚠️ **CLAUDE.md memory needs updating** (${claudeMdCheck.changedFiles.length} source files changed)\n`;
|
||||
logContent += ` Run "/update-memory" to sync latest memory\n`;
|
||||
|
||||
// Record change details to log
|
||||
logContent += `\n`;
|
||||
logContent += `### CLAUDE.md Change Details\n`;
|
||||
logContent += `\n`;
|
||||
logContent += `| Type | File | Modified |\n`;
|
||||
logContent += `|------|------|----------|\n`;
|
||||
for (const file of claudeMdCheck.changedFiles.slice(0, MAX_LOGGED_MEMORY_CHANGES)) {
|
||||
logContent += `| ${file.type} | ${file.relativePath} | ${file.mtime} |\n`;
|
||||
}
|
||||
if (claudeMdCheck.changedFiles.length > MAX_LOGGED_MEMORY_CHANGES) {
|
||||
logContent += `| ... | ${claudeMdCheck.changedFiles.length - MAX_LOGGED_MEMORY_CHANGES} more files omitted | ... |\n`;
|
||||
}
|
||||
} else {
|
||||
logContent += `- ✅ CLAUDE.md memory is up to date\n`;
|
||||
}
|
||||
|
||||
logContent += '- View context snapshot: `cat .claude/session-context-*.md`\n';
|
||||
logContent += `\n`;
|
||||
|
||||
// Write log file
|
||||
fs.writeFileSync(logFile, logContent, 'utf8');
|
||||
|
||||
// Clean up old log files (older than 30 days)
|
||||
try {
|
||||
const maxAge = 30 * 24 * 60 * 60 * 1000;
|
||||
const files = fs.readdirSync(logDir).filter(f => f.startsWith('session-') && f.endsWith('.md'));
|
||||
for (const file of files) {
|
||||
const filePath = path.join(logDir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
if (now.getTime() - stat.mtimeMs > maxAge) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Log cleanup failure should not affect main flow
|
||||
}
|
||||
|
||||
// Build message to display to user
|
||||
let displayMsg = '\n---\n';
|
||||
displayMsg += '✅ **Session ended** | Work log saved\n\n';
|
||||
displayMsg += '**Changes**: ';
|
||||
|
||||
if (gitInfo.is_repo) {
|
||||
if (gitInfo.has_changes) {
|
||||
displayMsg += `${gitInfo.changes_count} files\n\n`;
|
||||
displayMsg += '**Suggested actions**:\n';
|
||||
displayMsg += `- View log: cat .claude/logs/${path.basename(logFile)}\n`;
|
||||
displayMsg += '- Commit code: git add . && git commit -m "feat: xxx"\n';
|
||||
if (binding.bound) {
|
||||
displayMsg += '- Verify bound Obsidian updates: Daily/YYYY-MM-DD.md and .claude/project-memory/<project_id>.md; touch 00-Hub.md only when top-level project status changes\n';
|
||||
}
|
||||
} else {
|
||||
displayMsg += 'None\n\nWorking directory clean ✅\n';
|
||||
}
|
||||
} else {
|
||||
displayMsg += 'Not a Git repository\n';
|
||||
}
|
||||
|
||||
// Add CLAUDE.md update reminder to display message
|
||||
if (claudeMdCheck.needsUpdate) {
|
||||
displayMsg += '\n**⚠️ CLAUDE.md memory needs updating**\n';
|
||||
displayMsg += `- ${claudeMdCheck.changedFiles.length} source files changed\n`;
|
||||
displayMsg += '- Run `/update-memory` to sync latest memory\n';
|
||||
}
|
||||
|
||||
displayMsg += '\n---';
|
||||
|
||||
const result = {
|
||||
continue: true,
|
||||
systemMessage: displayMsg
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(result));
|
||||
|
||||
process.exit(0);
|
||||
235
文档润色流和知识库构建流/claude-scholar/hooks/skill-forced-eval.js
Normal file
235
文档润色流和知识库构建流/claude-scholar/hooks/skill-forced-eval.js
Normal file
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* UserPromptSubmit Hook: Forced skill activation flow (cross-platform version)
|
||||
*
|
||||
* Event: UserPromptSubmit
|
||||
* Function: Force AI to evaluate available skills and begin implementation after activation
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const common = require('./hook-common');
|
||||
|
||||
// Read stdin input
|
||||
let input = {};
|
||||
try {
|
||||
const stdinData = require('fs').readFileSync(0, 'utf8');
|
||||
if (stdinData.trim()) {
|
||||
input = JSON.parse(stdinData);
|
||||
}
|
||||
} catch {
|
||||
// Use default empty object
|
||||
}
|
||||
|
||||
const userPrompt = input.user_prompt || '';
|
||||
const cwd = input.cwd || process.cwd();
|
||||
|
||||
// Check if it is a slash command (escape)
|
||||
if (userPrompt.startsWith('/')) {
|
||||
// Distinguish commands from paths:
|
||||
// - Commands: /commit, /update-github (no second slash after the first)
|
||||
// - Paths: /Users/xxx, /path/to/file (contains path separators)
|
||||
const rest = userPrompt.substring(1);
|
||||
if (rest.includes('/')) {
|
||||
// This is a path, continue with skill scanning
|
||||
} else {
|
||||
// This is a command, skip skill evaluation
|
||||
console.log(JSON.stringify({ continue: true }));
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
const homeDir = os.homedir();
|
||||
|
||||
// Dynamically collect skill list
|
||||
function collectSkills() {
|
||||
const skills = [];
|
||||
const skillsDir = path.join(homeDir, '.claude', 'skills');
|
||||
|
||||
// 1. Collect local skills
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name);
|
||||
|
||||
for (const skillName of skillDirs) {
|
||||
skills.push(skillName);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Collect plugin skills
|
||||
const pluginsCache = path.join(homeDir, '.claude', 'plugins', 'cache');
|
||||
|
||||
if (fs.existsSync(pluginsCache)) {
|
||||
const marketplaces = fs.readdirSync(pluginsCache, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name);
|
||||
|
||||
for (const marketplace of marketplaces) {
|
||||
const marketplacePath = path.join(pluginsCache, marketplace);
|
||||
const plugins = fs.readdirSync(marketplacePath, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && !d.name.startsWith('.'))
|
||||
.map(d => d.name);
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const pluginPath = path.join(marketplacePath, plugin);
|
||||
const versions = fs.readdirSync(pluginPath, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name)
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
if (versions.length > 0) {
|
||||
const latestVersion = versions[0];
|
||||
const skillsDirPath = path.join(pluginPath, latestVersion, 'skills');
|
||||
|
||||
if (fs.existsSync(skillsDirPath)) {
|
||||
const skillDirs = fs.readdirSync(skillsDirPath, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name);
|
||||
|
||||
for (const skillName of skillDirs) {
|
||||
skills.push(`${plugin}:${skillName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
return [...new Set(skills)].sort();
|
||||
}
|
||||
|
||||
// Categorize skills into groups
|
||||
function categorizeSkills(skills) {
|
||||
const categories = {
|
||||
'Research & Writing': /research|paper|writing|citation|review-response|rebuttal|post-acceptance|doc-coauthoring|latex|daily-paper|ml-paper|results-analysis|results-report|brainstorm/,
|
||||
'Development': /coding|git|code-review|bug|architecture|verification|tdd|uv-package|webapp-testing|kaggle|driven-development|development-branch|planning|dispatching|executing|using-superpowers/,
|
||||
'Plugin Dev': /skill-|command-|hook-|mcp-|agent-identifier|plugin-structure/,
|
||||
'Design & UI': /frontend|ui-ux|web-design|canvas|brand|theme|algorithmic-art|slack-gif|figma/,
|
||||
'Documents': /docx|xlsx|pptx|pdf|internal-comms|web-artifacts/,
|
||||
};
|
||||
|
||||
const grouped = {};
|
||||
for (const cat of Object.keys(categories)) {
|
||||
grouped[cat] = [];
|
||||
}
|
||||
grouped['Other'] = [];
|
||||
|
||||
for (const skill of skills) {
|
||||
let matched = false;
|
||||
for (const [cat, regex] of Object.entries(categories)) {
|
||||
if (regex.test(skill)) {
|
||||
grouped[cat].push(skill);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
grouped['Other'].push(skill);
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
// Keyword-to-skill mapping for pre-matching
|
||||
// Note: \b doesn't work with CJK characters, so we use separate patterns
|
||||
// English keywords use \b for precision; Chinese keywords match without \b
|
||||
const KEYWORD_SKILL_MAP = [
|
||||
{ keywords: /\b(git|github|commit|push|pull|merge|rebase|branch|tag|stash|cherry.?pick|develop|master|main)\b|分支|合并|推送|提交代码|上传.*分支|主分支/i, skills: ['git-workflow'] },
|
||||
{ keywords: /\b(debug|bug|error|broken|failing|traceback|exception)\b|排查|调试|报错|出错|不工作/i, skills: ['bug-detective'] },
|
||||
{ keywords: /\b(tdd|test.?driven)\b|写测试|测试驱动/i, skills: ['superpowers:test-driven-development'] },
|
||||
{ keywords: /\b(code.?review|review code)\b|审查代码|代码审查/i, skills: ['code-review-excellence'] },
|
||||
{ keywords: /\b(paper|manuscript|draft)\b|论文|写作|投稿/i, skills: ['ml-paper-writing'] },
|
||||
{ keywords: /\b(research|idea|brainstorm)\b|研究|构思|文献/i, skills: ['research-ideation'] },
|
||||
{ keywords: /\b(rebuttal|reviewer|response to reviewer)\b|审稿|回复审稿/i, skills: ['review-response'] },
|
||||
{ keywords: /\b(frontend|landing.?page|dashboard)\b|前端|界面|网页设计/i, skills: ['frontend-design'] },
|
||||
{ keywords: /\b(create|write|develop|improve).*skill|skill.*(开发|创建|写|改进)|开发.*skill|写.*skill|改进.*skill/i, skills: ['skill-development'] },
|
||||
{ keywords: /\b(create|write|develop).*hook|hook.*(开发|创建|写)|开发.*hook|写.*hook/i, skills: ['hook-development'] },
|
||||
{ keywords: /\b(create|write|develop).*command|slash.*command|command.*(开发|创建|写)|开发.*command|写.*命令/i, skills: ['command-development'] },
|
||||
{ keywords: /\b(create|write|develop).*agent|agent.*(开发|创建|写)|开发.*agent|写.*agent/i, skills: ['agent-identifier'] },
|
||||
{ keywords: /\b(mcp)\b|mcp.*server|mcp.*集成/i, skills: ['mcp-integration'] },
|
||||
{ keywords: /\b(architecture|factory|registry)\b|架构|设计模式/i, skills: ['architecture-design'] },
|
||||
{ keywords: /\b(uv|pip|package.*manager|venv)\b|包管理|虚拟环境/i, skills: ['uv-package-manager'] },
|
||||
{ keywords: /\b(kaggle|competition)\b|竞赛/i, skills: ['kaggle-learner'] },
|
||||
{ keywords: /\b(citation|reference.*check)\b|引用|引文|参考文献/i, skills: ['citation-verification'] },
|
||||
{ keywords: /\b(latex.*template|overleaf)\b|模板整理/i, skills: ['latex-conference-template-organizer'] },
|
||||
{ keywords: /\b(ablation)\b|实验结果|results.*analysis|统计检验|消融实验|科学绘图|实验统计/i, skills: ['results-analysis'] },
|
||||
{ keywords: /\b(experiment.?report|results.?report|retrospective|wrap.?up)\b|实验报告|实验总结|实验复盘|结果总结/i, skills: ['results-report'] },
|
||||
{ keywords: /\b(poster|presentation|promote)\b|海报|演讲|推广/i, skills: ['post-acceptance'] },
|
||||
{ keywords: /\b(verify|verification)\b|验证/i, skills: ['verification-loop'] },
|
||||
{ keywords: /\b(self.?review)\b|自审|论文检查/i, skills: ['paper-self-review'] },
|
||||
{ keywords: /\b(anti.?ai|humanize)\b|去.*ai.*痕迹|AI写作/i, skills: ['writing-anti-ai'] },
|
||||
{ keywords: /\b(implement|write code|add feature|modify|refactor)\b|写代码|改代码|实现|添加功能|修改|重构/i, skills: ['daily-coding'] },
|
||||
];
|
||||
|
||||
// Pre-match user prompt against keyword map
|
||||
function suggestSkills(prompt) {
|
||||
const suggested = new Set();
|
||||
for (const { keywords, skills } of KEYWORD_SKILL_MAP) {
|
||||
if (keywords.test(prompt)) {
|
||||
for (const s of skills) suggested.add(s);
|
||||
}
|
||||
}
|
||||
return [...suggested];
|
||||
}
|
||||
|
||||
// Generate skill list
|
||||
const SKILL_LIST = collectSkills();
|
||||
const SKILL_GROUPS = categorizeSkills(SKILL_LIST);
|
||||
const suggestedSkills = suggestSkills(userPrompt);
|
||||
const binding = common.getProjectMemoryBinding(cwd);
|
||||
const isResearchPrompt = common.promptLooksResearchRelated(userPrompt);
|
||||
|
||||
if (binding.bound && isResearchPrompt) {
|
||||
if (SKILL_LIST.includes('obsidian-project-kb-core')) {
|
||||
suggestedSkills.push('obsidian-project-kb-core');
|
||||
}
|
||||
if (/\b(zotero|collection|doi|arxiv|citation)\b|zotero|文献|参考文献|collection/i.test(userPrompt) &&
|
||||
SKILL_LIST.includes('zotero-obsidian-bridge')) {
|
||||
suggestedSkills.push('zotero-obsidian-bridge');
|
||||
}
|
||||
if (/\b(paper|papers|literature|review|claim|method|evidence)\b|论文|综述|paper/i.test(userPrompt) &&
|
||||
SKILL_LIST.includes('obsidian-literature-workflow')) {
|
||||
suggestedSkills.push('obsidian-literature-workflow');
|
||||
}
|
||||
}
|
||||
|
||||
const dedupedSuggestedSkills = [...new Set(suggestedSkills)];
|
||||
|
||||
// Format grouped skills (skip empty groups)
|
||||
const groupedDisplay = Object.entries(SKILL_GROUPS)
|
||||
.filter(([, skills]) => skills.length > 0)
|
||||
.map(([cat, skills]) => `[${cat}] ${skills.join(', ')}`)
|
||||
.join('\n');
|
||||
|
||||
// Build suggested skills hint
|
||||
const suggestedHint = dedupedSuggestedSkills.length > 0
|
||||
? `\n**Pre-matched skills (MUST activate these)**: ${dedupedSuggestedSkills.join(', ')}\nThese skills matched keywords in the user's prompt. You MUST activate them via Skill tool.\n`
|
||||
: '';
|
||||
|
||||
const boundRepoHint = binding.bound && isResearchPrompt
|
||||
? `\n**Bound Obsidian repo detected**: ${binding.projectId || 'unknown-project'}\nUse lightweight curator behavior by default: keep \`Daily/YYYY-MM-DD.md\` and \`.claude/project-memory/<project_id>.md\` in sync when this turn changes research state, and touch \`00-Hub.md\` only when top-level project status really changes. Prefer \`obsidian-project-kb-core\` by default, and add \`obsidian-source-ingestion\`, \`obsidian-literature-workflow\`, or \`obsidian-kb-artifacts\` only when the task clearly needs them.\n`
|
||||
: '';
|
||||
|
||||
// Generate output
|
||||
const output = `## Instruction: Forced Skill Activation (Mandatory)
|
||||
|
||||
Silently scan the user's request against available skills. Do NOT list every skill with Yes/No.
|
||||
|
||||
Available skills:
|
||||
${groupedDisplay}
|
||||
${suggestedHint}${boundRepoHint}
|
||||
**Action**:
|
||||
- If any skill matches → Activate via Skill tool, then output: "Activating: [skill-name] — [reason]"
|
||||
- If no skill matches → Output: "No skills needed"
|
||||
- Begin implementation only after activation is complete.
|
||||
- When multiple skills match, activate all of them.
|
||||
`;
|
||||
|
||||
console.log(output);
|
||||
|
||||
process.exit(0);
|
||||
124
文档润色流和知识库构建流/claude-scholar/hooks/stop-summary.js
Normal file
124
文档润色流和知识库构建流/claude-scholar/hooks/stop-summary.js
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Stop Hook: Display basic status + AI summary prompt (Cross-platform)
|
||||
*
|
||||
* Event: Stop
|
||||
* Purpose: Display Git status, change statistics and temp files when session stops
|
||||
*/
|
||||
|
||||
const common = require('./hook-common');
|
||||
|
||||
// Read stdin input
|
||||
let input = {};
|
||||
try {
|
||||
const stdinData = require('fs').readFileSync(0, 'utf8');
|
||||
if (stdinData.trim()) {
|
||||
input = JSON.parse(stdinData);
|
||||
}
|
||||
} catch {
|
||||
// Use default empty object
|
||||
}
|
||||
|
||||
const cwd = input.cwd || process.cwd();
|
||||
const reason = input.reason || 'task_complete';
|
||||
const binding = common.getProjectMemoryBinding(cwd);
|
||||
const path = require('path');
|
||||
|
||||
function getTempBucket(file) {
|
||||
const normalized = file.replace(/\\/g, '/');
|
||||
const knownRoots = ['plan', 'docs/plans', '.claude/temp', 'tmp', 'temp'];
|
||||
|
||||
for (const root of knownRoots) {
|
||||
if (normalized === root || normalized.startsWith(`${root}/`)) {
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
const dirname = path.dirname(normalized);
|
||||
if (dirname === '.' || dirname === '') {
|
||||
return '.';
|
||||
}
|
||||
|
||||
return normalized.split('/')[0];
|
||||
}
|
||||
|
||||
// Build message
|
||||
function buildMessage() {
|
||||
let msg = '\n---\n';
|
||||
msg += '✅ Session ended\n\n';
|
||||
|
||||
// Git info
|
||||
const gitInfo = common.getGitInfo(cwd);
|
||||
|
||||
if (gitInfo.is_repo) {
|
||||
msg += '📁 Git repository\n';
|
||||
msg += ` Branch: ${gitInfo.branch}\n`;
|
||||
|
||||
if (gitInfo.has_changes) {
|
||||
const changesDetails = common.getChangesDetails(cwd);
|
||||
|
||||
msg += ' Changes:\n';
|
||||
if (changesDetails.added > 0) msg += ` 增加: ${changesDetails.added} files\n`;
|
||||
if (changesDetails.modified > 0) msg += ` 修改: ${changesDetails.modified} files\n`;
|
||||
if (changesDetails.deleted > 0) msg += ` 删除: ${changesDetails.deleted} files\n`;
|
||||
} else {
|
||||
msg += ' Status: clean\n';
|
||||
}
|
||||
} else {
|
||||
msg += '📁 Not a Git repository\n';
|
||||
}
|
||||
|
||||
msg += '\n';
|
||||
|
||||
// Temp file detection
|
||||
const tempInfo = common.detectTempFiles(cwd);
|
||||
|
||||
if (tempInfo.count > 0) {
|
||||
msg += `🧹 Temp files: ${tempInfo.count}\n`;
|
||||
|
||||
const grouped = {};
|
||||
for (const file of tempInfo.files) {
|
||||
const dir = getTempBucket(file);
|
||||
if (!grouped[dir]) grouped[dir] = [];
|
||||
grouped[dir].push(file);
|
||||
}
|
||||
|
||||
const orderedGroups = Object.entries(grouped).sort(([a], [b]) => {
|
||||
if (a === '.' && b !== '.') return -1;
|
||||
if (a !== '.' && b === '.') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
for (const [dir, files] of orderedGroups) {
|
||||
const label = dir === '.' ? './' : `${dir}/`;
|
||||
msg += ` 📂 ${label} (${files.length})\n`;
|
||||
}
|
||||
} else {
|
||||
msg += '✅ No temp files\n';
|
||||
}
|
||||
|
||||
if (binding.bound) {
|
||||
msg += '\n🧠 Bound Obsidian KB\n';
|
||||
msg += ` Project: ${binding.projectId || 'unknown'}\n`;
|
||||
msg += ' Minimum maintenance after research-state turns:\n';
|
||||
msg += ' • Daily/YYYY-MM-DD.md\n';
|
||||
msg += ` • ${binding.memoryPath || '.claude/project-memory/<project_id>.md'}\n`;
|
||||
msg += ' • 00-Hub.md (only when top-level project status changes)\n';
|
||||
}
|
||||
|
||||
msg += '---';
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Build and return
|
||||
const systemMessage = buildMessage();
|
||||
|
||||
const result = {
|
||||
continue: true,
|
||||
systemMessage: systemMessage
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(result));
|
||||
|
||||
process.exit(0);
|
||||
Reference in New Issue
Block a user