ccgx-workflow 2.3.0 → 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.BYuVglaM.mjs → ccgx-workflow.j1spUsik.mjs} +164 -2
- package/package.json +1 -1
- package/templates/commands/analyze.md +1 -1
- package/templates/commands/codex-exec.md +1 -1
- package/templates/commands/debate.md +3 -4
- package/templates/commands/execute.md +1 -3
- package/templates/commands/optimize.md +1 -1
- package/templates/commands/plan.md +1 -3
- package/templates/commands/review.md +7 -4
- package/templates/commands/spec-impl.md +68 -3
- package/templates/commands/spec-plan.md +1 -1
- package/templates/commands/spec-research.md +1 -1
- package/templates/commands/spec-review.md +1 -1
- package/templates/commands/team.md +1 -1
- package/templates/commands/test.md +1 -1
- package/templates/commands/workflow.md +1 -1
- 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/check-plugins.cjs +49 -0
- package/templates/scripts/spec-suggestion.cjs +266 -0
|
@@ -124,7 +124,7 @@ The diff will be reviewed before apply; do not assume it's been applied.
|
|
|
124
124
|
1. **优先 plugin spawn**(默认):装了 `codex@openai-codex` + gemini plugin(推荐 `gemini@gemini-ccgx` fork;或上游 `gemini@google-gemini` 配 repatch 脚本)→ 用 `Agent(subagent_type="codex:codex-rescue")` + `Agent(subagent_type="gemini:gemini-rescue")` 并行,主线接 ≤200 token 摘要。
|
|
125
125
|
2. **降级 codeagent-wrapper**(BC fallback):plugin 未装 → fallback Bash 调用,**保留 Step 4 session resume** 以维持会话上下文。
|
|
126
126
|
|
|
127
|
-
**判定**:preflight `Bash` 跑 `
|
|
127
|
+
**判定**:preflight `Bash` 跑 `node ~/.claude/.ccg/scripts/check-plugins.cjs`(解析 Claude Code 权威 `installed_plugins.json`)。exit `0` + stdout `{"codex":"<ver>","gemini":"<ver>"}` → 通道 A(plugin 默认);非 `0` → 通道 B(wrapper BC fallback)。
|
|
128
128
|
|
|
129
129
|
⚠️ spec-impl 命令在主线 context 内,**允许** `Agent(...)`——与 subagent "禁止嵌套 spawn" 约束不冲突。
|
|
130
130
|
⚠️ **plugin 路径无 session resume 能力**:通道 A 不接 Step 4 的 SESSION_ID,review 任务作为独立无状态分析跑(review 是 read-only 审查,无需会话连续性)。
|
|
@@ -269,13 +269,77 @@ Return ≤200 token structured summary.
|
|
|
269
269
|
- If below 80K: Ask user "Continue to next phase?"
|
|
270
270
|
- If approaching 80K: Suggest "Run `/clear` and resume with `/ccg:spec:impl`"
|
|
271
271
|
|
|
272
|
-
10. **
|
|
272
|
+
10. **Spec Evolution (RFC-9) — 归档前的可复用约定提炼**
|
|
273
|
+
|
|
274
|
+
在调用 `/opsx:archive` **之前**,从本次 change 的 git diff + REVIEW.md
|
|
275
|
+
抽取**可复用的、非显而易见的约定**。这是上游 V3 phase-guide.md §8 在
|
|
276
|
+
fork 的对应实现,借 `src/utils/spec-suggestion.ts` helper 完成:
|
|
277
|
+
|
|
278
|
+
a. **采集原料**(在 change 目录跑):
|
|
279
|
+
```bash
|
|
280
|
+
BASE=$(git merge-base HEAD origin/main 2>/dev/null || git rev-parse HEAD~1)
|
|
281
|
+
git diff "$BASE"..HEAD > .context/tmp/spec-evo-diff.txt
|
|
282
|
+
# REVIEW.md 路径由 Step 7 的 review 阶段产出,约定在
|
|
283
|
+
# openspec/changes/<change-id>/REVIEW.md
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
b. **调 helper CLI**(installer 安装到 `~/.claude/.ccg/scripts/spec-suggestion.cjs`,
|
|
287
|
+
零依赖 Node CJS。用户项目无需装 ccgx-workflow npm 包):
|
|
288
|
+
```bash
|
|
289
|
+
node ~/.claude/.ccg/scripts/spec-suggestion.cjs \
|
|
290
|
+
--diff .context/tmp/spec-evo-diff.txt \
|
|
291
|
+
--review openspec/changes/<change-id>/REVIEW.md \
|
|
292
|
+
--change-id <change-id> \
|
|
293
|
+
> .context/tmp/spec-evo-candidates.json
|
|
294
|
+
```
|
|
295
|
+
stdout = JSON 数组 `[{statement, rationale, source, category, rejectionReason?}, ...]`
|
|
296
|
+
exit 0 = 跑完(包括空结果);exit 1 = 文件读不到 / 参数错。
|
|
297
|
+
|
|
298
|
+
c. **解析 + 过滤 + 展示**:主线 Read `.context/tmp/spec-evo-candidates.json`,
|
|
299
|
+
按 `rejectionReason` 是否非空分两组:
|
|
300
|
+
- 全部 candidates 为空,或通过质量门的 `!c.rejectionReason` 都为 0 → **直接跳
|
|
301
|
+
到 Step 11**(无值得提炼,不强凑——这是 RFC-9 硬规约),并在主对话输出一句话:
|
|
302
|
+
"本次 Spec Evolution 无可复用约定,跳过 SPEC_EVOLUTION.md 创建。"
|
|
303
|
+
- 至少 1 个通过质量门 → 用 `AskUserQuestion` 列出 candidates(含 rejected 也展示
|
|
304
|
+
给用户审视,让用户看到 helper 拒了什么、为什么),用户**勾选**要保留的。
|
|
305
|
+
|
|
306
|
+
d. **写入**(仅用户勾选的条目):主线用 Write 工具直接构造 SPEC_EVOLUTION.md
|
|
307
|
+
内容(按下方模板渲染),写到 `openspec/changes/<change-id>/SPEC_EVOLUTION.md`:
|
|
308
|
+
|
|
309
|
+
```markdown
|
|
310
|
+
# Spec Evolution — <change-id>
|
|
311
|
+
|
|
312
|
+
> Captured <YYYY-MM-DD> during `/ccg:spec-impl` archive step.
|
|
313
|
+
> User-approved candidates only. Promote to `specs/<capability>/spec.md`
|
|
314
|
+
> via the next `/ccg:spec-research` iteration.
|
|
315
|
+
|
|
316
|
+
## backend
|
|
317
|
+
- **<statement>**
|
|
318
|
+
- *Why*: <rationale>
|
|
319
|
+
- *Source*: <source.kind> — `<source.excerpt>`
|
|
320
|
+
## frontend
|
|
321
|
+
...
|
|
322
|
+
## cross-cutting
|
|
323
|
+
...
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
按 candidate.category 分 backend / frontend / cross-cutting 三段(缺的段省略)。
|
|
327
|
+
|
|
328
|
+
**绝不**自动写入 `openspec/specs/` 主规范。SPEC_EVOLUTION.md 是"本次开发
|
|
329
|
+
学到的事"的审计文档,随 change 一起归档;提升为 `specs/<capability>/spec.md`
|
|
330
|
+
里的真正 SHALL 是下一轮 `/ccg:spec-research` 的工作。
|
|
331
|
+
|
|
332
|
+
e. **空跳**:通过质量门的 candidate 数 === 0 时**不创建** SPEC_EVOLUTION.md
|
|
333
|
+
(避免空文件污染归档)。在主对话输出一句话:"本次 Spec Evolution 无可复用约定。"
|
|
334
|
+
|
|
335
|
+
11. **Archive on Completion**
|
|
273
336
|
- When ALL tasks in `tasks.md` are marked `[x]`:
|
|
274
337
|
- Call `/opsx:archive` internally to archive the change:
|
|
275
338
|
```
|
|
276
339
|
/opsx:archive
|
|
277
340
|
```
|
|
278
|
-
- This merges spec deltas to `openspec/specs/` and moves change
|
|
341
|
+
- This merges spec deltas to `openspec/specs/` and moves change (including
|
|
342
|
+
`SPEC_EVOLUTION.md` from Step 10, if any) to `changes/archive/`.
|
|
279
343
|
- **Note**: This is an internal call. If archiving fails, guide the user to re-run `/ccg:spec-impl`.
|
|
280
344
|
|
|
281
345
|
**Reference**
|
|
@@ -288,5 +352,6 @@ Implementation is complete when:
|
|
|
288
352
|
- [ ] All tasks in `tasks.md` marked `[x]`
|
|
289
353
|
- [ ] All multi-model reviews passed
|
|
290
354
|
- [ ] Side-effect review confirmed no regressions
|
|
355
|
+
- [ ] Spec Evolution step ran (either wrote SPEC_EVOLUTION.md or explicitly reported "no reusable conventions")
|
|
291
356
|
- [ ] Change archived successfully
|
|
292
357
|
<!-- CCG:SPEC:IMPL:END -->
|
|
@@ -32,7 +32,7 @@ description: '多模型分析 → 消除歧义 → 零决策可执行计划'
|
|
|
32
32
|
1. **优先 plugin spawn**(默认):装了 `codex@openai-codex` + gemini plugin(推荐 `gemini@gemini-ccgx` fork;或上游 `gemini@google-gemini` 配 repatch 脚本)→ 用 `Agent(subagent_type="codex:codex-rescue")` + `Agent(subagent_type="gemini:gemini-rescue")` 并行,主线接 ≤200 token 摘要。
|
|
33
33
|
2. **降级 codeagent-wrapper**(BC fallback):plugin 未装 → fallback Bash 调用,行为与 plugin 路径等价。
|
|
34
34
|
|
|
35
|
-
**判定**:preflight `Bash` 跑 `
|
|
35
|
+
**判定**:preflight `Bash` 跑 `node ~/.claude/.ccg/scripts/check-plugins.cjs`(解析 Claude Code 权威 `installed_plugins.json`)。exit `0` + stdout `{"codex":"<ver>","gemini":"<ver>"}` → 通道 A(plugin 默认);非 `0` → 通道 B(wrapper BC fallback)。
|
|
36
36
|
|
|
37
37
|
⚠️ spec-plan 命令在主线 context 内,**允许** `Agent(...)`——与 subagent "禁止嵌套 spawn" 约束不冲突。
|
|
38
38
|
|
|
@@ -57,7 +57,7 @@ description: '需求 → 约束集(并行探索 + OPSX 提案)'
|
|
|
57
57
|
1. **优先 plugin spawn**(默认):装了 `codex@openai-codex` + gemini plugin(推荐 `gemini@gemini-ccgx` fork,含 P-1..P-21 + W1/W2/I1 patch;或上游 `gemini@google-gemini` 配 repatch 脚本)→ 用 `Agent(subagent_type="codex:codex-rescue")` + `Agent(subagent_type="gemini:gemini-rescue")` 并行,主线接 ≤200 token 摘要。
|
|
58
58
|
2. **降级 codeagent-wrapper**(BC fallback):plugin 未装 → fallback Bash 调用,行为与 plugin 路径等价。
|
|
59
59
|
|
|
60
|
-
**判定**:preflight `Bash` 跑 `
|
|
60
|
+
**判定**:preflight `Bash` 跑 `node ~/.claude/.ccg/scripts/check-plugins.cjs`(解析 Claude Code 权威 `installed_plugins.json`)。exit `0` + stdout `{"codex":"<ver>","gemini":"<ver>"}` → 通道 A(plugin 默认);非 `0` → 通道 B(wrapper BC fallback)。
|
|
61
61
|
|
|
62
62
|
⚠️ spec-research 命令在主线 context 内,**允许** `Agent(...)`——与 subagent "禁止嵌套 spawn" 约束不冲突。
|
|
63
63
|
|
|
@@ -33,7 +33,7 @@ description: '双模型交叉审查(独立工具,随时可用)'
|
|
|
33
33
|
1. **优先 plugin spawn**(默认):装了 `codex@openai-codex` + gemini plugin(推荐 `gemini@gemini-ccgx` fork;或上游 `gemini@google-gemini` 配 repatch 脚本)→ 用 `Agent(subagent_type="codex:codex-rescue")` + `Agent(subagent_type="gemini:gemini-rescue")` 并行,主线接 ≤200 token 摘要。
|
|
34
34
|
2. **降级 codeagent-wrapper**(BC fallback):plugin 未装 → fallback Bash 调用,行为与 plugin 路径等价。
|
|
35
35
|
|
|
36
|
-
**判定**:preflight `Bash` 跑 `
|
|
36
|
+
**判定**:preflight `Bash` 跑 `node ~/.claude/.ccg/scripts/check-plugins.cjs`(解析 Claude Code 权威 `installed_plugins.json`)。exit `0` + stdout `{"codex":"<ver>","gemini":"<ver>"}` → 通道 A(plugin 默认);非 `0` → 通道 B(wrapper BC fallback)。
|
|
37
37
|
|
|
38
38
|
⚠️ spec-review 命令在主线 context 内,**允许** `Agent(...)`——与 subagent "禁止嵌套 spawn" 约束不冲突。
|
|
39
39
|
|
|
@@ -164,7 +164,7 @@ Phase 8: INTEGRATION → Lead 全量验证 + 报告 + 清理
|
|
|
164
164
|
1. **优先 plugin spawn**(默认):装了 `codex@openai-codex` + gemini plugin → 用 `Agent(subagent_type="codex:codex-rescue")` + `Agent(subagent_type="gemini:gemini-rescue")` 并行,主线接 ≤200 token 摘要。
|
|
165
165
|
2. **降级 codeagent-wrapper**(BC fallback):plugin 未装 → fallback Bash 调用,行为与 plugin 路径等价。
|
|
166
166
|
|
|
167
|
-
**判定**:preflight `Bash` 跑 `
|
|
167
|
+
**判定**:preflight `Bash` 跑 `node ~/.claude/.ccg/scripts/check-plugins.cjs`(解析 Claude Code 权威 `installed_plugins.json`)。exit `0` + stdout `{"codex":"<ver>","gemini":"<ver>"}` → 通道 A(plugin 默认);非 `0` → 通道 B(wrapper BC fallback)。
|
|
168
168
|
|
|
169
169
|
- **CRITICAL**: 必须在一条消息中同时发起两个并行调用。
|
|
170
170
|
|
|
@@ -47,7 +47,7 @@ CCG 把双模型并行通道从 `Bash(codeagent-wrapper)` **默认切换**为 pl
|
|
|
47
47
|
1. **优先 plugin spawn**(默认):装了 `codex@openai-codex` + `gemini@google-gemini` plugin → 用 `Agent(subagent_type="codex:codex-rescue")` + `Agent(subagent_type="gemini:gemini-rescue")` 并行,主线接 ≤200 token 摘要。
|
|
48
48
|
2. **降级 codeagent-wrapper**(BC fallback):plugin 未装 → fallback 到 Bash 调用,行为与 plugin 路径等价。
|
|
49
49
|
|
|
50
|
-
**判定**:preflight `Bash` 跑 `
|
|
50
|
+
**判定**:preflight `Bash` 跑 `node ~/.claude/.ccg/scripts/check-plugins.cjs`(解析 Claude Code 权威 `installed_plugins.json`)。exit `0` + stdout `{"codex":"<ver>","gemini":"<ver>"}` → 通道 A(plugin 默认);非 `0` → 通道 B(wrapper BC fallback)。
|
|
51
51
|
|
|
52
52
|
⚠️ Test 命令在主线 context 内,**允许** `Agent(...)`——与 subagent "禁止嵌套 spawn" 约束不冲突。
|
|
53
53
|
|
|
@@ -44,7 +44,7 @@ subagent_freshness: required
|
|
|
44
44
|
1. **优先 plugin spawn**(默认):plugin 已装 → `Agent(subagent_type="<codex:codex-rescue|gemini:gemini-rescue>")`。session 复用通过 prompt 内 `--resume` flag 表达(subagent 映射为 codex/gemini `--resume-last`,**注意是 last,不是 by-id**)。
|
|
45
45
|
2. **降级 codeagent-wrapper**(BC fallback):plugin 未装 → Bash 调用,保留 `resume <SESSION_ID>` 显式会话管理。
|
|
46
46
|
|
|
47
|
-
**判定**:preflight `Bash` 跑 `
|
|
47
|
+
**判定**:preflight `Bash` 跑 `node ~/.claude/.ccg/scripts/check-plugins.cjs`(解析 Claude Code 权威 `installed_plugins.json`)。exit `0` + stdout `{"codex":"<ver>","gemini":"<ver>"}` → 通道 A(plugin 默认);非 `0` → 通道 B(wrapper BC fallback)。
|
|
48
48
|
|
|
49
49
|
---
|
|
50
50
|
|
|
@@ -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
|
+
};
|