@haposoft/cafekit 0.3.11 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -28
- package/bin/install.js +125 -1
- package/package.json +5 -3
- package/src/claude/hooks/agent.cjs +203 -0
- package/src/claude/hooks/lib/color.cjs +95 -0
- package/src/claude/hooks/lib/config.cjs +831 -0
- package/src/claude/hooks/lib/context.cjs +616 -0
- package/src/claude/hooks/lib/counter.cjs +103 -0
- package/src/claude/hooks/lib/detect.cjs +474 -0
- package/src/claude/hooks/lib/git.cjs +143 -0
- package/src/claude/hooks/lib/parser.cjs +182 -0
- package/src/claude/hooks/session.cjs +360 -0
- package/src/claude/hooks/usage.cjs +179 -0
- package/src/claude/migration-manifest.json +27 -2
- package/src/claude/settings/status.settings.json +54 -0
- package/src/claude/status.cjs +539 -0
- package/src/common/skills/code/SKILL.md +55 -0
- package/src/common/skills/code/references/execution-loop.md +21 -0
- package/src/common/skills/impact-analysis/references/change-detection.md +16 -16
- package/src/common/skills/impact-analysis/references/dependency-scouting.md +8 -8
- package/src/common/skills/impact-analysis/references/edge-case-identification.md +11 -11
- package/src/common/skills/impact-analysis/references/industry-techniques.md +36 -36
- package/src/common/skills/impact-analysis/references/practical-techniques-guide.md +16 -16
- package/src/common/skills/impact-analysis/references/project-detection.md +1 -1
- package/src/common/skills/impact-analysis/references/report-template.md +2 -2
- package/src/common/skills/impact-analysis/scripts/README.md +3 -3
- package/src/common/skills/review/SKILL.md +46 -0
- package/src/common/skills/review/references/review-focus.md +28 -0
- package/src/common/skills/spec-design/SKILL.md +66 -0
- package/src/common/skills/spec-design/references/design-discovery.md +46 -0
- package/src/common/skills/spec-init/SKILL.md +61 -0
- package/src/common/skills/spec-requirements/SKILL.md +59 -0
- package/src/common/skills/spec-requirements/references/requirements-workflow.md +36 -0
- package/src/common/skills/spec-tasks/SKILL.md +60 -0
- package/src/common/skills/spec-tasks/references/task-sizing.md +36 -0
- package/src/common/skills/test/SKILL.md +40 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Git Info Cache - Cross-platform git information batching
|
|
6
|
+
*
|
|
7
|
+
* Problem: 5-6 git process spawns per statusline render are slow on Windows (CreateProcess overhead)
|
|
8
|
+
* Solution: Cache git query results for 3 seconds — subsequent renders read cache (zero processes)
|
|
9
|
+
*
|
|
10
|
+
* Performance: 5 spawns per render → event-driven refresh + 30s TTL fallback
|
|
11
|
+
* Cross-platform: No bash-only syntax (no 2>/dev/null), windowsHide on all exec calls
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { execSync } = require('child_process');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
|
|
19
|
+
// Cache TTL — long fallback for external changes (git checkout outside Claude)
|
|
20
|
+
// Active invalidation happens via PostToolUse hooks after Edit/Write/Bash
|
|
21
|
+
const CACHE_TTL = 30000;
|
|
22
|
+
const CACHE_MISS = Symbol('cache_miss');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Safe command execution wrapper with optional cwd
|
|
26
|
+
*/
|
|
27
|
+
function execIn(cmd, cwd) {
|
|
28
|
+
try {
|
|
29
|
+
return execSync(cmd, {
|
|
30
|
+
encoding: 'utf8',
|
|
31
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
32
|
+
windowsHide: true,
|
|
33
|
+
cwd: cwd || undefined
|
|
34
|
+
}).trim();
|
|
35
|
+
} catch {
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get cache file path for current working directory
|
|
42
|
+
*/
|
|
43
|
+
function getCachePath(cwd) {
|
|
44
|
+
const hash = require('crypto')
|
|
45
|
+
.createHash('md5')
|
|
46
|
+
.update(cwd)
|
|
47
|
+
.digest('hex')
|
|
48
|
+
.slice(0, 8);
|
|
49
|
+
return path.join(os.tmpdir(), `ck-git-cache-${hash}.json`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read cache if valid (not expired). Returns CACHE_MISS on miss.
|
|
54
|
+
* No existsSync check (TOCTOU race) — just try read and catch.
|
|
55
|
+
*/
|
|
56
|
+
function readCache(cachePath) {
|
|
57
|
+
try {
|
|
58
|
+
const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
59
|
+
if (Date.now() - cache.timestamp < CACHE_TTL) {
|
|
60
|
+
return cache.data; // Can be null (non-git dir) or object (git info)
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// File missing, corrupted, or expired — all treated as cache miss
|
|
64
|
+
}
|
|
65
|
+
return CACHE_MISS;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Write cache atomically (temp file + rename to avoid partial reads on Windows)
|
|
70
|
+
*/
|
|
71
|
+
function writeCache(cachePath, data) {
|
|
72
|
+
const tmpPath = cachePath + '.tmp';
|
|
73
|
+
try {
|
|
74
|
+
fs.writeFileSync(tmpPath, JSON.stringify({ timestamp: Date.now(), data }));
|
|
75
|
+
fs.renameSync(tmpPath, cachePath);
|
|
76
|
+
} catch {
|
|
77
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Count non-empty lines in a newline-delimited string
|
|
83
|
+
*/
|
|
84
|
+
function countLines(str) {
|
|
85
|
+
if (!str) return 0;
|
|
86
|
+
return str.split('\n').filter(l => l.trim()).length;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Fetch git info directly in-process
|
|
91
|
+
* The cache is what eliminates redundant spawns — not subprocess wrapping
|
|
92
|
+
* @param {string} cwd - Directory to run git commands in
|
|
93
|
+
* Returns: { branch, unstaged, staged, ahead, behind } or null if not git repo
|
|
94
|
+
*/
|
|
95
|
+
function fetchGitInfo(cwd) {
|
|
96
|
+
// Check if git repo (fast check) — run in target cwd, not process.cwd()
|
|
97
|
+
if (!execIn('git rev-parse --git-dir', cwd)) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const branch = execIn('git branch --show-current', cwd) || execIn('git rev-parse --short HEAD', cwd);
|
|
102
|
+
const unstaged = countLines(execIn('git diff --name-only', cwd));
|
|
103
|
+
const staged = countLines(execIn('git diff --cached --name-only', cwd));
|
|
104
|
+
|
|
105
|
+
// Ahead/behind — no 2>/dev/null (invalid on Windows cmd.exe)
|
|
106
|
+
let ahead = 0, behind = 0;
|
|
107
|
+
const aheadBehind = execIn('git rev-list --left-right --count @{u}...HEAD', cwd);
|
|
108
|
+
if (aheadBehind) {
|
|
109
|
+
const parts = aheadBehind.split(/\s+/);
|
|
110
|
+
behind = parseInt(parts[0], 10) || 0;
|
|
111
|
+
ahead = parseInt(parts[1], 10) || 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { branch, unstaged, staged, ahead, behind };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get git info with caching
|
|
119
|
+
* Main export function used by statusline
|
|
120
|
+
*/
|
|
121
|
+
function getGitInfo(cwd = process.cwd()) {
|
|
122
|
+
const cachePath = getCachePath(cwd);
|
|
123
|
+
|
|
124
|
+
// Try cache first (includes cached null for non-git dirs)
|
|
125
|
+
const cached = readCache(cachePath);
|
|
126
|
+
if (cached !== CACHE_MISS) return cached;
|
|
127
|
+
|
|
128
|
+
// Cache miss or expired, fetch fresh data in target cwd
|
|
129
|
+
const data = fetchGitInfo(cwd);
|
|
130
|
+
// Cache both positive and null results (avoids re-spawning git in non-git dirs)
|
|
131
|
+
writeCache(cachePath, data);
|
|
132
|
+
|
|
133
|
+
return data;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Invalidate cache for a directory (call after file changes to trigger fresh git query)
|
|
138
|
+
*/
|
|
139
|
+
function invalidateCache(cwd = process.cwd()) {
|
|
140
|
+
try { fs.unlinkSync(getCachePath(cwd)); } catch {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { getGitInfo, invalidateCache };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Transcript Parser - Extract tool/agent/todo state from session JSONL
|
|
6
|
+
* @module transcript-parser
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const readline = require('readline');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse transcript JSONL file
|
|
14
|
+
* @param {string} transcriptPath - Path to transcript file
|
|
15
|
+
* @returns {Promise<TranscriptData>}
|
|
16
|
+
*/
|
|
17
|
+
async function parseTranscript(transcriptPath) {
|
|
18
|
+
const result = {
|
|
19
|
+
tools: [],
|
|
20
|
+
agents: [],
|
|
21
|
+
todos: [],
|
|
22
|
+
sessionStart: null
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const toolMap = new Map();
|
|
30
|
+
const agentMap = new Map();
|
|
31
|
+
let latestTodos = [];
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const fileStream = fs.createReadStream(transcriptPath);
|
|
35
|
+
const rl = readline.createInterface({
|
|
36
|
+
input: fileStream,
|
|
37
|
+
crlfDelay: Infinity
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
for await (const line of rl) {
|
|
41
|
+
if (!line.trim()) continue;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const entry = JSON.parse(line);
|
|
45
|
+
processEntry(entry, toolMap, agentMap, latestTodos, result);
|
|
46
|
+
} catch {
|
|
47
|
+
// Skip malformed lines
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// Return partial results on error
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
result.tools = Array.from(toolMap.values()).slice(-20);
|
|
55
|
+
result.agents = Array.from(agentMap.values()).slice(-10);
|
|
56
|
+
result.todos = latestTodos;
|
|
57
|
+
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Process single JSONL entry
|
|
63
|
+
* @param {Object} entry - Parsed JSON line
|
|
64
|
+
* @param {Map} toolMap - Tool tracking map
|
|
65
|
+
* @param {Map} agentMap - Agent tracking map
|
|
66
|
+
* @param {Array} latestTodos - Latest todo array reference
|
|
67
|
+
* @param {Object} result - Result object
|
|
68
|
+
*/
|
|
69
|
+
function processEntry(entry, toolMap, agentMap, latestTodos, result) {
|
|
70
|
+
const timestamp = entry.timestamp ? new Date(entry.timestamp) : new Date();
|
|
71
|
+
|
|
72
|
+
// Track session start
|
|
73
|
+
if (!result.sessionStart && entry.timestamp) {
|
|
74
|
+
result.sessionStart = timestamp;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const content = entry.message?.content;
|
|
78
|
+
if (!content || !Array.isArray(content)) return;
|
|
79
|
+
|
|
80
|
+
for (const block of content) {
|
|
81
|
+
// Handle tool_use blocks
|
|
82
|
+
if (block.type === 'tool_use' && block.id && block.name) {
|
|
83
|
+
if (block.name === 'Task') {
|
|
84
|
+
// Agent spawn
|
|
85
|
+
agentMap.set(block.id, {
|
|
86
|
+
id: block.id,
|
|
87
|
+
type: block.input?.subagent_type ?? 'unknown',
|
|
88
|
+
model: block.input?.model ?? null,
|
|
89
|
+
description: block.input?.description ?? null,
|
|
90
|
+
status: 'running',
|
|
91
|
+
startTime: timestamp,
|
|
92
|
+
endTime: null
|
|
93
|
+
});
|
|
94
|
+
} else if (block.name === 'TodoWrite') {
|
|
95
|
+
// Legacy: Replace todo array (deprecated, kept for backwards compatibility)
|
|
96
|
+
if (block.input?.todos && Array.isArray(block.input.todos)) {
|
|
97
|
+
latestTodos.length = 0;
|
|
98
|
+
latestTodos.push(...block.input.todos);
|
|
99
|
+
}
|
|
100
|
+
} else if (block.name === 'TaskCreate') {
|
|
101
|
+
// Native Task API: Add new task
|
|
102
|
+
if (block.input?.subject) {
|
|
103
|
+
latestTodos.push({
|
|
104
|
+
id: block.id,
|
|
105
|
+
content: block.input.subject,
|
|
106
|
+
status: 'pending',
|
|
107
|
+
activeForm: block.input.activeForm || null
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
} else if (block.name === 'TaskUpdate') {
|
|
111
|
+
// Native Task API: Update existing task status
|
|
112
|
+
if (block.input?.taskId && block.input?.status) {
|
|
113
|
+
const task = latestTodos.find(t => t.id === block.input.taskId);
|
|
114
|
+
if (task) {
|
|
115
|
+
task.status = block.input.status;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
// Regular tool
|
|
120
|
+
toolMap.set(block.id, {
|
|
121
|
+
id: block.id,
|
|
122
|
+
name: block.name,
|
|
123
|
+
target: extractTarget(block.name, block.input),
|
|
124
|
+
status: 'running',
|
|
125
|
+
startTime: timestamp,
|
|
126
|
+
endTime: null
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Handle tool_result blocks
|
|
132
|
+
if (block.type === 'tool_result' && block.tool_use_id) {
|
|
133
|
+
const tool = toolMap.get(block.tool_use_id);
|
|
134
|
+
if (tool) {
|
|
135
|
+
tool.status = block.is_error ? 'error' : 'completed';
|
|
136
|
+
tool.endTime = timestamp;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const agent = agentMap.get(block.tool_use_id);
|
|
140
|
+
if (agent) {
|
|
141
|
+
agent.status = 'completed';
|
|
142
|
+
agent.endTime = timestamp;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Extract target from tool input
|
|
150
|
+
* @param {string} toolName - Tool name
|
|
151
|
+
* @param {Object} input - Tool input object
|
|
152
|
+
* @returns {string|null} - Extracted target
|
|
153
|
+
*/
|
|
154
|
+
function extractTarget(toolName, input) {
|
|
155
|
+
if (!input) return null;
|
|
156
|
+
|
|
157
|
+
switch (toolName) {
|
|
158
|
+
case 'Read':
|
|
159
|
+
case 'Write':
|
|
160
|
+
case 'Edit':
|
|
161
|
+
return input.file_path ?? input.path ?? null;
|
|
162
|
+
|
|
163
|
+
case 'Glob':
|
|
164
|
+
case 'Grep':
|
|
165
|
+
return input.pattern ?? null;
|
|
166
|
+
|
|
167
|
+
case 'Bash':
|
|
168
|
+
const cmd = input.command;
|
|
169
|
+
if (!cmd) return null;
|
|
170
|
+
return cmd.length > 30 ? cmd.slice(0, 30) + '...' : cmd;
|
|
171
|
+
|
|
172
|
+
default:
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
parseTranscript,
|
|
179
|
+
// Export for testing
|
|
180
|
+
processEntry,
|
|
181
|
+
extractTarget
|
|
182
|
+
};
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SessionStart Hook - Initializes session environment with project detection
|
|
4
|
+
*
|
|
5
|
+
* Fires: Once per session (startup, resume, clear, compact)
|
|
6
|
+
* Purpose: Load config, detect project info, persist to env vars, output context
|
|
7
|
+
*
|
|
8
|
+
* Exit Codes:
|
|
9
|
+
* 0 - Success (non-blocking, allows continuation)
|
|
10
|
+
*
|
|
11
|
+
* Core detection logic extracted to lib/detect.cjs for OpenCode plugin reuse.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Crash wrapper
|
|
15
|
+
try {
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const {
|
|
20
|
+
loadConfig,
|
|
21
|
+
writeEnv,
|
|
22
|
+
writeSessionState,
|
|
23
|
+
resolvePlanPath,
|
|
24
|
+
getReportsPath,
|
|
25
|
+
resolveNamingPattern,
|
|
26
|
+
extractTaskListId,
|
|
27
|
+
isHookEnabled
|
|
28
|
+
} = require('./lib/config.cjs');
|
|
29
|
+
|
|
30
|
+
// Early exit if hook disabled in config
|
|
31
|
+
if (!isHookEnabled('session-init')) {
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Import shared project detection logic
|
|
36
|
+
const {
|
|
37
|
+
detectProjectType,
|
|
38
|
+
detectPackageManager,
|
|
39
|
+
detectFramework,
|
|
40
|
+
getPythonVersion,
|
|
41
|
+
getGitRemoteUrl,
|
|
42
|
+
getGitBranch,
|
|
43
|
+
getGitRoot,
|
|
44
|
+
getCodingLevelStyleName,
|
|
45
|
+
getCodingLevelGuidelines,
|
|
46
|
+
buildContextOutput,
|
|
47
|
+
execSafe
|
|
48
|
+
} = require('./lib/detect.cjs');
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One-time cleanup for orphaned .shadowed/ directories from skill-dedup hook (Issue #422)
|
|
52
|
+
* The hook was disabled due to race conditions; this restores any orphaned skills.
|
|
53
|
+
*/
|
|
54
|
+
function cleanupOrphanedShadowedSkills() {
|
|
55
|
+
const shadowedDir = path.join(process.cwd(), '.claude', 'skills', '.shadowed');
|
|
56
|
+
if (!fs.existsSync(shadowedDir)) return { restored: [], skipped: [], kept: [] };
|
|
57
|
+
|
|
58
|
+
const skillsDir = path.join(process.cwd(), '.claude', 'skills');
|
|
59
|
+
const restored = [];
|
|
60
|
+
const skipped = [];
|
|
61
|
+
const kept = []; // Skills kept for manual review (content differs)
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const entries = fs.readdirSync(shadowedDir, { withFileTypes: true });
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (!entry.isDirectory()) continue;
|
|
67
|
+
const src = path.join(shadowedDir, entry.name);
|
|
68
|
+
const dest = path.join(skillsDir, entry.name);
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
if (!fs.existsSync(dest)) {
|
|
72
|
+
fs.renameSync(src, dest);
|
|
73
|
+
restored.push(entry.name);
|
|
74
|
+
} else {
|
|
75
|
+
// Skill exists in local - verify content match before deleting orphaned copy
|
|
76
|
+
const orphanedSkill = path.join(src, 'SKILL.md');
|
|
77
|
+
const localSkill = path.join(dest, 'SKILL.md');
|
|
78
|
+
if (fs.existsSync(orphanedSkill) && fs.existsSync(localSkill)) {
|
|
79
|
+
const orphanedContent = fs.readFileSync(orphanedSkill, 'utf8');
|
|
80
|
+
const localContent = fs.readFileSync(localSkill, 'utf8');
|
|
81
|
+
if (orphanedContent === localContent) {
|
|
82
|
+
// Content identical - safe to remove orphaned duplicate
|
|
83
|
+
fs.rmSync(src, { recursive: true, force: true });
|
|
84
|
+
skipped.push(entry.name);
|
|
85
|
+
} else {
|
|
86
|
+
// Content differs - user may have edited orphaned version, keep for review
|
|
87
|
+
kept.push(entry.name);
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
// Missing SKILL.md - safe to remove orphaned copy
|
|
91
|
+
fs.rmSync(src, { recursive: true, force: true });
|
|
92
|
+
skipped.push(entry.name);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
process.stderr.write(`[session-init] Failed to process "${entry.name}": ${err.message}\n`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Clean up manifest and shadowed dir if empty
|
|
100
|
+
const manifestFile = path.join(shadowedDir, '.dedup-manifest.json');
|
|
101
|
+
if (fs.existsSync(manifestFile)) fs.unlinkSync(manifestFile);
|
|
102
|
+
// Only remove shadowed dir if empty (kept skills may remain)
|
|
103
|
+
const remaining = fs.readdirSync(shadowedDir);
|
|
104
|
+
if (remaining.length === 0) fs.rmdirSync(shadowedDir);
|
|
105
|
+
return { restored, skipped, kept };
|
|
106
|
+
} catch (err) {
|
|
107
|
+
process.stderr.write(`[session-init] Shadowed cleanup error: ${err.message}\n`);
|
|
108
|
+
return { restored, skipped, kept };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Detect if this session is running inside an Agent Team.
|
|
114
|
+
* Scans ~/.claude/teams/ for active team configs and checks membership.
|
|
115
|
+
* Note: Returns first team found — Claude Code supports one team per session.
|
|
116
|
+
* Note: Team lifecycle (creation/cleanup) is managed by Claude Code, not this hook.
|
|
117
|
+
* @returns {{ teamName: string, memberCount: number } | null}
|
|
118
|
+
*/
|
|
119
|
+
function detectAgentTeam() {
|
|
120
|
+
try {
|
|
121
|
+
const teamsDir = path.join(os.homedir(), '.claude', 'teams');
|
|
122
|
+
if (!fs.existsSync(teamsDir)) return null;
|
|
123
|
+
|
|
124
|
+
const teams = fs.readdirSync(teamsDir, { withFileTypes: true });
|
|
125
|
+
for (const entry of teams) {
|
|
126
|
+
if (!entry.isDirectory()) continue;
|
|
127
|
+
const configPath = path.join(teamsDir, entry.name, 'config.json');
|
|
128
|
+
if (!fs.existsSync(configPath)) continue;
|
|
129
|
+
try {
|
|
130
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
131
|
+
if (config.members && config.members.length > 0) {
|
|
132
|
+
return { teamName: entry.name, memberCount: config.members.length };
|
|
133
|
+
}
|
|
134
|
+
} catch { /* skip malformed configs */ }
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Main hook execution
|
|
144
|
+
*/
|
|
145
|
+
async function main() {
|
|
146
|
+
try {
|
|
147
|
+
// Issue #422: One-time cleanup of orphaned .shadowed/ from disabled skill-dedup hook
|
|
148
|
+
const shadowedCleanup = cleanupOrphanedShadowedSkills();
|
|
149
|
+
|
|
150
|
+
const stdin = fs.readFileSync(0, 'utf-8').trim();
|
|
151
|
+
const data = stdin ? JSON.parse(stdin) : {};
|
|
152
|
+
const envFile = process.env.CLAUDE_ENV_FILE;
|
|
153
|
+
const source = data.source || 'unknown';
|
|
154
|
+
const sessionId = data.session_id || null;
|
|
155
|
+
|
|
156
|
+
const config = loadConfig();
|
|
157
|
+
|
|
158
|
+
const detections = {
|
|
159
|
+
type: detectProjectType(config.project?.type),
|
|
160
|
+
pm: detectPackageManager(config.project?.packageManager),
|
|
161
|
+
framework: detectFramework(config.project?.framework)
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Resolve plan - now returns { path, resolvedBy }
|
|
165
|
+
const resolved = resolvePlanPath(null, config);
|
|
166
|
+
|
|
167
|
+
// CRITICAL FIX: Only persist explicitly-set plans to session state
|
|
168
|
+
// Branch-matched plans are "suggested" - stored separately, not as activePlan
|
|
169
|
+
// This prevents stale plan pollution on fresh sessions
|
|
170
|
+
if (sessionId) {
|
|
171
|
+
writeSessionState(sessionId, {
|
|
172
|
+
sessionOrigin: process.cwd(),
|
|
173
|
+
// Only session-resolved plans are truly "active"
|
|
174
|
+
activePlan: resolved.resolvedBy === 'session' ? resolved.path : null,
|
|
175
|
+
// Track suggested plan separately (for UI hints, not for report paths)
|
|
176
|
+
suggestedPlan: resolved.resolvedBy === 'branch' ? resolved.path : null,
|
|
177
|
+
timestamp: Date.now(),
|
|
178
|
+
source
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Reports path only uses active plans, not suggested ones
|
|
183
|
+
const reportsPath = getReportsPath(resolved.path, resolved.resolvedBy, config.plan, config.paths);
|
|
184
|
+
|
|
185
|
+
// Extract task list ID for Claude Code Tasks coordination (shared helper)
|
|
186
|
+
const taskListId = extractTaskListId(resolved);
|
|
187
|
+
|
|
188
|
+
// Collect static environment info (computed once per session)
|
|
189
|
+
const staticEnv = {
|
|
190
|
+
nodeVersion: process.version,
|
|
191
|
+
pythonVersion: getPythonVersion(),
|
|
192
|
+
osPlatform: process.platform,
|
|
193
|
+
gitUrl: getGitRemoteUrl(),
|
|
194
|
+
gitBranch: getGitBranch(),
|
|
195
|
+
gitRoot: getGitRoot(),
|
|
196
|
+
user: process.env.USERNAME || process.env.USER || process.env.LOGNAME || os.userInfo().username,
|
|
197
|
+
locale: process.env.LANG || '',
|
|
198
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
199
|
+
claudeSettingsDir: path.resolve(__dirname, '..')
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Compute base directory for absolute paths (Issue #327: use CWD for subdirectory support)
|
|
203
|
+
// Git root is kept in staticEnv for reference, but CWD determines where files are created
|
|
204
|
+
const baseDir = process.cwd();
|
|
205
|
+
|
|
206
|
+
// Compute resolved naming pattern (date + issue resolved, {slug} kept as placeholder)
|
|
207
|
+
const namePattern = resolveNamingPattern(config.plan, staticEnv.gitBranch);
|
|
208
|
+
|
|
209
|
+
if (envFile) {
|
|
210
|
+
// Session & plan config
|
|
211
|
+
writeEnv(envFile, 'CK_SESSION_ID', sessionId || '');
|
|
212
|
+
writeEnv(envFile, 'CK_PLAN_NAMING_FORMAT', config.plan.namingFormat);
|
|
213
|
+
writeEnv(envFile, 'CK_PLAN_DATE_FORMAT', config.plan.dateFormat);
|
|
214
|
+
writeEnv(envFile, 'CK_PLAN_ISSUE_PREFIX', config.plan.issuePrefix || '');
|
|
215
|
+
writeEnv(envFile, 'CK_PLAN_REPORTS_DIR', config.plan.reportsDir);
|
|
216
|
+
|
|
217
|
+
// NEW: Resolved naming pattern for DRY file naming in agents
|
|
218
|
+
// Example: "251212-1830-GH-88-{slug}" or "251212-1830-{slug}"
|
|
219
|
+
// Agents use: `{agent-type}-$CK_NAME_PATTERN.md` and substitute {slug}
|
|
220
|
+
writeEnv(envFile, 'CK_NAME_PATTERN', namePattern);
|
|
221
|
+
|
|
222
|
+
// Plan resolution
|
|
223
|
+
writeEnv(envFile, 'CK_ACTIVE_PLAN', resolved.resolvedBy === 'session' ? resolved.path : '');
|
|
224
|
+
writeEnv(envFile, 'CK_SUGGESTED_PLAN', resolved.resolvedBy === 'branch' ? resolved.path : '');
|
|
225
|
+
|
|
226
|
+
// Claude Code Tasks integration - enables multi-session/subagent coordination
|
|
227
|
+
// Task list ID = plan directory name (shared across all sessions working on same plan)
|
|
228
|
+
if (taskListId) {
|
|
229
|
+
writeEnv(envFile, 'CLAUDE_CODE_TASK_LIST_ID', taskListId);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Paths - use absolute paths based on CWD for subdirectory workflow support (Issue #327)
|
|
233
|
+
writeEnv(envFile, 'CK_GIT_ROOT', staticEnv.gitRoot || '');
|
|
234
|
+
writeEnv(envFile, 'CK_REPORTS_PATH', path.join(baseDir, reportsPath));
|
|
235
|
+
writeEnv(envFile, 'CK_DOCS_PATH', path.join(baseDir, config.paths.docs));
|
|
236
|
+
writeEnv(envFile, 'CK_PLANS_PATH', path.join(baseDir, config.paths.plans));
|
|
237
|
+
writeEnv(envFile, 'CK_PROJECT_ROOT', process.cwd());
|
|
238
|
+
|
|
239
|
+
// Project detection
|
|
240
|
+
writeEnv(envFile, 'CK_PROJECT_TYPE', detections.type || '');
|
|
241
|
+
writeEnv(envFile, 'CK_PACKAGE_MANAGER', detections.pm || '');
|
|
242
|
+
writeEnv(envFile, 'CK_FRAMEWORK', detections.framework || '');
|
|
243
|
+
|
|
244
|
+
// NEW: Static environment info (so other hooks don't need to recompute)
|
|
245
|
+
writeEnv(envFile, 'CK_NODE_VERSION', staticEnv.nodeVersion);
|
|
246
|
+
writeEnv(envFile, 'CK_PYTHON_VERSION', staticEnv.pythonVersion || '');
|
|
247
|
+
writeEnv(envFile, 'CK_OS_PLATFORM', staticEnv.osPlatform);
|
|
248
|
+
writeEnv(envFile, 'CK_GIT_URL', staticEnv.gitUrl || '');
|
|
249
|
+
writeEnv(envFile, 'CK_GIT_BRANCH', staticEnv.gitBranch || '');
|
|
250
|
+
writeEnv(envFile, 'CK_USER', staticEnv.user);
|
|
251
|
+
writeEnv(envFile, 'CK_LOCALE', staticEnv.locale);
|
|
252
|
+
writeEnv(envFile, 'CK_TIMEZONE', staticEnv.timezone);
|
|
253
|
+
writeEnv(envFile, 'CK_CLAUDE_SETTINGS_DIR', staticEnv.claudeSettingsDir);
|
|
254
|
+
|
|
255
|
+
// Locale config
|
|
256
|
+
if (config.locale?.thinkingLanguage) {
|
|
257
|
+
writeEnv(envFile, 'CK_THINKING_LANGUAGE', config.locale.thinkingLanguage);
|
|
258
|
+
}
|
|
259
|
+
if (config.locale?.responseLanguage) {
|
|
260
|
+
writeEnv(envFile, 'CK_RESPONSE_LANGUAGE', config.locale.responseLanguage);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Plan validation config (for /plan validate, /plan --hard, /plan --parallel)
|
|
264
|
+
const validation = config.plan?.validation || {};
|
|
265
|
+
writeEnv(envFile, 'CK_VALIDATION_MODE', validation.mode || 'prompt');
|
|
266
|
+
writeEnv(envFile, 'CK_VALIDATION_MIN_QUESTIONS', validation.minQuestions || 3);
|
|
267
|
+
writeEnv(envFile, 'CK_VALIDATION_MAX_QUESTIONS', validation.maxQuestions || 8);
|
|
268
|
+
writeEnv(envFile, 'CK_VALIDATION_FOCUS_AREAS', (validation.focusAreas || ['assumptions', 'risks', 'tradeoffs', 'architecture']).join(','));
|
|
269
|
+
|
|
270
|
+
// Coding level config (for output style selection)
|
|
271
|
+
const codingLevel = config.codingLevel ?? 5;
|
|
272
|
+
writeEnv(envFile, 'CK_CODING_LEVEL', codingLevel);
|
|
273
|
+
writeEnv(envFile, 'CK_CODING_LEVEL_STYLE', getCodingLevelStyleName(codingLevel));
|
|
274
|
+
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Agent Teams detection — detect once, used for env vars and console output
|
|
278
|
+
const teamInfo = detectAgentTeam();
|
|
279
|
+
if (envFile && teamInfo) {
|
|
280
|
+
writeEnv(envFile, 'CK_AGENT_TEAM', teamInfo.teamName);
|
|
281
|
+
writeEnv(envFile, 'CK_AGENT_TEAM_MEMBERS', teamInfo.memberCount);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
console.log(`Session ${source}. ${buildContextOutput(config, detections, resolved, staticEnv.gitRoot)}`);
|
|
285
|
+
|
|
286
|
+
// Issue #422: Notify user if orphaned skills were recovered from .shadowed/
|
|
287
|
+
const hasCleanup = shadowedCleanup.restored.length > 0 || shadowedCleanup.skipped.length > 0 || shadowedCleanup.kept.length > 0;
|
|
288
|
+
if (hasCleanup) {
|
|
289
|
+
console.log(`\n[!] SKILL-DEDUP CLEANUP (Issue #422):`);
|
|
290
|
+
console.log(`Recovered orphaned .shadowed/ directory from disabled skill-dedup hook.`);
|
|
291
|
+
if (shadowedCleanup.restored.length > 0) {
|
|
292
|
+
console.log(`Restored ${shadowedCleanup.restored.length} skill(s): ${shadowedCleanup.restored.join(', ')}`);
|
|
293
|
+
}
|
|
294
|
+
if (shadowedCleanup.skipped.length > 0) {
|
|
295
|
+
console.log(`Removed ${shadowedCleanup.skipped.length} duplicate(s): ${shadowedCleanup.skipped.join(', ')}`);
|
|
296
|
+
}
|
|
297
|
+
if (shadowedCleanup.kept.length > 0) {
|
|
298
|
+
console.log(`[!] Kept ${shadowedCleanup.kept.length} skill(s) for manual review (content differs): ${shadowedCleanup.kept.join(', ')}`);
|
|
299
|
+
console.log(` Review .claude/skills/.shadowed/ and merge changes manually.`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Agent Teams: Show team context if running inside a team (uses cached result)
|
|
304
|
+
if (teamInfo) {
|
|
305
|
+
console.log(`[i] Agent Team detected: "${teamInfo.teamName}" (${teamInfo.memberCount} members)`);
|
|
306
|
+
console.log(` Team config: ~/.claude/teams/${teamInfo.teamName}/config.json`);
|
|
307
|
+
console.log(` Use /team skill for orchestration templates.`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Info: Show git root when running from subdirectory (Issue #327: now supported)
|
|
311
|
+
if (staticEnv.gitRoot && staticEnv.gitRoot !== process.cwd()) {
|
|
312
|
+
console.log(`📁 Subdirectory mode: Plans/docs will be created in current directory`);
|
|
313
|
+
console.log(` Git root: ${staticEnv.gitRoot}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// MITIGATION: Issue #277 - Auto-compact can bypass AskUserQuestion approval gates
|
|
317
|
+
// When context is compacted mid-workflow, the summarization may lose "pending approval" state.
|
|
318
|
+
// This warning reminds Claude to verify if user approval was pending before proceeding.
|
|
319
|
+
// Upstream bug: Claude Code CLI should preserve pending interactive state during compaction.
|
|
320
|
+
if (source === 'compact') {
|
|
321
|
+
console.log(`\n⚠️ CONTEXT COMPACTED - APPROVAL STATE CHECK:`);
|
|
322
|
+
console.log(`If you were waiting for user approval via AskUserQuestion (e.g., Step 4 review gate),`);
|
|
323
|
+
console.log(`you MUST re-confirm with the user before proceeding. Do NOT assume approval was given.`);
|
|
324
|
+
console.log(`Use AskUserQuestion to verify: "Context was compacted. Please confirm approval to continue."`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Auto-inject coding level guidelines (if not disabled)
|
|
328
|
+
const codingLevel = config.codingLevel ?? -1;
|
|
329
|
+
const guidelines = getCodingLevelGuidelines(codingLevel);
|
|
330
|
+
if (guidelines) {
|
|
331
|
+
console.log(`\n${guidelines}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (config.assertions?.length > 0) {
|
|
335
|
+
console.log(`\nUser Assertions:`);
|
|
336
|
+
config.assertions.forEach((assertion, i) => {
|
|
337
|
+
console.log(` ${i + 1}. ${assertion}`);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
process.exit(0);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
console.error(`SessionStart hook error: ${error.message}`);
|
|
344
|
+
process.exit(0);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
main();
|
|
349
|
+
} catch (e) {
|
|
350
|
+
// Minimal crash logging (zero deps — only Node builtins)
|
|
351
|
+
try {
|
|
352
|
+
const fs = require('fs');
|
|
353
|
+
const p = require('path');
|
|
354
|
+
const logDir = p.join(__dirname, '.logs');
|
|
355
|
+
if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
|
|
356
|
+
fs.appendFileSync(p.join(logDir, 'hook-log.jsonl'),
|
|
357
|
+
JSON.stringify({ ts: new Date().toISOString(), hook: p.basename(__filename, '.cjs'), status: 'crash', error: e.message }) + '\n');
|
|
358
|
+
} catch (_) {}
|
|
359
|
+
process.exit(0); // fail-open
|
|
360
|
+
}
|