chief-clancy 0.8.22 → 0.9.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/bin/clancy.js +153 -0
- package/package.json +8 -88
- package/README.md +0 -292
- package/dist/bundle/clancy-afk.js +0 -6
- package/dist/bundle/clancy-once.js +0 -239
- package/dist/installer/file-ops/file-ops.d.ts +0 -32
- package/dist/installer/file-ops/file-ops.d.ts.map +0 -1
- package/dist/installer/file-ops/file-ops.js +0 -58
- package/dist/installer/file-ops/file-ops.js.map +0 -1
- package/dist/installer/hook-installer/hook-installer.d.ts +0 -31
- package/dist/installer/hook-installer/hook-installer.d.ts.map +0 -1
- package/dist/installer/hook-installer/hook-installer.js +0 -137
- package/dist/installer/hook-installer/hook-installer.js.map +0 -1
- package/dist/installer/install.d.ts +0 -3
- package/dist/installer/install.d.ts.map +0 -1
- package/dist/installer/install.js +0 -270
- package/dist/installer/install.js.map +0 -1
- package/dist/installer/manifest/manifest.d.ts +0 -41
- package/dist/installer/manifest/manifest.d.ts.map +0 -1
- package/dist/installer/manifest/manifest.js +0 -97
- package/dist/installer/manifest/manifest.js.map +0 -1
- package/dist/installer/prompts/prompts.d.ts +0 -33
- package/dist/installer/prompts/prompts.d.ts.map +0 -1
- package/dist/installer/prompts/prompts.js +0 -55
- package/dist/installer/prompts/prompts.js.map +0 -1
- package/dist/installer/role-filter/role-filter.d.ts +0 -15
- package/dist/installer/role-filter/role-filter.d.ts.map +0 -1
- package/dist/installer/role-filter/role-filter.js +0 -48
- package/dist/installer/role-filter/role-filter.js.map +0 -1
- package/dist/installer/ui/ui.d.ts +0 -9
- package/dist/installer/ui/ui.d.ts.map +0 -1
- package/dist/installer/ui/ui.js +0 -94
- package/dist/installer/ui/ui.js.map +0 -1
- package/dist/scripts/shared/env-parser/env-parser.d.ts +0 -30
- package/dist/scripts/shared/env-parser/env-parser.d.ts.map +0 -1
- package/dist/scripts/shared/env-parser/env-parser.js +0 -64
- package/dist/scripts/shared/env-parser/env-parser.js.map +0 -1
- package/dist/utils/ansi/ansi.d.ts +0 -55
- package/dist/utils/ansi/ansi.d.ts.map +0 -1
- package/dist/utils/ansi/ansi.js +0 -55
- package/dist/utils/ansi/ansi.js.map +0 -1
- package/hooks/clancy-branch-guard.js +0 -128
- package/hooks/clancy-check-update.js +0 -114
- package/hooks/clancy-context-monitor.js +0 -189
- package/hooks/clancy-credential-guard.js +0 -120
- package/hooks/clancy-drift-detector.js +0 -96
- package/hooks/clancy-notification.js +0 -105
- package/hooks/clancy-post-compact.js +0 -53
- package/hooks/clancy-statusline.js +0 -82
- package/hooks/package.json +0 -3
- package/registry/boards.json +0 -44
- package/src/agents/arch-agent.md +0 -72
- package/src/agents/concerns-agent.md +0 -89
- package/src/agents/design-agent.md +0 -130
- package/src/agents/devils-advocate.md +0 -53
- package/src/agents/quality-agent.md +0 -161
- package/src/agents/tech-agent.md +0 -92
- package/src/agents/verification-gate.md +0 -128
- package/src/roles/implementer/commands/dry-run.md +0 -14
- package/src/roles/implementer/commands/once.md +0 -17
- package/src/roles/implementer/commands/run.md +0 -11
- package/src/roles/implementer/workflows/once.md +0 -146
- package/src/roles/implementer/workflows/run.md +0 -127
- package/src/roles/planner/commands/approve-plan.md +0 -10
- package/src/roles/planner/commands/plan.md +0 -20
- package/src/roles/planner/workflows/approve-plan.md +0 -535
- package/src/roles/planner/workflows/plan.md +0 -536
- package/src/roles/reviewer/commands/logs.md +0 -7
- package/src/roles/reviewer/commands/review.md +0 -9
- package/src/roles/reviewer/commands/status.md +0 -9
- package/src/roles/reviewer/workflows/logs.md +0 -104
- package/src/roles/reviewer/workflows/review.md +0 -186
- package/src/roles/reviewer/workflows/status.md +0 -134
- package/src/roles/setup/commands/doctor.md +0 -7
- package/src/roles/setup/commands/help.md +0 -80
- package/src/roles/setup/commands/init.md +0 -7
- package/src/roles/setup/commands/map-codebase.md +0 -16
- package/src/roles/setup/commands/settings.md +0 -7
- package/src/roles/setup/commands/uninstall.md +0 -5
- package/src/roles/setup/commands/update-docs.md +0 -9
- package/src/roles/setup/commands/update.md +0 -12
- package/src/roles/setup/workflows/doctor.md +0 -124
- package/src/roles/setup/workflows/init.md +0 -1073
- package/src/roles/setup/workflows/map-codebase.md +0 -125
- package/src/roles/setup/workflows/scaffold.md +0 -845
- package/src/roles/setup/workflows/settings.md +0 -944
- package/src/roles/setup/workflows/uninstall.md +0 -161
- package/src/roles/setup/workflows/update-docs.md +0 -92
- package/src/roles/setup/workflows/update.md +0 -277
- package/src/roles/strategist/commands/approve-brief.md +0 -21
- package/src/roles/strategist/commands/brief.md +0 -27
- package/src/roles/strategist/workflows/approve-brief.md +0 -834
- package/src/roles/strategist/workflows/brief.md +0 -890
- package/src/templates/CLAUDE.md +0 -87
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Clancy Branch Guard — PreToolUse hook.
|
|
3
|
-
// Blocks dangerous git operations: force push, push to protected branches,
|
|
4
|
-
// hard reset, clean, bulk discard, and force branch deletion.
|
|
5
|
-
// Best-effort — never blocks on error (fail-open).
|
|
6
|
-
|
|
7
|
-
'use strict';
|
|
8
|
-
|
|
9
|
-
// Protected branch names — pushes to these are blocked.
|
|
10
|
-
// CLANCY_BASE_BRANCH is added dynamically if set and not already in the list.
|
|
11
|
-
const PROTECTED_BRANCHES = ['main', 'master', 'develop'];
|
|
12
|
-
if (process.env.CLANCY_BASE_BRANCH && !PROTECTED_BRANCHES.includes(process.env.CLANCY_BASE_BRANCH)) {
|
|
13
|
-
PROTECTED_BRANCHES.push(process.env.CLANCY_BASE_BRANCH);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Check whether a command string contains a dangerous git operation.
|
|
18
|
-
* Returns a reason string if blocked, or null if allowed.
|
|
19
|
-
*/
|
|
20
|
-
function checkCommand(cmd) {
|
|
21
|
-
if (!cmd || typeof cmd !== 'string') return null;
|
|
22
|
-
|
|
23
|
-
// --- git push --force / -f (without --force-with-lease) ---
|
|
24
|
-
// Match any occurrence of "git push" with --force or -f flag
|
|
25
|
-
if (/\bgit\s+push\b/.test(cmd)) {
|
|
26
|
-
const hasForceFlag = /\s--force\b/.test(cmd) || /\s-f\b/.test(cmd);
|
|
27
|
-
const hasForceWithLease = /--force-with-lease\b/.test(cmd);
|
|
28
|
-
if (hasForceFlag && !hasForceWithLease) {
|
|
29
|
-
return 'Blocked: git push --force destroys remote history. Use --force-with-lease instead.';
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// --- git push to protected branches ---
|
|
33
|
-
// Pattern: git push [flags...] <remote> <protected-branch>
|
|
34
|
-
// Skip any flags (tokens starting with -) to find the remote and branch.
|
|
35
|
-
// The branch must be a standalone token — not a prefix of a longer name.
|
|
36
|
-
for (const branch of PROTECTED_BRANCHES) {
|
|
37
|
-
// Matches: git push origin main, git push -u origin main, git push --set-upstream origin main:main
|
|
38
|
-
// Does NOT match: git push origin main-feature
|
|
39
|
-
// Escape regex metacharacters in branch name (e.g. release/1.0 → release\/1\.0)
|
|
40
|
-
const escaped = branch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
41
|
-
const pattern = new RegExp(`\\bgit\\s+push\\s+(?:-\\S+\\s+)*\\S+\\s+${escaped}(?:\\s|$|:)`);
|
|
42
|
-
if (pattern.test(cmd)) {
|
|
43
|
-
return `Blocked: direct push to protected branch '${branch}'. Create a PR instead.`;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// --- git reset --hard ---
|
|
49
|
-
if (/\bgit\s+reset\s+--hard\b/.test(cmd)) {
|
|
50
|
-
return 'Blocked: git reset --hard destroys uncommitted work. Use --soft or --mixed instead.';
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// --- git clean -f (any variant with -f but not just -n) ---
|
|
54
|
-
// Block git clean with -f flag (e.g. -f, -fd, -fdx, -xfd, etc.) but allow -n (dry run)
|
|
55
|
-
if (/\bgit\s+clean\b/.test(cmd)) {
|
|
56
|
-
// Check for -f flag in the clean arguments
|
|
57
|
-
// Match short flags like -f, -fd, -fdx, -xf, etc.
|
|
58
|
-
const cleanMatch = cmd.match(/\bgit\s+clean\s+(.*)/);
|
|
59
|
-
if (cleanMatch) {
|
|
60
|
-
const args = cleanMatch[1];
|
|
61
|
-
// Check for -f in combined short flags or standalone
|
|
62
|
-
if (/(?:^|\s)-[a-zA-Z]*f/.test(args) && !/(?:^|\s)-n\b/.test(args)) {
|
|
63
|
-
return 'Blocked: git clean -f deletes untracked files permanently. Use -n for a dry run first.';
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// --- git checkout -- . (discard all changes) ---
|
|
69
|
-
if (/\bgit\s+checkout\s+--\s+\./.test(cmd)) {
|
|
70
|
-
return 'Blocked: git checkout -- . discards all uncommitted changes.';
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// --- git restore . (discard all changes) ---
|
|
74
|
-
// Only block bare "git restore ." (all files), not "git restore ./specific-file"
|
|
75
|
-
if (/\bgit\s+restore\s+\.(?:\s*$|\s*[;&|])/.test(cmd)) {
|
|
76
|
-
return 'Blocked: git restore . discards all uncommitted changes.';
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// --- git branch -D (force delete) ---
|
|
80
|
-
// Block uppercase -D only, not lowercase -d
|
|
81
|
-
if (/\bgit\s+branch\s+.*-D\b/.test(cmd)) {
|
|
82
|
-
return 'Blocked: git branch -D force-deletes a branch irrecoverably. Use -d for safe deletion.';
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Read hook input — Claude Code passes PreToolUse data as a JSON argument.
|
|
89
|
-
// Fall back to stdin for forward compatibility with potential API changes.
|
|
90
|
-
function readInput() {
|
|
91
|
-
if (process.argv[2]) return process.argv[2];
|
|
92
|
-
try { return require('fs').readFileSync('/dev/stdin', 'utf8'); } catch { return '{}'; }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
// Check if guard is disabled
|
|
97
|
-
if (process.env.CLANCY_BRANCH_GUARD === 'false') {
|
|
98
|
-
console.log(JSON.stringify({ decision: 'approve' }));
|
|
99
|
-
process.exit(0);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const input = JSON.parse(readInput());
|
|
103
|
-
const toolName = input.tool_name || '';
|
|
104
|
-
const toolInput = input.tool_input || {};
|
|
105
|
-
|
|
106
|
-
// Only check Bash tool calls
|
|
107
|
-
if (toolName !== 'Bash') {
|
|
108
|
-
console.log(JSON.stringify({ decision: 'approve' }));
|
|
109
|
-
process.exit(0);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const cmd = (toolInput.command || '').trim();
|
|
113
|
-
const reason = checkCommand(cmd);
|
|
114
|
-
|
|
115
|
-
if (reason) {
|
|
116
|
-
console.log(JSON.stringify({ decision: 'block', reason }));
|
|
117
|
-
} else {
|
|
118
|
-
console.log(JSON.stringify({ decision: 'approve' }));
|
|
119
|
-
}
|
|
120
|
-
} catch {
|
|
121
|
-
// Best-effort — never block on error
|
|
122
|
-
console.log(JSON.stringify({ decision: 'approve' }));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Export for testing
|
|
126
|
-
if (typeof module !== 'undefined') {
|
|
127
|
-
module.exports = { checkCommand };
|
|
128
|
-
}
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// SessionStart hook: check for Clancy updates in the background.
|
|
3
|
-
// Spawns a detached child process to hit npm, writes result to cache.
|
|
4
|
-
// Claude reads the cache via CLAUDE.md instruction — no output from this script.
|
|
5
|
-
|
|
6
|
-
'use strict';
|
|
7
|
-
|
|
8
|
-
const fs = require('fs');
|
|
9
|
-
const path = require('path');
|
|
10
|
-
const os = require('os');
|
|
11
|
-
const { spawn } = require('child_process');
|
|
12
|
-
|
|
13
|
-
const homeDir = os.homedir();
|
|
14
|
-
const cwd = process.cwd();
|
|
15
|
-
|
|
16
|
-
// Resolve the Clancy install dir (local takes priority over global).
|
|
17
|
-
function findInstallDir() {
|
|
18
|
-
const localVersion = path.join(cwd, '.claude', 'commands', 'clancy', 'VERSION');
|
|
19
|
-
const globalVersion = path.join(homeDir, '.claude', 'commands', 'clancy', 'VERSION');
|
|
20
|
-
if (fs.existsSync(localVersion)) return path.dirname(localVersion);
|
|
21
|
-
if (fs.existsSync(globalVersion)) return path.dirname(globalVersion);
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const installDir = findInstallDir();
|
|
26
|
-
if (!installDir) process.exit(0); // Clancy not installed — nothing to check
|
|
27
|
-
|
|
28
|
-
const cacheDir = path.join(homeDir, '.claude', 'cache');
|
|
29
|
-
const cacheFile = path.join(cacheDir, 'clancy-update-check.json');
|
|
30
|
-
const versionFile = path.join(installDir, 'VERSION');
|
|
31
|
-
|
|
32
|
-
if (!fs.existsSync(cacheDir)) {
|
|
33
|
-
try { fs.mkdirSync(cacheDir, { recursive: true }); } catch { process.exit(0); }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Spawn a detached background process to do the actual npm check.
|
|
37
|
-
// This script returns immediately so it never delays session start.
|
|
38
|
-
const child = spawn(process.execPath, ['-e', `
|
|
39
|
-
const fs = require('fs');
|
|
40
|
-
const { execFileSync } = require('child_process');
|
|
41
|
-
|
|
42
|
-
const cacheFile = ${JSON.stringify(cacheFile)};
|
|
43
|
-
const versionFile = ${JSON.stringify(versionFile)};
|
|
44
|
-
|
|
45
|
-
let installed = '0.0.0';
|
|
46
|
-
try { installed = fs.readFileSync(versionFile, 'utf8').trim(); } catch {}
|
|
47
|
-
|
|
48
|
-
let latest = null;
|
|
49
|
-
try {
|
|
50
|
-
latest = execFileSync('npm', ['view', 'chief-clancy', 'version'], {
|
|
51
|
-
encoding: 'utf8',
|
|
52
|
-
timeout: 10000,
|
|
53
|
-
windowsHide: true,
|
|
54
|
-
}).trim();
|
|
55
|
-
} catch {}
|
|
56
|
-
|
|
57
|
-
const result = {
|
|
58
|
-
update_available: Boolean(latest && installed !== latest),
|
|
59
|
-
installed,
|
|
60
|
-
latest: latest || 'unknown',
|
|
61
|
-
checked: Math.floor(Date.now() / 1000),
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
try { fs.writeFileSync(cacheFile, JSON.stringify(result)); } catch {}
|
|
65
|
-
`], {
|
|
66
|
-
stdio: 'ignore',
|
|
67
|
-
windowsHide: true,
|
|
68
|
-
detached: true,
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
child.unref();
|
|
72
|
-
|
|
73
|
-
// --- Stale brief detection ---
|
|
74
|
-
// Best-effort: detect unapproved briefs older than 7 days, write count to cache file.
|
|
75
|
-
try {
|
|
76
|
-
const briefsDir = path.join(cwd, '.clancy', 'briefs');
|
|
77
|
-
const staleFile = path.join(cwd, '.clancy', '.brief-stale-count');
|
|
78
|
-
|
|
79
|
-
if (!fs.existsSync(briefsDir)) {
|
|
80
|
-
// No briefs dir — clear any stale cache so old counts don't linger
|
|
81
|
-
try { fs.unlinkSync(staleFile); } catch { /* may not exist */ }
|
|
82
|
-
} else {
|
|
83
|
-
const files = fs.readdirSync(briefsDir);
|
|
84
|
-
// Only count brief files (YYYY-MM-DD-slug.md), exclude .feedback.md and .approved markers
|
|
85
|
-
const mdFiles = files.filter(f =>
|
|
86
|
-
f.endsWith('.md') &&
|
|
87
|
-
!f.endsWith('.md.approved') &&
|
|
88
|
-
!f.endsWith('.feedback.md')
|
|
89
|
-
);
|
|
90
|
-
const now = Date.now();
|
|
91
|
-
const sevenDays = 7 * 24 * 60 * 60 * 1000;
|
|
92
|
-
let staleCount = 0;
|
|
93
|
-
|
|
94
|
-
for (const file of mdFiles) {
|
|
95
|
-
// Check there is no corresponding .approved marker
|
|
96
|
-
if (files.includes(file + '.approved')) continue;
|
|
97
|
-
|
|
98
|
-
// Parse date from filename prefix: YYYY-MM-DD-slug.md
|
|
99
|
-
const match = file.match(/^(\d{4}-\d{2}-\d{2})-/);
|
|
100
|
-
if (!match) continue;
|
|
101
|
-
|
|
102
|
-
const fileDate = new Date(match[1] + 'T00:00:00Z');
|
|
103
|
-
if (isNaN(fileDate.getTime())) continue;
|
|
104
|
-
|
|
105
|
-
if (now - fileDate.getTime() > sevenDays) {
|
|
106
|
-
staleCount++;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
fs.writeFileSync(staleFile, String(staleCount));
|
|
111
|
-
}
|
|
112
|
-
} catch {
|
|
113
|
-
// Best-effort — never crash the hook
|
|
114
|
-
}
|
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Clancy Context Monitor — PostToolUse hook.
|
|
3
|
-
// Reads context metrics from the bridge file written by clancy-statusline.js
|
|
4
|
-
// and injects warnings into Claude's conversation when context runs low.
|
|
5
|
-
//
|
|
6
|
-
// Context thresholds:
|
|
7
|
-
// WARNING (remaining <= 35%): wrap up analysis, move to implementation
|
|
8
|
-
// CRITICAL (remaining <= 25%): commit current work, log to .clancy/progress.txt, stop
|
|
9
|
-
//
|
|
10
|
-
// Time guard:
|
|
11
|
-
// Reads .clancy/lock.json for startedAt timestamp.
|
|
12
|
-
// WARNING (elapsed >= 80% of CLANCY_TIME_LIMIT): wrap up implementation
|
|
13
|
-
// CRITICAL (elapsed >= 100% of CLANCY_TIME_LIMIT): stop and deliver
|
|
14
|
-
//
|
|
15
|
-
// Debounce: 5 tool uses between warnings; severity escalation bypasses debounce.
|
|
16
|
-
// Context and time guards use independent debounce counters.
|
|
17
|
-
|
|
18
|
-
'use strict';
|
|
19
|
-
|
|
20
|
-
const fs = require('fs');
|
|
21
|
-
const os = require('os');
|
|
22
|
-
const path = require('path');
|
|
23
|
-
|
|
24
|
-
const WARNING_THRESHOLD = 35;
|
|
25
|
-
const CRITICAL_THRESHOLD = 25;
|
|
26
|
-
const STALE_SECONDS = 60;
|
|
27
|
-
const DEBOUNCE_CALLS = 5;
|
|
28
|
-
const DEFAULT_TIME_LIMIT = 30; // minutes
|
|
29
|
-
|
|
30
|
-
let input = '';
|
|
31
|
-
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
|
|
32
|
-
process.stdin.setEncoding('utf8');
|
|
33
|
-
process.stdin.on('data', chunk => { input += chunk; });
|
|
34
|
-
process.stdin.on('end', () => {
|
|
35
|
-
clearTimeout(stdinTimeout);
|
|
36
|
-
try {
|
|
37
|
-
const data = JSON.parse(input);
|
|
38
|
-
const session = data.session_id;
|
|
39
|
-
if (!session) process.exit(0);
|
|
40
|
-
|
|
41
|
-
const messages = [];
|
|
42
|
-
|
|
43
|
-
// ── Warn file (shared by context + time debounce) ───────────
|
|
44
|
-
const warnPath = path.join(os.tmpdir(), `clancy-ctx-${session}-warned.json`);
|
|
45
|
-
let warnData = {
|
|
46
|
-
callsSinceWarn: 0,
|
|
47
|
-
lastLevel: null,
|
|
48
|
-
timeCallsSinceWarn: 0,
|
|
49
|
-
timeLastLevel: null,
|
|
50
|
-
};
|
|
51
|
-
let firstContextWarn = true;
|
|
52
|
-
let firstTimeWarn = true;
|
|
53
|
-
|
|
54
|
-
if (fs.existsSync(warnPath)) {
|
|
55
|
-
try {
|
|
56
|
-
const existing = JSON.parse(fs.readFileSync(warnPath, 'utf8'));
|
|
57
|
-
warnData = { ...warnData, ...existing };
|
|
58
|
-
firstContextWarn = !existing.lastLevel;
|
|
59
|
-
firstTimeWarn = !existing.timeLastLevel;
|
|
60
|
-
} catch {}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ── Context guard ───────────────────────────────────────────
|
|
64
|
-
const bridgePath = path.join(os.tmpdir(), `clancy-ctx-${session}.json`);
|
|
65
|
-
let contextFired = false;
|
|
66
|
-
|
|
67
|
-
if (fs.existsSync(bridgePath)) {
|
|
68
|
-
const metrics = JSON.parse(fs.readFileSync(bridgePath, 'utf8'));
|
|
69
|
-
const now = Math.floor(Date.now() / 1000);
|
|
70
|
-
|
|
71
|
-
const isStale = metrics.timestamp && (now - metrics.timestamp) > STALE_SECONDS;
|
|
72
|
-
if (!isStale) {
|
|
73
|
-
const remaining = metrics.remaining_percentage;
|
|
74
|
-
const usedPct = metrics.used_pct;
|
|
75
|
-
|
|
76
|
-
if (remaining <= WARNING_THRESHOLD) {
|
|
77
|
-
warnData.callsSinceWarn = (warnData.callsSinceWarn || 0) + 1;
|
|
78
|
-
|
|
79
|
-
const isCritical = remaining <= CRITICAL_THRESHOLD;
|
|
80
|
-
const currentLevel = isCritical ? 'critical' : 'warning';
|
|
81
|
-
const severityEscalated = currentLevel === 'critical' && warnData.lastLevel === 'warning';
|
|
82
|
-
|
|
83
|
-
const shouldFire = firstContextWarn ||
|
|
84
|
-
warnData.callsSinceWarn >= DEBOUNCE_CALLS ||
|
|
85
|
-
severityEscalated;
|
|
86
|
-
|
|
87
|
-
if (shouldFire) {
|
|
88
|
-
warnData.callsSinceWarn = 0;
|
|
89
|
-
warnData.lastLevel = currentLevel;
|
|
90
|
-
contextFired = true;
|
|
91
|
-
|
|
92
|
-
if (isCritical) {
|
|
93
|
-
messages.push(
|
|
94
|
-
`CONTEXT CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
|
|
95
|
-
'Context is nearly exhausted. Stop reading files and wrap up immediately:\n' +
|
|
96
|
-
'1. Commit whatever work is staged on the current feature branch\n' +
|
|
97
|
-
'2. Append a WIP entry to .clancy/progress.txt: ' +
|
|
98
|
-
'YYYY-MM-DD HH:MM | TICKET-KEY | Summary | WIP — context exhausted\n' +
|
|
99
|
-
'3. Inform the user what was completed and what remains.\n' +
|
|
100
|
-
'Do NOT start any new work.'
|
|
101
|
-
);
|
|
102
|
-
} else {
|
|
103
|
-
messages.push(
|
|
104
|
-
`CONTEXT WARNING: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
|
|
105
|
-
'Context is getting limited. Stop exploring and move to implementation. ' +
|
|
106
|
-
'Avoid reading additional files unless strictly necessary. ' +
|
|
107
|
-
'Commit completed work as soon as it is ready.'
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// ── Time guard ──────────────────────────────────────────────
|
|
116
|
-
const timeLimitEnv = process.env.CLANCY_TIME_LIMIT;
|
|
117
|
-
const timeLimit = timeLimitEnv !== undefined ? Number(timeLimitEnv) : DEFAULT_TIME_LIMIT;
|
|
118
|
-
|
|
119
|
-
if (timeLimit > 0) {
|
|
120
|
-
// Look for lock.json in the working directory's .clancy/
|
|
121
|
-
const cwd = data.cwd || process.cwd();
|
|
122
|
-
const lockPath = path.join(cwd, '.clancy', 'lock.json');
|
|
123
|
-
|
|
124
|
-
if (fs.existsSync(lockPath)) {
|
|
125
|
-
try {
|
|
126
|
-
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
127
|
-
const startedAt = new Date(lock.startedAt);
|
|
128
|
-
|
|
129
|
-
if (!isNaN(startedAt.getTime())) {
|
|
130
|
-
const elapsedMs = Date.now() - startedAt.getTime();
|
|
131
|
-
const elapsedMin = Math.floor(elapsedMs / 60000);
|
|
132
|
-
const limitMs = timeLimit * 60000;
|
|
133
|
-
const pct = Math.floor((elapsedMs / limitMs) * 100);
|
|
134
|
-
|
|
135
|
-
if (pct >= 80) {
|
|
136
|
-
warnData.timeCallsSinceWarn = (warnData.timeCallsSinceWarn || 0) + 1;
|
|
137
|
-
|
|
138
|
-
const isTimeCritical = pct >= 100;
|
|
139
|
-
const currentTimeLevel = isTimeCritical ? 'critical' : 'warning';
|
|
140
|
-
const timeSeverityEscalated =
|
|
141
|
-
currentTimeLevel === 'critical' && warnData.timeLastLevel === 'warning';
|
|
142
|
-
|
|
143
|
-
const shouldFireTime = firstTimeWarn ||
|
|
144
|
-
warnData.timeCallsSinceWarn >= DEBOUNCE_CALLS ||
|
|
145
|
-
timeSeverityEscalated;
|
|
146
|
-
|
|
147
|
-
if (shouldFireTime) {
|
|
148
|
-
warnData.timeCallsSinceWarn = 0;
|
|
149
|
-
warnData.timeLastLevel = currentTimeLevel;
|
|
150
|
-
|
|
151
|
-
if (isTimeCritical) {
|
|
152
|
-
messages.push(
|
|
153
|
-
`TIME CRITICAL: Time limit reached (${elapsedMin}min of ${timeLimit}min).\n` +
|
|
154
|
-
'STOP implementation immediately. Commit current work, push the branch,\n' +
|
|
155
|
-
'and create the PR with whatever is ready. Log a WIP entry if incomplete.'
|
|
156
|
-
);
|
|
157
|
-
} else {
|
|
158
|
-
messages.push(
|
|
159
|
-
`TIME WARNING: Ticket implementation at ${elapsedMin}min of ${timeLimit}min limit (${pct}%).\n` +
|
|
160
|
-
'Wrap up implementation and prepare for delivery. Avoid starting new approaches.'
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
} catch {}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// ── Persist debounce state (only when a threshold was reached) ──
|
|
171
|
-
if (messages.length > 0 || contextFired || warnData.callsSinceWarn > 0 || warnData.timeCallsSinceWarn > 0) {
|
|
172
|
-
fs.writeFileSync(warnPath, JSON.stringify(warnData));
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// ── Output ──────────────────────────────────────────────────
|
|
176
|
-
if (messages.length === 0) process.exit(0);
|
|
177
|
-
|
|
178
|
-
const output = {
|
|
179
|
-
hookSpecificOutput: {
|
|
180
|
-
hookEventName: 'PostToolUse',
|
|
181
|
-
additionalContext: messages.join('\n'),
|
|
182
|
-
},
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
process.stdout.write(JSON.stringify(output));
|
|
186
|
-
} catch {
|
|
187
|
-
process.exit(0);
|
|
188
|
-
}
|
|
189
|
-
});
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Clancy Credential Guard — PreToolUse hook.
|
|
3
|
-
// Scans file content being written or edited for credential patterns
|
|
4
|
-
// (API keys, tokens, passwords, private keys) and blocks the operation
|
|
5
|
-
// if a match is found. Best-effort — never fails the tool call on error.
|
|
6
|
-
|
|
7
|
-
'use strict';
|
|
8
|
-
|
|
9
|
-
const CREDENTIAL_PATTERNS = [
|
|
10
|
-
// Generic API keys and tokens
|
|
11
|
-
{ name: 'Generic API key', pattern: /(?:api[_-]?key|apikey)\s*[:=]\s*["']?[A-Za-z0-9\-_.]{20,}["']?/i },
|
|
12
|
-
{ name: 'Generic secret', pattern: /(?:secret|private[_-]?key)\s*[:=]\s*["']?[A-Za-z0-9\-_.]{20,}["']?/i },
|
|
13
|
-
{ name: 'Generic token', pattern: /(?:auth[_-]?token|access[_-]?token|bearer)\s*[:=]\s*["']?[A-Za-z0-9\-_.]{20,}["']?/i },
|
|
14
|
-
{ name: 'Generic password', pattern: /(?:password|passwd|pwd)\s*[:=]\s*["']?[^\s"']{8,}["']?/i },
|
|
15
|
-
|
|
16
|
-
// AWS
|
|
17
|
-
{ name: 'AWS Access Key', pattern: /AKIA[0-9A-Z]{16}/ },
|
|
18
|
-
{ name: 'AWS Secret Key', pattern: /(?:aws_secret_access_key|aws_secret)\s*[:=]\s*["']?[A-Za-z0-9/+=]{40}["']?/i },
|
|
19
|
-
|
|
20
|
-
// GitHub
|
|
21
|
-
{ name: 'GitHub PAT (classic)', pattern: /ghp_[A-Za-z0-9]{36}/ },
|
|
22
|
-
{ name: 'GitHub PAT (fine-grained)', pattern: /github_pat_[A-Za-z0-9_]{82}/ },
|
|
23
|
-
{ name: 'GitHub OAuth token', pattern: /gho_[A-Za-z0-9]{36}/ },
|
|
24
|
-
|
|
25
|
-
// Slack
|
|
26
|
-
{ name: 'Slack token', pattern: /xox[bpors]-[0-9]{10,}-[A-Za-z0-9-]+/ },
|
|
27
|
-
|
|
28
|
-
// Stripe
|
|
29
|
-
{ name: 'Stripe key', pattern: /(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{24,}/ },
|
|
30
|
-
|
|
31
|
-
// Private keys
|
|
32
|
-
{ name: 'Private key', pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/ },
|
|
33
|
-
|
|
34
|
-
// Jira/Atlassian API tokens (base64-like, 24+ chars after the prefix)
|
|
35
|
-
{ name: 'Atlassian API token', pattern: /(?:jira_api_token|atlassian[_-]?token)\s*[:=]\s*["']?[A-Za-z0-9+/=]{24,}["']?/i },
|
|
36
|
-
|
|
37
|
-
// Linear API key
|
|
38
|
-
{ name: 'Linear API key', pattern: /lin_api_[A-Za-z0-9]{40,}/ },
|
|
39
|
-
|
|
40
|
-
// Generic connection strings
|
|
41
|
-
{ name: 'Database connection string', pattern: /(?:mongodb|postgres|mysql|redis):\/\/[^\s"']+:[^\s"']+@/i },
|
|
42
|
-
];
|
|
43
|
-
|
|
44
|
-
// Files that are expected to contain credentials — skip them
|
|
45
|
-
const ALLOWED_PATHS = [
|
|
46
|
-
'.clancy/.env',
|
|
47
|
-
'.env.local',
|
|
48
|
-
'.env.example',
|
|
49
|
-
'.env.development',
|
|
50
|
-
'.env.test',
|
|
51
|
-
];
|
|
52
|
-
|
|
53
|
-
function isAllowedPath(filePath) {
|
|
54
|
-
if (!filePath) return false;
|
|
55
|
-
return ALLOWED_PATHS.some(allowed => filePath.endsWith(allowed));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function scanForCredentials(content) {
|
|
59
|
-
if (!content || typeof content !== 'string') return [];
|
|
60
|
-
const matches = [];
|
|
61
|
-
for (const { name, pattern } of CREDENTIAL_PATTERNS) {
|
|
62
|
-
if (pattern.test(content)) {
|
|
63
|
-
matches.push(name);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return matches;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Read hook input — Claude Code passes PreToolUse data as a JSON argument.
|
|
70
|
-
// Fall back to stdin for forward compatibility with potential API changes.
|
|
71
|
-
function readInput() {
|
|
72
|
-
if (process.argv[2]) return process.argv[2];
|
|
73
|
-
try { return require('fs').readFileSync('/dev/stdin', 'utf8'); } catch { return '{}'; }
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
const input = JSON.parse(readInput());
|
|
78
|
-
const toolName = input.tool_name || '';
|
|
79
|
-
const toolInput = input.tool_input || {};
|
|
80
|
-
|
|
81
|
-
// Only check file-writing tools
|
|
82
|
-
if (!['Write', 'Edit', 'MultiEdit'].includes(toolName)) {
|
|
83
|
-
// Pass through — not a file write
|
|
84
|
-
console.log(JSON.stringify({ decision: 'approve' }));
|
|
85
|
-
process.exit(0);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const filePath = toolInput.file_path || '';
|
|
89
|
-
|
|
90
|
-
// Skip files that are expected to contain credentials
|
|
91
|
-
if (isAllowedPath(filePath)) {
|
|
92
|
-
console.log(JSON.stringify({ decision: 'approve' }));
|
|
93
|
-
process.exit(0);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Collect content to scan based on tool type
|
|
97
|
-
let contentToScan = '';
|
|
98
|
-
if (toolName === 'Write') {
|
|
99
|
-
contentToScan = toolInput.content || '';
|
|
100
|
-
} else if (toolName === 'Edit') {
|
|
101
|
-
contentToScan = toolInput.new_string || '';
|
|
102
|
-
} else if (toolName === 'MultiEdit') {
|
|
103
|
-
const edits = toolInput.edits || [];
|
|
104
|
-
contentToScan = edits.map(e => e.new_string || '').join('\n');
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const found = scanForCredentials(contentToScan);
|
|
108
|
-
|
|
109
|
-
if (found.length > 0) {
|
|
110
|
-
console.log(JSON.stringify({
|
|
111
|
-
decision: 'block',
|
|
112
|
-
reason: `Credential guard: blocked writing to ${filePath}. Detected: ${found.join(', ')}. Move credentials to .clancy/.env instead.`,
|
|
113
|
-
}));
|
|
114
|
-
} else {
|
|
115
|
-
console.log(JSON.stringify({ decision: 'approve' }));
|
|
116
|
-
}
|
|
117
|
-
} catch {
|
|
118
|
-
// Best-effort — never block on error
|
|
119
|
-
console.log(JSON.stringify({ decision: 'approve' }));
|
|
120
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Clancy Drift Detector — PostToolUse hook (debounced, once per session).
|
|
3
|
-
// Compares .clancy/version.json against the installed chief-clancy package
|
|
4
|
-
// version. If mismatched, warns that Clancy files are outdated.
|
|
5
|
-
//
|
|
6
|
-
// Debounce: writes a session flag to os.tmpdir() (same pattern as context-monitor).
|
|
7
|
-
// Best-effort — never crashes, exit 0 on any error.
|
|
8
|
-
|
|
9
|
-
'use strict';
|
|
10
|
-
|
|
11
|
-
const fs = require('fs');
|
|
12
|
-
const os = require('os');
|
|
13
|
-
const path = require('path');
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Compare two semver-like version strings.
|
|
17
|
-
* Returns true if they differ.
|
|
18
|
-
*/
|
|
19
|
-
function versionsDiffer(a, b) {
|
|
20
|
-
if (!a || !b) return false;
|
|
21
|
-
return a.trim() !== b.trim();
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
let input = '';
|
|
25
|
-
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
|
|
26
|
-
process.stdin.setEncoding('utf8');
|
|
27
|
-
process.stdin.on('data', chunk => { input += chunk; });
|
|
28
|
-
process.stdin.on('end', () => {
|
|
29
|
-
clearTimeout(stdinTimeout);
|
|
30
|
-
try {
|
|
31
|
-
const data = JSON.parse(input);
|
|
32
|
-
const session = data.session_id;
|
|
33
|
-
if (!session) process.exit(0);
|
|
34
|
-
|
|
35
|
-
// Debounce — only check once per session
|
|
36
|
-
const safeSession = String(session).replace(/[^a-zA-Z0-9_-]/g, '');
|
|
37
|
-
const flagPath = path.join(os.tmpdir(), `clancy-drift-${safeSession}`);
|
|
38
|
-
if (fs.existsSync(flagPath)) process.exit(0);
|
|
39
|
-
|
|
40
|
-
// Write the flag immediately to prevent future checks this session
|
|
41
|
-
try { fs.writeFileSync(flagPath, '1'); } catch { /* best-effort */ }
|
|
42
|
-
|
|
43
|
-
const cwd = data.cwd || process.cwd();
|
|
44
|
-
|
|
45
|
-
// Read .clancy/version.json (written by installer)
|
|
46
|
-
const versionJsonPath = path.join(cwd, '.clancy', 'version.json');
|
|
47
|
-
if (!fs.existsSync(versionJsonPath)) process.exit(0);
|
|
48
|
-
|
|
49
|
-
let versionData;
|
|
50
|
-
try {
|
|
51
|
-
versionData = JSON.parse(fs.readFileSync(versionJsonPath, 'utf8'));
|
|
52
|
-
} catch {
|
|
53
|
-
process.exit(0);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const installedVersion = versionData.version;
|
|
57
|
-
if (!installedVersion) process.exit(0);
|
|
58
|
-
|
|
59
|
-
// Read the package version from the installed commands' VERSION file
|
|
60
|
-
// Check local then global
|
|
61
|
-
const localVersion = path.join(cwd, '.claude', 'commands', 'clancy', 'VERSION');
|
|
62
|
-
const homeDir = os.homedir();
|
|
63
|
-
const globalVersion = path.join(homeDir, '.claude', 'commands', 'clancy', 'VERSION');
|
|
64
|
-
|
|
65
|
-
let packageVersion = null;
|
|
66
|
-
for (const vPath of [localVersion, globalVersion]) {
|
|
67
|
-
if (fs.existsSync(vPath)) {
|
|
68
|
-
try {
|
|
69
|
-
packageVersion = fs.readFileSync(vPath, 'utf8').trim();
|
|
70
|
-
break;
|
|
71
|
-
} catch { /* try next */ }
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (!packageVersion) process.exit(0);
|
|
76
|
-
|
|
77
|
-
if (versionsDiffer(installedVersion, packageVersion)) {
|
|
78
|
-
const output = {
|
|
79
|
-
hookSpecificOutput: {
|
|
80
|
-
hookEventName: 'PostToolUse',
|
|
81
|
-
additionalContext:
|
|
82
|
-
`DRIFT WARNING: Clancy runtime files are outdated (runtime: ${installedVersion}, commands: ${packageVersion}). ` +
|
|
83
|
-
'Run /clancy:update to sync your installation.',
|
|
84
|
-
},
|
|
85
|
-
};
|
|
86
|
-
process.stdout.write(JSON.stringify(output));
|
|
87
|
-
}
|
|
88
|
-
} catch {
|
|
89
|
-
process.exit(0);
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// Export for testing
|
|
94
|
-
if (typeof module !== 'undefined') {
|
|
95
|
-
module.exports = { versionsDiffer };
|
|
96
|
-
}
|