first commit

This commit is contained in:
2026-06-11 03:33:14 +08:00
commit 5f555bf342
599 changed files with 142347 additions and 0 deletions

View 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
};

View 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
}
]
}
]
}
}

View 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);
}

View 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);

View 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);

View 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);

View 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);