ccgx-workflow 2.3.1 → 2.4.1
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/dist/chunks/version-build.mjs +3 -0
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +237 -3
- package/dist/index.d.ts +237 -3
- package/dist/index.mjs +243 -8
- package/dist/shared/{ccgx-workflow.DDYfdCuM.mjs → ccgx-workflow.j1spUsik.mjs} +156 -2
- package/package.json +1 -1
- package/templates/commands/review.md +6 -3
- package/templates/commands/spec-impl.md +67 -2
- package/templates/hooks/ccg-loop-detector.cjs +232 -0
- package/templates/hooks/ccg-skill-router.cjs +249 -0
- package/templates/hooks/ccg-stop-gate.cjs +202 -0
- package/templates/scripts/spec-suggestion.cjs +266 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CCG Loop Detector Hook — UserPromptSubmit
|
|
3
|
+
//
|
|
4
|
+
// Detects when the user is stuck describing the same problem turn after turn
|
|
5
|
+
// (typical signs: model gave a wrong answer, user re-phrases the question,
|
|
6
|
+
// model gives a similar wrong answer, repeat). Emits a `LOOP DETECTED` warning
|
|
7
|
+
// into the model's additionalContext along with a break-loop protocol, so the
|
|
8
|
+
// next reply is forced to step back rather than re-attempt the same approach.
|
|
9
|
+
//
|
|
10
|
+
// Why this hook lives in fork-land (not just porting upstream's workflow-state.js):
|
|
11
|
+
// - Upstream binds loop detection to .ccg/tasks/{id}/task.json — the V3
|
|
12
|
+
// intent-driven engine that fork does not adopt.
|
|
13
|
+
// - Fork's task state lives in .ccg/roadmap.md (milestone) + .ccg/state.md
|
|
14
|
+
// (wave). Those are coarse — they don't change per-turn.
|
|
15
|
+
// - The actual signal we can read every turn is the user's own prompt.
|
|
16
|
+
// Repeated near-identical prompts within a short window are a strong loop
|
|
17
|
+
// indicator regardless of what the orchestrator is doing internally.
|
|
18
|
+
//
|
|
19
|
+
// State file: <workdir>/.ccg/.turns.json (kept tiny: last 5 turns only)
|
|
20
|
+
//
|
|
21
|
+
// Performance budget: <50 ms hot path. Hook fires every UserPromptSubmit; if
|
|
22
|
+
// anything throws we exit 0 silently so the user message is never blocked.
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
const STATE_FILE = path.join('.ccg', '.turns.json');
|
|
30
|
+
const MAX_TURNS = 5; // sliding window
|
|
31
|
+
const LOOP_THRESHOLD = 3; // 3 similar in a row → loop
|
|
32
|
+
// Bound the state-file read. .turns.json holds at most MAX_TURNS rows of
|
|
33
|
+
// fp+ts (~280B each), so anything beyond 64KB is corrupt or malicious.
|
|
34
|
+
// Matches the budget used by ccg-stop-gate.cjs for the same reason.
|
|
35
|
+
const MAX_STATE_FILE_BYTES = 64 * 1024;
|
|
36
|
+
// Threshold tuned for character-bigram Jaccard. Empirically:
|
|
37
|
+
// "fix login bug" vs "fix login bug please" → ~0.67
|
|
38
|
+
// "fix login bug" vs "fix login bug now" → ~0.69
|
|
39
|
+
// "帮我修登录的错误" vs "帮我修一下登录的错误" → ~0.60
|
|
40
|
+
// unrelated English prompts → < 0.15
|
|
41
|
+
// unrelated CJK prompts → < 0.20
|
|
42
|
+
// 0.55 catches re-phrasings of the same ask in both scripts while staying
|
|
43
|
+
// well above the unrelated-pair floor.
|
|
44
|
+
const SIMILARITY_THRESHOLD = 0.55;
|
|
45
|
+
const WINDOW_MS = 10 * 60 * 1000;// ignore matches > 10 min apart
|
|
46
|
+
const PROMPT_FINGERPRINT_LEN = 240;
|
|
47
|
+
|
|
48
|
+
function readJsonSafe(p) {
|
|
49
|
+
try {
|
|
50
|
+
// Bounded read so a corrupt 100MB .turns.json doesn't wedge the prompt path.
|
|
51
|
+
const st = fs.statSync(p);
|
|
52
|
+
if (!st.isFile() || st.size > MAX_STATE_FILE_BYTES) return null;
|
|
53
|
+
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeJsonSafe(p, data) {
|
|
61
|
+
try {
|
|
62
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
63
|
+
fs.writeFileSync(p, JSON.stringify(data), 'utf-8');
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Hook must never fail loudly.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Cheap fingerprint: lowercased, whitespace-collapsed, truncated.
|
|
71
|
+
// Long enough to capture meaningful overlap; short enough to keep .turns.json tiny.
|
|
72
|
+
function fingerprint(prompt) {
|
|
73
|
+
if (typeof prompt !== 'string') return '';
|
|
74
|
+
return prompt.toLowerCase().replace(/\s+/g, ' ').trim().slice(0, PROMPT_FINGERPRINT_LEN);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Character-bigram set. Works across scripts:
|
|
78
|
+
// - English: "fix login bug" → {"fi","ix","x ", " l", ...}
|
|
79
|
+
// - CJK: "帮我修登录" → {"帮我","我修","修登","登录"}
|
|
80
|
+
// Word-level tokenization is a non-starter for CJK (no whitespace separators).
|
|
81
|
+
// Bigrams give us a script-agnostic signal at the cost of a slightly noisier
|
|
82
|
+
// floor (~0.15 for unrelated English, ~0.20 for unrelated CJK) which we
|
|
83
|
+
// absorbed when tuning SIMILARITY_THRESHOLD.
|
|
84
|
+
function bigrams(s) {
|
|
85
|
+
const out = new Set();
|
|
86
|
+
if (!s || s.length < 2) return out;
|
|
87
|
+
for (let i = 0; i < s.length - 1; i++) {
|
|
88
|
+
out.add(s.slice(i, i + 2));
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function jaccard(a, b) {
|
|
94
|
+
if (!a || !b) return 0;
|
|
95
|
+
const A = bigrams(a);
|
|
96
|
+
const B = bigrams(b);
|
|
97
|
+
if (A.size === 0 || B.size === 0) return 0;
|
|
98
|
+
let intersection = 0;
|
|
99
|
+
for (const t of A) if (B.has(t)) intersection++;
|
|
100
|
+
const union = A.size + B.size - intersection;
|
|
101
|
+
return intersection / union;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function detectLoop(turns) {
|
|
105
|
+
if (turns.length < LOOP_THRESHOLD) return null;
|
|
106
|
+
const recent = turns.slice(-LOOP_THRESHOLD);
|
|
107
|
+
const oldest = recent[0];
|
|
108
|
+
const newest = recent[recent.length - 1];
|
|
109
|
+
if (newest.ts - oldest.ts > WINDOW_MS) return null;
|
|
110
|
+
|
|
111
|
+
// Pairwise similarity must clear threshold for every consecutive pair.
|
|
112
|
+
// This is stricter than "average" — one outlier breaks the loop call,
|
|
113
|
+
// which is what we want (avoids false-positives on "good question, follow-up").
|
|
114
|
+
for (let i = 1; i < recent.length; i++) {
|
|
115
|
+
if (jaccard(recent[i - 1].fp, recent[i].fp) < SIMILARITY_THRESHOLD) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
count: recent.length,
|
|
122
|
+
elapsedSec: Math.round((newest.ts - oldest.ts) / 1000),
|
|
123
|
+
fingerprint: oldest.fp.slice(0, 80),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Compose the additionalContext payload. The 5-step break-loop protocol is
|
|
128
|
+
// adapted from upstream's workflow-state.js but oriented at the user-prompt
|
|
129
|
+
// signal instead of phase+nextAction state.
|
|
130
|
+
function composeWarning(loop) {
|
|
131
|
+
return [
|
|
132
|
+
'<ccg-loop-warning>',
|
|
133
|
+
`⚠️ LOOP DETECTED — same/similar user prompt repeated ${loop.count} turns within ${loop.elapsedSec}s.`,
|
|
134
|
+
`Sample: "${loop.fingerprint}..."`,
|
|
135
|
+
'',
|
|
136
|
+
'🔄 BREAK-LOOP PROTOCOL (apply before answering this turn):',
|
|
137
|
+
' 1. STOP. Do not re-attempt the previous approach.',
|
|
138
|
+
' 2. Re-read the user message looking for what you missed (a constraint? a file path? a "no" you treated as "maybe"?).',
|
|
139
|
+
' 3. State, in one sentence, why your previous answer did not satisfy the user.',
|
|
140
|
+
' 4. Pick a different approach OR ask one specific clarifying question (not "what do you mean") if you cannot diagnose the miss.',
|
|
141
|
+
' 5. If you suspect an external blocker (missing creds, broken tool, unsupported feature), say so explicitly — do not loop on it.',
|
|
142
|
+
'</ccg-loop-warning>',
|
|
143
|
+
].join('\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readInput() {
|
|
147
|
+
// SessionStart / UserPromptSubmit pass JSON on stdin. We only need .prompt
|
|
148
|
+
// and .cwd. Failing to read is fine — we exit 0 silently.
|
|
149
|
+
let raw = '';
|
|
150
|
+
try {
|
|
151
|
+
raw = fs.readFileSync(0, 'utf-8');
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return JSON.parse(raw || '{}');
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function main() {
|
|
165
|
+
const input = readInput();
|
|
166
|
+
if (!input) {
|
|
167
|
+
process.exit(0);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const prompt = typeof input.prompt === 'string' ? input.prompt : '';
|
|
172
|
+
if (!prompt) {
|
|
173
|
+
process.exit(0);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// CCG state lives under the project root. Use the cwd Claude Code passed in;
|
|
178
|
+
// fall back to process.cwd() (which Claude Code sets for the hook anyway).
|
|
179
|
+
const cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : process.cwd();
|
|
180
|
+
const statePath = path.join(cwd, STATE_FILE);
|
|
181
|
+
|
|
182
|
+
const fp = fingerprint(prompt);
|
|
183
|
+
if (!fp) {
|
|
184
|
+
process.exit(0);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const existing = readJsonSafe(statePath);
|
|
189
|
+
// Filter out malformed turn objects (e.g. corrupted via hand edit, partial
|
|
190
|
+
// write). Without this a missing/non-numeric ts becomes NaN inside detectLoop
|
|
191
|
+
// and produces silent false negatives instead of just dropping the bad row.
|
|
192
|
+
const turns = (Array.isArray(existing) ? existing : []).filter(
|
|
193
|
+
(t) => t && typeof t.fp === 'string' && typeof t.ts === 'number' && Number.isFinite(t.ts),
|
|
194
|
+
);
|
|
195
|
+
turns.push({ fp, ts: Date.now() });
|
|
196
|
+
if (turns.length > MAX_TURNS) {
|
|
197
|
+
turns.splice(0, turns.length - MAX_TURNS);
|
|
198
|
+
}
|
|
199
|
+
writeJsonSafe(statePath, turns);
|
|
200
|
+
|
|
201
|
+
const loop = detectLoop(turns);
|
|
202
|
+
if (!loop) {
|
|
203
|
+
process.exit(0);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Emit the warning. Same envelope shape as other CCG hooks.
|
|
208
|
+
process.stdout.write(JSON.stringify({
|
|
209
|
+
hookSpecificOutput: {
|
|
210
|
+
hookEventName: 'UserPromptSubmit',
|
|
211
|
+
additionalContext: composeWarning(loop),
|
|
212
|
+
},
|
|
213
|
+
}));
|
|
214
|
+
process.exit(0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (require.main === module) {
|
|
218
|
+
main();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Exported for unit tests (loaded via createRequire from .ts).
|
|
222
|
+
module.exports = {
|
|
223
|
+
fingerprint,
|
|
224
|
+
jaccard,
|
|
225
|
+
detectLoop,
|
|
226
|
+
composeWarning,
|
|
227
|
+
// Constants exported so tests can document the contract.
|
|
228
|
+
LOOP_THRESHOLD,
|
|
229
|
+
WINDOW_MS,
|
|
230
|
+
SIMILARITY_THRESHOLD,
|
|
231
|
+
MAX_TURNS,
|
|
232
|
+
};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CCG Skill Router Hook — UserPromptSubmit
|
|
3
|
+
//
|
|
4
|
+
// When the user's message contains domain keywords (e.g. "SQLi", "Kubernetes",
|
|
5
|
+
// "RAG"), inject a short additionalContext that names the relevant skill file
|
|
6
|
+
// the model should Read before answering.
|
|
7
|
+
//
|
|
8
|
+
// Why this is a hook rather than relying on rules/ccg-skill-routing.md alone:
|
|
9
|
+
// - The rules file is a passive document. The model has to remember to
|
|
10
|
+
// consult it, and in practice it often answers from training data without
|
|
11
|
+
// reading the domain skill (false-positives in security/architecture/AI
|
|
12
|
+
// advice are the typical failure mode).
|
|
13
|
+
// - This hook runs deterministically on every prompt and surfaces the exact
|
|
14
|
+
// path inline, so the model doesn't have to find it. Token cost is ~50
|
|
15
|
+
// per match — much cheaper than the wrong answer it prevents.
|
|
16
|
+
//
|
|
17
|
+
// Design choices that diverge from upstream skill-router.js:
|
|
18
|
+
// - We DO NOT inject the file content. Upstream slices the first ~120 lines
|
|
19
|
+
// and ships them in context. That bloats every prompt that matches a
|
|
20
|
+
// keyword. We just point at the path and let the model Read on demand.
|
|
21
|
+
// - We DO NOT spawn external models from this hook. Upstream uses keyword
|
|
22
|
+
// phrases like "用 codex 审查" to autoexecute the codeagent-wrapper.
|
|
23
|
+
// Fork's plugin-spawn architecture would conflict with that path.
|
|
24
|
+
//
|
|
25
|
+
// Performance budget: <30 ms. Hook fires every UserPromptSubmit.
|
|
26
|
+
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
const fs = require('node:fs');
|
|
30
|
+
|
|
31
|
+
// Routing table mirrors templates/rules/ccg-skill-routing.md. Keep them in
|
|
32
|
+
// sync — the rules file is for human reference, this hook is what actually
|
|
33
|
+
// fires. Keywords are matched case-insensitively as substrings.
|
|
34
|
+
//
|
|
35
|
+
// Match shape: { keywords: [...], skill: '<relative path under skills/ccg/>', label: '<human-readable>' }
|
|
36
|
+
const ROUTES = [
|
|
37
|
+
// ── Security ──
|
|
38
|
+
{ keywords: ['pentest', 'red team', '红队', '渗透', 'exploit', 'c2', 'lateral movement', 'privilege escalation', '提权', 'evasion', 'persistence', '免杀', '持久化'],
|
|
39
|
+
skill: 'domains/security/red-team.md', label: 'Red Team' },
|
|
40
|
+
{ keywords: ['blue team', '蓝队', 'incident response', '应急', 'ioc', 'siem', 'edr', '取证', 'forensics', 'containment'],
|
|
41
|
+
skill: 'domains/security/blue-team.md', label: 'Blue Team' },
|
|
42
|
+
{ keywords: ['sqli', 'sql injection', 'xss', 'ssrf', 'rce', 'owasp', 'web pentest', 'api security', 'injection'],
|
|
43
|
+
skill: 'domains/security/pentest.md', label: 'Web Pentest' },
|
|
44
|
+
{ keywords: ['code audit', '代码审计', 'taint analysis', '污点分析', 'sink', 'dangerous function', '危险函数'],
|
|
45
|
+
skill: 'domains/security/code-audit.md', label: 'Code Audit' },
|
|
46
|
+
{ keywords: ['binary', 'reversing', '逆向', 'pwn', 'fuzzing', 'stack overflow', 'heap overflow', '栈溢出', '堆溢出', 'rop'],
|
|
47
|
+
skill: 'domains/security/vuln-research.md', label: 'Vuln Research' },
|
|
48
|
+
{ keywords: ['osint', 'threat intelligence', '威胁情报', 'threat modeling', '威胁建模', 'att&ck', 'threat hunting'],
|
|
49
|
+
skill: 'domains/security/threat-intel.md', label: 'Threat Intel' },
|
|
50
|
+
|
|
51
|
+
// ── Architecture ──
|
|
52
|
+
{ keywords: ['api design', 'api 设计', 'rest', 'graphql', 'grpc', 'endpoint', 'versioning'],
|
|
53
|
+
skill: 'domains/architecture/api-design.md', label: 'API Design' },
|
|
54
|
+
{ keywords: ['caching', '缓存', 'redis', 'memcached', 'cache invalidation', 'cdn'],
|
|
55
|
+
skill: 'domains/architecture/caching.md', label: 'Caching' },
|
|
56
|
+
{ keywords: ['cloud native', '云原生', 'kubernetes', 'k8s', 'docker', 'microservice', '微服务', 'service mesh'],
|
|
57
|
+
skill: 'domains/architecture/cloud-native.md', label: 'Cloud Native' },
|
|
58
|
+
{ keywords: ['message queue', '消息队列', 'kafka', 'rabbitmq', 'event driven', 'pub/sub'],
|
|
59
|
+
skill: 'domains/architecture/message-queue.md', label: 'Message Queue' },
|
|
60
|
+
{ keywords: ['security architecture', '安全架构', 'zero trust', 'defense in depth', 'iam'],
|
|
61
|
+
skill: 'domains/architecture/security-arch.md', label: 'Security Arch' },
|
|
62
|
+
|
|
63
|
+
// ── AI / MLOps ──
|
|
64
|
+
{ keywords: ['rag', 'retrieval augmented', '检索增强', 'vector database', 'embedding', 'chunking'],
|
|
65
|
+
skill: 'domains/ai/rag-system.md', label: 'RAG System' },
|
|
66
|
+
{ keywords: ['ai agent', 'tool use', 'function calling', 'agent framework', 'orchestration'],
|
|
67
|
+
skill: 'domains/ai/agent-dev.md', label: 'Agent Dev' },
|
|
68
|
+
{ keywords: ['llm security', 'prompt injection', 'jailbreak', 'guardrail'],
|
|
69
|
+
skill: 'domains/ai/llm-security.md', label: 'LLM Security' },
|
|
70
|
+
{ keywords: ['prompt engineering', 'model evaluation', 'benchmark', 'fine-tuning'],
|
|
71
|
+
skill: 'domains/ai/prompt-and-eval.md', label: 'Prompt & Eval' },
|
|
72
|
+
|
|
73
|
+
// ── DevOps ──
|
|
74
|
+
{ keywords: ['git workflow', 'branching strategy', 'trunk-based', 'gitflow'],
|
|
75
|
+
skill: 'domains/devops/git-workflow.md', label: 'Git Workflow' },
|
|
76
|
+
{ keywords: ['testing strategy', 'unit test', 'integration test', 'e2e', 'test pyramid'],
|
|
77
|
+
skill: 'domains/devops/testing.md', label: 'Testing' },
|
|
78
|
+
{ keywords: ['database migration', 'schema design', 'indexing', 'query optimization'],
|
|
79
|
+
skill: 'domains/devops/database.md', label: 'Database' },
|
|
80
|
+
{ keywords: ['profiling', 'load test', 'latency', 'throughput', 'performance tuning'],
|
|
81
|
+
skill: 'domains/devops/performance.md', label: 'Performance' },
|
|
82
|
+
{ keywords: ['observability', 'logging', 'tracing', 'metrics', 'prometheus', 'grafana'],
|
|
83
|
+
skill: 'domains/devops/observability.md', label: 'Observability' },
|
|
84
|
+
{ keywords: ['devsecops', 'ci security', 'sast', 'dast', 'supply chain'],
|
|
85
|
+
skill: 'domains/devops/devsecops.md', label: 'DevSecOps' },
|
|
86
|
+
{ keywords: ['cost optimization', 'cloud cost', 'finops', 'right-sizing'],
|
|
87
|
+
skill: 'domains/devops/cost-optimization.md', label: 'Cost Optimization' },
|
|
88
|
+
|
|
89
|
+
// ── Frontend Design (only style packs; basic UI is too noisy to match) ──
|
|
90
|
+
{ keywords: ['claymorphism'], skill: 'domains/frontend-design/claymorphism/SKILL.md', label: 'Claymorphism' },
|
|
91
|
+
{ keywords: ['glassmorphism'], skill: 'domains/frontend-design/glassmorphism/SKILL.md', label: 'Glassmorphism' },
|
|
92
|
+
{ keywords: ['liquid glass'], skill: 'domains/frontend-design/liquid-glass/SKILL.md', label: 'Liquid Glass' },
|
|
93
|
+
{ keywords: ['neubrutalism'], skill: 'domains/frontend-design/neubrutalism/SKILL.md', label: 'Neubrutalism' },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const MAX_INJECTIONS = 2; // cap on matched skills per prompt to keep context tight
|
|
97
|
+
|
|
98
|
+
// Short keyword cutoff (ASCII). ASCII keywords shorter than this length MUST
|
|
99
|
+
// use a word-boundary regex to avoid catastrophic false positives. Empirically:
|
|
100
|
+
// 'rag' → "drag/fragment/Bragg" (3 chars)
|
|
101
|
+
// 'rest' → "restart/rest assured/interest" (4 chars)
|
|
102
|
+
// 'rce' → "service/source" (3 chars)
|
|
103
|
+
// 'route' → "rerouted" (5 chars but unambiguous as a word)
|
|
104
|
+
// Cutoff = 5 catches all the painful ones while letting actual nouns ('route',
|
|
105
|
+
// 'redis', 'cache', etc.) take the cheap substring path.
|
|
106
|
+
const SHORT_KW_CUTOFF = 5;
|
|
107
|
+
|
|
108
|
+
// CJK keywords are a different problem space. There is no formal word
|
|
109
|
+
// boundary in CJK writing — '红队' next to '员' produces '红队员' (a real
|
|
110
|
+
// compound noun), but the user query '我想做一次红队演练' also has '红队'
|
|
111
|
+
// next to a CJK char ('次' before, '演' after). A boundary regex anchored on
|
|
112
|
+
// \P{L} would reject BOTH cases identically, killing legitimate matches.
|
|
113
|
+
//
|
|
114
|
+
// We accept the cost: CJK keywords always take the substring path. In
|
|
115
|
+
// practice CJK queries are more topical and less likely to produce ASCII-style
|
|
116
|
+
// homograph collisions, so substring false-positive rate on CJK stays low.
|
|
117
|
+
function hasCjk(s) {
|
|
118
|
+
return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(s);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Returns true iff `kw` appears in `prompt` as a stand-alone token (ASCII)
|
|
123
|
+
* or as a substring (CJK).
|
|
124
|
+
*
|
|
125
|
+
* Regex objects are cached per keyword — the hook runs on every
|
|
126
|
+
* UserPromptSubmit and building these on the hot path would be wasteful.
|
|
127
|
+
*/
|
|
128
|
+
const KW_REGEX_CACHE = new Map();
|
|
129
|
+
function matchKeyword(prompt, kw) {
|
|
130
|
+
if (!kw) return false;
|
|
131
|
+
const lowerPrompt = prompt.toLowerCase();
|
|
132
|
+
const lowerKw = kw.toLowerCase();
|
|
133
|
+
// CJK short keywords: substring path (boundary makes no sense — see above).
|
|
134
|
+
// Long keywords (>= cutoff): substring is safe enough.
|
|
135
|
+
if (hasCjk(lowerKw) || lowerKw.length >= SHORT_KW_CUTOFF) {
|
|
136
|
+
return lowerPrompt.includes(lowerKw);
|
|
137
|
+
}
|
|
138
|
+
// ASCII short keyword: word-boundary regex.
|
|
139
|
+
let re = KW_REGEX_CACHE.get(lowerKw);
|
|
140
|
+
if (!re) {
|
|
141
|
+
// Escape regex metachars in the keyword (handles 'c2', 'pub/sub', etc.).
|
|
142
|
+
const esc = lowerKw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
143
|
+
// Boundary = position whose neighboring char is NOT a letter or digit
|
|
144
|
+
// (Unicode-aware). Start/end of string are also valid boundaries.
|
|
145
|
+
re = new RegExp(`(?:^|[^\\p{L}\\p{N}])${esc}(?:$|[^\\p{L}\\p{N}])`, 'iu');
|
|
146
|
+
KW_REGEX_CACHE.set(lowerKw, re);
|
|
147
|
+
}
|
|
148
|
+
return re.test(lowerPrompt);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function readInput() {
|
|
152
|
+
let raw = '';
|
|
153
|
+
try {
|
|
154
|
+
raw = fs.readFileSync(0, 'utf-8');
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
return JSON.parse(raw || '{}');
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function findMatches(prompt) {
|
|
168
|
+
const text = prompt || '';
|
|
169
|
+
if (text.length < 5) return [];
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
const matches = [];
|
|
172
|
+
for (const route of ROUTES) {
|
|
173
|
+
if (seen.has(route.skill)) continue;
|
|
174
|
+
if (route.keywords.some(kw => matchKeyword(text, kw))) {
|
|
175
|
+
matches.push(route);
|
|
176
|
+
seen.add(route.skill);
|
|
177
|
+
if (matches.length >= MAX_INJECTIONS) break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return matches;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function composePointer(matches, skillsBase) {
|
|
184
|
+
// Output format intentionally short. The model only needs the path; reading
|
|
185
|
+
// the file is its job, not the hook's.
|
|
186
|
+
const lines = ['<ccg-domain-routing>'];
|
|
187
|
+
lines.push('The user\'s prompt mentions a domain that CCG has authoritative reference material for.');
|
|
188
|
+
lines.push('Read the file(s) below BEFORE answering — do not rely on training-data recall for these topics.');
|
|
189
|
+
lines.push('');
|
|
190
|
+
for (const m of matches) {
|
|
191
|
+
lines.push(`- ${m.label}: ${skillsBase}/${m.skill}`);
|
|
192
|
+
}
|
|
193
|
+
lines.push('</ccg-domain-routing>');
|
|
194
|
+
return lines.join('\n');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function main() {
|
|
198
|
+
const input = readInput();
|
|
199
|
+
if (!input) {
|
|
200
|
+
process.exit(0);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const prompt = typeof input.prompt === 'string' ? input.prompt : '';
|
|
204
|
+
if (!prompt) {
|
|
205
|
+
process.exit(0);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const matches = findMatches(prompt);
|
|
210
|
+
if (matches.length === 0) {
|
|
211
|
+
process.exit(0);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Resolve skills directory once. We point at the user-facing install path
|
|
216
|
+
// (~/.claude/skills/ccg) rather than checking it exists — if the user
|
|
217
|
+
// uninstalled the skills, the model's Read will fail loudly and we surface
|
|
218
|
+
// that, which is preferable to silently swallowing the routing.
|
|
219
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
220
|
+
if (!homeDir) {
|
|
221
|
+
// Stripped CI container / minimal Docker image with no HOME or USERPROFILE.
|
|
222
|
+
// Skip the injection entirely — pointing at '/.claude/skills/ccg' would be
|
|
223
|
+
// worse than nothing (the model would Read a path that cannot exist).
|
|
224
|
+
process.exit(0);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const skillsBase = `${homeDir.replace(/\\/g, '/')}/.claude/skills/ccg`;
|
|
228
|
+
|
|
229
|
+
process.stdout.write(JSON.stringify({
|
|
230
|
+
hookSpecificOutput: {
|
|
231
|
+
hookEventName: 'UserPromptSubmit',
|
|
232
|
+
additionalContext: composePointer(matches, skillsBase),
|
|
233
|
+
},
|
|
234
|
+
}));
|
|
235
|
+
process.exit(0);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (require.main === module) {
|
|
239
|
+
main();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = {
|
|
243
|
+
findMatches,
|
|
244
|
+
composePointer,
|
|
245
|
+
matchKeyword,
|
|
246
|
+
ROUTES,
|
|
247
|
+
MAX_INJECTIONS,
|
|
248
|
+
SHORT_KW_CUTOFF,
|
|
249
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CCG Stop Gate Hook — Stop / SubagentStop
|
|
3
|
+
//
|
|
4
|
+
// When Claude is about to end its turn, check whether there are still
|
|
5
|
+
// active background tasks the user expects to finish. Emit a warning into
|
|
6
|
+
// additionalContext so the model does not falsely claim "done" while:
|
|
7
|
+
// - a CCG job (.context/jobs/<id>/state.json) is still queued/running
|
|
8
|
+
// - Claude Code's own harness reports background_tasks (added in 2.1.145)
|
|
9
|
+
//
|
|
10
|
+
// Why this matters: fork's async trio (status/result/cancel) and
|
|
11
|
+
// phase-runner-launcher wrap codex/gemini calls in long-lived OS subprocesses.
|
|
12
|
+
// Without this hook, the main thread frequently said "✅ complete" while a
|
|
13
|
+
// launcher was still mid-task. Users had to keep checking /ccg:status.
|
|
14
|
+
//
|
|
15
|
+
// Design choices:
|
|
16
|
+
// - Warn, never block. Setting `decision: "block"` would force the model to
|
|
17
|
+
// keep working, which is the wrong tool when the user explicitly said
|
|
18
|
+
// "stop" or when the model finished its main answer and the background
|
|
19
|
+
// task is fire-and-forget. We surface the info; the model decides.
|
|
20
|
+
// - Source-of-truth merge: harness-reported background_tasks ∪ CCG jobs.
|
|
21
|
+
// The two sets overlap heavily but neither is a superset. Harness sees
|
|
22
|
+
// bash run_in_background processes; CCG sees its own job registry.
|
|
23
|
+
// - Bounded scan: list the .context/jobs/ directory but never read a file
|
|
24
|
+
// bigger than 64 KB. Performance must not regress.
|
|
25
|
+
//
|
|
26
|
+
// Performance budget: <100 ms even with many jobs. Hook runs on every Stop.
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const path = require('node:path');
|
|
32
|
+
|
|
33
|
+
const JOBS_SUBDIR = path.join('.context', 'jobs');
|
|
34
|
+
const ACTIVE_STATUSES = new Set(['queued', 'running']);
|
|
35
|
+
const MAX_STATE_FILE_BYTES = 64 * 1024;
|
|
36
|
+
const MAX_REPORT_JOBS = 5; // truncate to keep additionalContext tight
|
|
37
|
+
|
|
38
|
+
function readInput() {
|
|
39
|
+
let raw = '';
|
|
40
|
+
try {
|
|
41
|
+
raw = fs.readFileSync(0, 'utf-8');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(raw || '{}');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readJobState(stateFile) {
|
|
55
|
+
try {
|
|
56
|
+
const stat = fs.statSync(stateFile);
|
|
57
|
+
if (!stat.isFile() || stat.size > MAX_STATE_FILE_BYTES) return null;
|
|
58
|
+
const raw = fs.readFileSync(stateFile, 'utf-8');
|
|
59
|
+
return JSON.parse(raw);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Returns array of { jobId, status, phase, summary, sourcedFrom: 'ccg-jobs' }.
|
|
67
|
+
function scanCcgJobs(cwd) {
|
|
68
|
+
const jobsDir = path.join(cwd, JOBS_SUBDIR);
|
|
69
|
+
let entries;
|
|
70
|
+
try {
|
|
71
|
+
entries = fs.readdirSync(jobsDir, { withFileTypes: true });
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
const active = [];
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (!entry.isDirectory()) continue;
|
|
79
|
+
const jobId = entry.name;
|
|
80
|
+
const state = readJobState(path.join(jobsDir, jobId, 'state.json'));
|
|
81
|
+
if (!state || typeof state.status !== 'string') continue;
|
|
82
|
+
if (!ACTIVE_STATUSES.has(state.status)) continue;
|
|
83
|
+
active.push({
|
|
84
|
+
jobId,
|
|
85
|
+
status: state.status,
|
|
86
|
+
phase: typeof state.phase === 'string' ? state.phase : null,
|
|
87
|
+
summary: typeof state.summary === 'string' ? state.summary.slice(0, 120) : null,
|
|
88
|
+
sourcedFrom: 'ccg-jobs',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return active;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Normalize harness-supplied background_tasks (field shape added in CC 2.1.145).
|
|
95
|
+
// Shape is best-effort: we accept either an array of strings (task ids) or an
|
|
96
|
+
// array of objects with at minimum a `task_id` or `id` field.
|
|
97
|
+
function normalizeHarnessTasks(input) {
|
|
98
|
+
const raw = input && input.background_tasks;
|
|
99
|
+
if (!Array.isArray(raw)) return [];
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const item of raw) {
|
|
102
|
+
if (typeof item === 'string') {
|
|
103
|
+
out.push({ jobId: item, status: 'unknown', phase: null, summary: null, sourcedFrom: 'harness' });
|
|
104
|
+
}
|
|
105
|
+
else if (item && typeof item === 'object') {
|
|
106
|
+
const jobId = item.task_id || item.id || item.name || '(unnamed)';
|
|
107
|
+
const status = typeof item.status === 'string' ? item.status : 'unknown';
|
|
108
|
+
out.push({
|
|
109
|
+
jobId,
|
|
110
|
+
status,
|
|
111
|
+
phase: typeof item.phase === 'string' ? item.phase : null,
|
|
112
|
+
summary: typeof item.description === 'string' ? item.description.slice(0, 120) : null,
|
|
113
|
+
sourcedFrom: 'harness',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function mergeTasks(ccgJobs, harnessTasks) {
|
|
121
|
+
// Dedupe by jobId across the two sources (CCG jobs win on conflict because
|
|
122
|
+
// their status is authoritative — harness only knows "active").
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
const merged = [];
|
|
125
|
+
for (const j of ccgJobs) {
|
|
126
|
+
seen.add(j.jobId);
|
|
127
|
+
merged.push(j);
|
|
128
|
+
}
|
|
129
|
+
for (const h of harnessTasks) {
|
|
130
|
+
if (seen.has(h.jobId)) continue;
|
|
131
|
+
merged.push(h);
|
|
132
|
+
}
|
|
133
|
+
return merged;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function composeWarning(eventName, tasks) {
|
|
137
|
+
const limited = tasks.slice(0, MAX_REPORT_JOBS);
|
|
138
|
+
const omitted = tasks.length - limited.length;
|
|
139
|
+
const lines = ['<ccg-stop-gate>'];
|
|
140
|
+
lines.push(`⚠️ ${tasks.length} background task(s) still active at ${eventName}.`);
|
|
141
|
+
lines.push('Do not declare the user-facing work "complete" until these settle.');
|
|
142
|
+
lines.push('Options: tell the user "background X is still running, check /ccg:status <id>", or wait and re-summarize.');
|
|
143
|
+
lines.push('');
|
|
144
|
+
for (const t of limited) {
|
|
145
|
+
const phase = t.phase ? ` phase=${t.phase}` : '';
|
|
146
|
+
const summary = t.summary ? ` — ${t.summary}` : '';
|
|
147
|
+
lines.push(`- ${t.jobId} [${t.status}, ${t.sourcedFrom}]${phase}${summary}`);
|
|
148
|
+
}
|
|
149
|
+
if (omitted > 0) {
|
|
150
|
+
lines.push(`- ... ${omitted} more (truncated)`);
|
|
151
|
+
}
|
|
152
|
+
lines.push('</ccg-stop-gate>');
|
|
153
|
+
return lines.join('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function detectEvent(input) {
|
|
157
|
+
// Claude Code Stop/SubagentStop payloads carry the event name in
|
|
158
|
+
// `hook_event_name`. Default to 'Stop' if absent — the model treats the two
|
|
159
|
+
// identically, this only affects the echoed hookEventName field in our
|
|
160
|
+
// response envelope.
|
|
161
|
+
if (input && typeof input.hook_event_name === 'string') {
|
|
162
|
+
if (input.hook_event_name === 'SubagentStop') return 'SubagentStop';
|
|
163
|
+
}
|
|
164
|
+
return 'Stop';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function main() {
|
|
168
|
+
const input = readInput();
|
|
169
|
+
const cwd = (input && typeof input.cwd === 'string' && input.cwd) ? input.cwd : process.cwd();
|
|
170
|
+
const eventName = detectEvent(input);
|
|
171
|
+
|
|
172
|
+
const ccgJobs = scanCcgJobs(cwd);
|
|
173
|
+
const harnessTasks = normalizeHarnessTasks(input);
|
|
174
|
+
const merged = mergeTasks(ccgJobs, harnessTasks);
|
|
175
|
+
|
|
176
|
+
if (merged.length === 0) {
|
|
177
|
+
process.exit(0);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
process.stdout.write(JSON.stringify({
|
|
182
|
+
hookSpecificOutput: {
|
|
183
|
+
hookEventName: eventName,
|
|
184
|
+
additionalContext: composeWarning(eventName, merged),
|
|
185
|
+
},
|
|
186
|
+
}));
|
|
187
|
+
process.exit(0);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (require.main === module) {
|
|
191
|
+
main();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = {
|
|
195
|
+
scanCcgJobs,
|
|
196
|
+
normalizeHarnessTasks,
|
|
197
|
+
mergeTasks,
|
|
198
|
+
composeWarning,
|
|
199
|
+
detectEvent,
|
|
200
|
+
ACTIVE_STATUSES,
|
|
201
|
+
MAX_REPORT_JOBS,
|
|
202
|
+
};
|