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/index.mjs CHANGED
@@ -1,7 +1,8 @@
1
- import { h as collectSkills, j as resolvePluginBashCommand } from './shared/ccgx-workflow.BYuVglaM.mjs';
2
- export { k as changeLanguage, G as checkForUpdates, I as collectInvocableSkills, H as compareVersions, m as createDefaultConfig, n as createDefaultRouting, J as generateCommandContent, o as getCcgDir, p as getConfigPath, E as getCurrentVersion, F as getLatestVersion, t as getWorkflowById, q as getWorkflowConfigs, c as i18n, e as init, b as initI18n, y as installAceTool, z as installAceToolRs, K as installSkillCommands, x as installWorkflows, C as migrateToV1_4_0, D as needsMigration, L as parseFrontmatter, a as readCcgConfig, s as showMainMenu, B as uninstallAceTool, A as uninstallWorkflows, u as update, l as writeCcgConfig } from './shared/ccgx-workflow.BYuVglaM.mjs';
3
- import { existsSync, readFileSync, mkdirSync, writeFileSync, statSync, readdirSync } from 'node:fs';
4
- import { join, dirname } from 'node:path';
1
+ import { h as collectSkills, j as resolvePluginBashCommand } from './shared/ccgx-workflow.j1spUsik.mjs';
2
+ export { k as changeLanguage, G as checkForUpdates, I as collectInvocableSkills, H as compareVersions, m as createDefaultConfig, n as createDefaultRouting, J as generateCommandContent, o as getCcgDir, p as getConfigPath, E as getCurrentVersion, F as getLatestVersion, t as getWorkflowById, q as getWorkflowConfigs, c as i18n, e as init, b as initI18n, y as installAceTool, z as installAceToolRs, K as installSkillCommands, x as installWorkflows, C as migrateToV1_4_0, D as needsMigration, L as parseFrontmatter, a as readCcgConfig, s as showMainMenu, B as uninstallAceTool, A as uninstallWorkflows, u as update, l as writeCcgConfig } from './shared/ccgx-workflow.j1spUsik.mjs';
3
+ import { mkdirSync, appendFileSync, existsSync, statSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
4
+ import { dirname, resolve } from 'pathe';
5
+ import { join, dirname as dirname$1 } from 'node:path';
5
6
  import { homedir } from 'node:os';
6
7
  import { execSync } from 'node:child_process';
7
8
  import 'ansis';
@@ -9,12 +10,246 @@ import 'inquirer';
9
10
  import 'ora';
10
11
  import 'node:util';
11
12
  import 'node:url';
12
- import 'pathe';
13
13
  import 'fs-extra';
14
14
  import 'smol-toml';
15
15
  import 'node:path/posix';
16
16
  import 'i18next';
17
17
 
18
+ const FIX_LOG_RELATIVE_PATH = ".context/fix-log.jsonl";
19
+ function resolveFixLogPath(workdir) {
20
+ return resolve(workdir, FIX_LOG_RELATIVE_PATH);
21
+ }
22
+ function appendFixLogEntry(workdir, entry) {
23
+ validateEntry(entry);
24
+ const logPath = resolveFixLogPath(workdir);
25
+ mkdirSync(dirname(logPath), { recursive: true });
26
+ appendFileSync(logPath, `${JSON.stringify(entry)}
27
+ `, "utf-8");
28
+ }
29
+ function validateEntry(entry) {
30
+ if (!entry || typeof entry !== "object") {
31
+ throw new TypeError("fix-log entry must be an object");
32
+ }
33
+ const e = entry;
34
+ if (e.kind !== "review" && e.kind !== "fix") {
35
+ throw new TypeError(`fix-log entry.kind must be 'review' or 'fix' (got ${String(e.kind)})`);
36
+ }
37
+ if (typeof e.ts !== "string" || !e.ts) {
38
+ throw new TypeError("fix-log entry.ts must be a non-empty ISO string");
39
+ }
40
+ if (typeof e.round !== "number" || !Number.isInteger(e.round) || e.round < 1) {
41
+ throw new TypeError(`fix-log entry.round must be a positive integer (got ${e.round})`);
42
+ }
43
+ if (e.kind === "review") {
44
+ const f = e.findings;
45
+ if (!f || typeof f !== "object" || typeof f.critical !== "number" || typeof f.warning !== "number" || typeof f.info !== "number") {
46
+ throw new TypeError("review entry.findings must include numeric critical/warning/info");
47
+ }
48
+ }
49
+ if (e.kind === "fix") {
50
+ const allowed = /* @__PURE__ */ new Set(["completed", "partial", "escalated", "failed"]);
51
+ if (typeof e.status !== "string" || !allowed.has(e.status)) {
52
+ throw new TypeError(`fix entry.status must be one of completed|partial|escalated|failed (got ${String(e.status)})`);
53
+ }
54
+ if (!Array.isArray(e.commits)) {
55
+ throw new TypeError("fix entry.commits must be an array");
56
+ }
57
+ for (let i = 0; i < e.commits.length; i++) {
58
+ if (typeof e.commits[i] !== "string") {
59
+ throw new TypeError(`fix entry.commits[${i}] must be a string (got ${typeof e.commits[i]})`);
60
+ }
61
+ }
62
+ }
63
+ }
64
+ const MAX_LOG_FILE_BYTES = 5 * 1024 * 1024;
65
+ function readFixLog(workdir) {
66
+ const logPath = resolveFixLogPath(workdir);
67
+ if (!existsSync(logPath)) return [];
68
+ let raw;
69
+ try {
70
+ const st = statSync(logPath);
71
+ if (!st.isFile() || st.size > MAX_LOG_FILE_BYTES) return [];
72
+ raw = readFileSync(logPath, "utf-8");
73
+ } catch {
74
+ return [];
75
+ }
76
+ const out = [];
77
+ for (const line of raw.split("\n")) {
78
+ const trimmed = line.trim();
79
+ if (!trimmed) continue;
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(trimmed);
83
+ } catch {
84
+ continue;
85
+ }
86
+ try {
87
+ validateEntry(parsed);
88
+ out.push(parsed);
89
+ } catch {
90
+ continue;
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+ function convergeHistoryFromLog(entries) {
96
+ const byRound = /* @__PURE__ */ new Map();
97
+ for (const e of entries) {
98
+ if (e.kind !== "review") continue;
99
+ byRound.set(e.round, { round: e.round, findings: { ...e.findings } });
100
+ }
101
+ return [...byRound.values()].sort((a, b) => a.round - b.round);
102
+ }
103
+ function loadConvergeHistory(workdir) {
104
+ return convergeHistoryFromLog(readFixLog(workdir));
105
+ }
106
+
107
+ const MIN_RATIONALE_CHARS = 40;
108
+ const MAX_STATEMENT_CHARS = 200;
109
+ const FILLER_PHRASES = [
110
+ "\u5199\u597D\u7684\u4EE3\u7801",
111
+ "\u6CE8\u610F\u5B89\u5168",
112
+ "\u5C0F\u5FC3",
113
+ "\u4FDD\u6301\u4EE3\u7801\u6574\u6D01",
114
+ "\u9075\u5FAA\u6700\u4F73\u5B9E\u8DF5",
115
+ "\u8BF7\u4ED4\u7EC6\u5BA1\u67E5",
116
+ "best practice",
117
+ "be careful",
118
+ "write good code",
119
+ "clean code",
120
+ "follow best practices"
121
+ ];
122
+ const SPECIFIC_REFERENCE_PATTERN = /(?:[a-zA-Z0-9_-]+\.[a-zA-Z]{1,8}\b|\w+\/\w+|\b[A-Z][a-zA-Z0-9]{2,}\b|`[^`]+`)/;
123
+ const CATEGORY_HINTS = [
124
+ { kw: /\b(api|endpoint|route|router|middleware|jwt|oauth|session|cookie|sql|schema|migration|database|orm|prisma|drizzle|knex|graphql|grpc|kafka|redis)\b/i, category: "backend" },
125
+ { kw: /\b(component|hook|jsx|tsx|css|tailwind|store|reducer|redux|zustand|pinia|router\.(get|push)|route|svelte|vue|react)\b/i, category: "frontend" },
126
+ // CJK 关键词在 cross-cutting 之外补一组
127
+ { kw: /(?:认证|授权|加密|后端|接口|数据库|迁移)/, category: "backend" },
128
+ { kw: /(?:前端|组件|页面|样式|交互|路由跳转)/, category: "frontend" }
129
+ ];
130
+ function categorizeStatement(statement, rationale) {
131
+ const haystack = `${statement} ${rationale}`;
132
+ for (const hint of CATEGORY_HINTS) {
133
+ if (hint.kw.test(haystack)) return hint.category;
134
+ }
135
+ return "cross-cutting";
136
+ }
137
+ function checkQuality(statement, rationale) {
138
+ if (!statement || statement.length < 10) return "statement too short (< 10 chars)";
139
+ if (statement.length > MAX_STATEMENT_CHARS) return `statement too long (> ${MAX_STATEMENT_CHARS} chars) \u2014 break it down`;
140
+ if (rationale.length < MIN_RATIONALE_CHARS) return `rationale too thin (< ${MIN_RATIONALE_CHARS} chars) \u2014 explain *why*`;
141
+ const lower = `${statement} ${rationale}`.toLowerCase();
142
+ for (const filler of FILLER_PHRASES) {
143
+ if (lower.includes(filler.toLowerCase())) return `filler phrase detected: "${filler}"`;
144
+ }
145
+ if (!SPECIFIC_REFERENCE_PATTERN.test(`${statement} ${rationale}`)) {
146
+ return "no specific file / API / command reference \u2014 add a concrete anchor";
147
+ }
148
+ return void 0;
149
+ }
150
+ function safeSlice(s, start, end) {
151
+ let e = Math.min(end, s.length);
152
+ if (e > 0 && e < s.length) {
153
+ const code = s.charCodeAt(e - 1);
154
+ if (code >= 55296 && code <= 56319) e--;
155
+ }
156
+ return s.slice(start, e);
157
+ }
158
+ function extractFromReview(reviewMd, _changeId) {
159
+ const out = [];
160
+ const findingRe = /^[#\-*\s]*(?:Finding\s+)?[CW]-?\d+\b[^\n]*(?:Critical|Warning|critical|warning)[^\n]*$/gm;
161
+ let match;
162
+ while ((match = findingRe.exec(reviewMd)) !== null) {
163
+ const start = match.index;
164
+ const tail = safeSlice(reviewMd, start, start + 1200);
165
+ const lines = tail.split("\n").slice(0, 12);
166
+ const header = lines[0].trim();
167
+ const body = lines.slice(1).join(" ").replace(/\s+/g, " ").trim();
168
+ const sugMatch = body.match(/(?:建议[::]|Suggested(?:\s+fix)?[::]|应当?[::]|Must[::]|Should[::])\s*([^\.。;;]+)/i);
169
+ const statement = safeSlice(sugMatch?.[1] ?? header, 0, MAX_STATEMENT_CHARS).trim();
170
+ const rationale = safeSlice(body, 0, 400).trim();
171
+ const category = categorizeStatement(statement, rationale);
172
+ out.push({
173
+ statement,
174
+ rationale,
175
+ source: { kind: "review", excerpt: header },
176
+ category,
177
+ rejectionReason: checkQuality(statement, rationale)
178
+ });
179
+ }
180
+ return out;
181
+ }
182
+ function extractFromDiff(gitDiff) {
183
+ const out = [];
184
+ const lines = gitDiff.split("\n");
185
+ let currentFile = "";
186
+ const fileRe = /^\+\+\+ b\/(.+)$/;
187
+ const constraintRe = /^\+\s*(?:\/\/|#|--|\*)\s*(?:MUST|必须|invariant|约束|不变量|注意[::])\s*(.+)/i;
188
+ for (let i = 0; i < lines.length; i++) {
189
+ const line = lines[i];
190
+ const fm = line.match(fileRe);
191
+ if (fm) {
192
+ currentFile = fm[1];
193
+ continue;
194
+ }
195
+ const cm = line.match(constraintRe);
196
+ if (!cm) continue;
197
+ const statement = safeSlice(cm[1].trim(), 0, MAX_STATEMENT_CHARS);
198
+ const context = safeSlice(lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).map((l) => l.replace(/^[+\-]\s?/, "")).join("\n"), 0, 400);
199
+ const rationale = safeSlice(`In ${currentFile}: ${context}`, 0, 400);
200
+ const category = categorizeStatement(statement, rationale);
201
+ out.push({
202
+ statement,
203
+ rationale,
204
+ source: { kind: "diff", excerpt: `${currentFile} (${cm[0].slice(0, 80)}...)` },
205
+ category,
206
+ rejectionReason: checkQuality(statement, rationale)
207
+ });
208
+ }
209
+ return out;
210
+ }
211
+ function extractCandidates(input) {
212
+ const out = [];
213
+ if (input.reviewMd) out.push(...extractFromReview(input.reviewMd, input.changeId));
214
+ if (input.gitDiff) out.push(...extractFromDiff(input.gitDiff));
215
+ return dedupeByStatement(out);
216
+ }
217
+ function dedupeByStatement(candidates) {
218
+ const seen = /* @__PURE__ */ new Set();
219
+ const out = [];
220
+ for (const c of candidates) {
221
+ const key = c.statement.toLowerCase().replace(/\s+/g, " ").trim();
222
+ if (seen.has(key)) continue;
223
+ seen.add(key);
224
+ out.push(c);
225
+ }
226
+ return out;
227
+ }
228
+ function formatAsMarkdown(approved, meta) {
229
+ if (approved.length === 0) {
230
+ return "";
231
+ }
232
+ const lines = [];
233
+ lines.push(`# Spec Evolution \u2014 ${meta.changeId}`);
234
+ lines.push("");
235
+ lines.push(`> Captured ${meta.isoDate} during \`/ccg:spec-impl\` archive step.`);
236
+ lines.push("> User-approved candidates only. Promote to `specs/<capability>/spec.md` via the next `/ccg:spec-research` iteration.");
237
+ lines.push("");
238
+ for (const category of ["backend", "frontend", "cross-cutting"]) {
239
+ const inCat = approved.filter((c) => c.category === category);
240
+ if (inCat.length === 0) continue;
241
+ lines.push(`## ${category}`);
242
+ lines.push("");
243
+ for (const c of inCat) {
244
+ lines.push(`- **${c.statement}**`);
245
+ lines.push(` - *Why*: ${c.rationale}`);
246
+ lines.push(` - *Source*: ${c.source.kind} \u2014 \`${c.source.excerpt.slice(0, 120)}\``);
247
+ }
248
+ lines.push("");
249
+ }
250
+ return lines.join("\n").trimEnd() + "\n";
251
+ }
252
+
18
253
  function phaseDir(workdir, phase) {
19
254
  return join(workdir, ".context", sanitizePhase(phase));
20
255
  }
@@ -88,7 +323,7 @@ function serializeList(values) {
88
323
  }
89
324
  function writeContext(workdir, ctx) {
90
325
  const target = contextPath(workdir, ctx.phase);
91
- mkdirSync(dirname(target), { recursive: true });
326
+ mkdirSync(dirname$1(target), { recursive: true });
92
327
  const frontmatter = [
93
328
  "---",
94
329
  `phase: ${serializeScalar(ctx.phase)}`,
@@ -141,7 +376,7 @@ function readContext(workdir, phase) {
141
376
  }
142
377
  function writeSummary(workdir, summary) {
143
378
  const target = summaryPath(workdir, summary.phase);
144
- mkdirSync(dirname(target), { recursive: true });
379
+ mkdirSync(dirname$1(target), { recursive: true });
145
380
  const frontmatter = [
146
381
  "---",
147
382
  `phase: ${serializeScalar(summary.phase)}`,
@@ -2025,4 +2260,4 @@ function hasBlockingFindings(report) {
2025
2260
  return report.findings.some((f) => f.severity === "critical");
2026
2261
  }
2027
2262
 
2028
- export { ALL_LAYERS, CONTEXT_BUDGET_THRESHOLD, DESCRIPTION_SOFT_LIMIT, ROUTING_SCHEMA_VERSION, aggregatePlans, auditSkillDescriptions, auditSkillsDirectory, auditTarballContents, batchByMaxConcurrent, bothPluginsInstalled, buildQualityPlan, buildWaves, cascadeSkip, collectSkills, contextPath, decideFromSummaries, detectPlugin, detectPluginAvailability, estimateBriefLength, estimateTokens, extractFrontmatter, criticalFindings as interfaceAuditCriticals, hasBlockingFindings as interfaceAuditHasBlocking, majorFindings as interfaceAuditMajors, isLayer, parseChallengerSummary, parseDependsOn, parseFrontmatterFields, parseInterfaceAuditorReport, parseQualityFlag, parseRoadmap, parseRoleFlag, parseVerifyReport, phaseDir, planChallengerSpawns, planVerifyWave, planWavesForTier, promptFilePath, readContext, readSummary, readSummaryFrontmatter, renderAuditMarkdown, renderPipelineReport, resolveQualityTier, routeSpecialist, runPipelineCheck, runPnpmPack, sampleAll, sampleHookSchema, samplePackageStructure, samplePluginList, sampleSkillList, sanitizePhase, schedule, serializeBriefForPrompt, summarizeGroundTruth, summaryPath, summaryTokenEstimate, synthesizeRevisionFeedback, synthesizeVerifyFeedback, synthesizeVerifyResults, verifyAllCommandsIncluded, writeContext, writeSummary };
2263
+ export { ALL_LAYERS, CONTEXT_BUDGET_THRESHOLD, DESCRIPTION_SOFT_LIMIT, FIX_LOG_RELATIVE_PATH, MAX_STATEMENT_CHARS, MIN_RATIONALE_CHARS, ROUTING_SCHEMA_VERSION, aggregatePlans, appendFixLogEntry, auditSkillDescriptions, auditSkillsDirectory, auditTarballContents, batchByMaxConcurrent, bothPluginsInstalled, buildQualityPlan, buildWaves, cascadeSkip, categorizeStatement, checkQuality, collectSkills, contextPath, convergeHistoryFromLog, decideFromSummaries, detectPlugin, detectPluginAvailability, estimateBriefLength, estimateTokens, extractCandidates, extractFrontmatter, formatAsMarkdown, criticalFindings as interfaceAuditCriticals, hasBlockingFindings as interfaceAuditHasBlocking, majorFindings as interfaceAuditMajors, isLayer, loadConvergeHistory, parseChallengerSummary, parseDependsOn, parseFrontmatterFields, parseInterfaceAuditorReport, parseQualityFlag, parseRoadmap, parseRoleFlag, parseVerifyReport, phaseDir, planChallengerSpawns, planVerifyWave, planWavesForTier, promptFilePath, readContext, readFixLog, readSummary, readSummaryFrontmatter, renderAuditMarkdown, renderPipelineReport, resolveFixLogPath, resolveQualityTier, routeSpecialist, runPipelineCheck, runPnpmPack, sampleAll, sampleHookSchema, samplePackageStructure, samplePluginList, sampleSkillList, sanitizePhase, schedule, serializeBriefForPrompt, summarizeGroundTruth, summaryPath, summaryTokenEstimate, synthesizeRevisionFeedback, synthesizeVerifyFeedback, synthesizeVerifyResults, verifyAllCommandsIncluded, writeContext, writeSummary };
@@ -13,7 +13,7 @@ import { join as join$2 } from 'node:path/posix';
13
13
  import { join as join$1 } from 'node:path';
14
14
  import i18next from 'i18next';
15
15
 
16
- const version = "2.3.0";
16
+ const version = "2.4.1";
17
17
 
18
18
  function cmd(id, order, category, name, nameEn, description, descriptionEn, cmdOverride) {
19
19
  return {
@@ -87,11 +87,20 @@ function getAllCommandIds() {
87
87
  const HOOK_FILES = [
88
88
  "ccg-context-monitor.js",
89
89
  "ccg-statusline.js",
90
- "ccg-session-state.cjs"
90
+ "ccg-session-state.cjs",
91
+ "ccg-loop-detector.cjs",
92
+ "ccg-skill-router.cjs",
93
+ "ccg-stop-gate.cjs"
91
94
  ];
92
95
  const POST_TOOL_MATCHER = "Bash|Edit|Write|MultiEdit|Agent|Task";
93
96
  const POST_TOOL_TIMEOUT_SEC = 10;
94
97
  const SESSION_START_TIMEOUT_SEC = 15;
98
+ const USER_PROMPT_TIMEOUT_SEC = 5;
99
+ const STOP_TIMEOUT_SEC = 5;
100
+ const CCG_SKILL_OVERRIDES = {
101
+ "codex:rescue": "user-invocable-only",
102
+ "gemini:rescue": "user-invocable-only"
103
+ };
95
104
  function buildHookCommand(hookFilePath) {
96
105
  return `node "${hookFilePath}"`;
97
106
  }
@@ -129,6 +138,9 @@ async function patchSettingsJson(ctx) {
129
138
  const monitorPath = join(hooksDir, "ccg-context-monitor.js");
130
139
  const statuslinePath = join(hooksDir, "ccg-statusline.js");
131
140
  const sessionStatePath = join(hooksDir, "ccg-session-state.cjs");
141
+ const loopDetectorPath = join(hooksDir, "ccg-loop-detector.cjs");
142
+ const skillRouterPath = join(hooksDir, "ccg-skill-router.cjs");
143
+ const stopGatePath = join(hooksDir, "ccg-stop-gate.cjs");
132
144
  let settings = {};
133
145
  if (await fs.pathExists(settingsPath)) {
134
146
  try {
@@ -182,6 +194,77 @@ async function patchSettingsJson(ctx) {
182
194
  modified = true;
183
195
  }
184
196
  }
197
+ if (await fs.pathExists(loopDetectorPath)) {
198
+ if (!settings.hooks) settings.hooks = {};
199
+ if (!Array.isArray(settings.hooks.UserPromptSubmit)) settings.hooks.UserPromptSubmit = [];
200
+ const alreadyRegistered = settings.hooks.UserPromptSubmit.some(
201
+ (entry) => entry?.hooks?.some((h) => typeof h?.command === "string" && h.command.includes("ccg-loop-detector"))
202
+ );
203
+ if (!alreadyRegistered) {
204
+ settings.hooks.UserPromptSubmit.push({
205
+ matcher: "",
206
+ hooks: [
207
+ {
208
+ type: "command",
209
+ command: buildHookCommand(loopDetectorPath),
210
+ timeout: USER_PROMPT_TIMEOUT_SEC
211
+ }
212
+ ]
213
+ });
214
+ modified = true;
215
+ }
216
+ }
217
+ if (await fs.pathExists(skillRouterPath)) {
218
+ if (!settings.hooks) settings.hooks = {};
219
+ if (!Array.isArray(settings.hooks.UserPromptSubmit)) settings.hooks.UserPromptSubmit = [];
220
+ const alreadyRegistered = settings.hooks.UserPromptSubmit.some(
221
+ (entry) => entry?.hooks?.some((h) => typeof h?.command === "string" && h.command.includes("ccg-skill-router"))
222
+ );
223
+ if (!alreadyRegistered) {
224
+ settings.hooks.UserPromptSubmit.push({
225
+ matcher: "",
226
+ hooks: [
227
+ {
228
+ type: "command",
229
+ command: buildHookCommand(skillRouterPath),
230
+ timeout: USER_PROMPT_TIMEOUT_SEC
231
+ }
232
+ ]
233
+ });
234
+ modified = true;
235
+ }
236
+ }
237
+ if (await fs.pathExists(stopGatePath)) {
238
+ if (!settings.hooks) settings.hooks = {};
239
+ for (const eventName of ["Stop", "SubagentStop"]) {
240
+ if (!Array.isArray(settings.hooks[eventName])) settings.hooks[eventName] = [];
241
+ const alreadyRegistered = settings.hooks[eventName].some(
242
+ (entry) => entry?.hooks?.some((h) => typeof h?.command === "string" && h.command.includes("ccg-stop-gate"))
243
+ );
244
+ if (!alreadyRegistered) {
245
+ settings.hooks[eventName].push({
246
+ matcher: "",
247
+ hooks: [
248
+ {
249
+ type: "command",
250
+ command: buildHookCommand(stopGatePath),
251
+ timeout: STOP_TIMEOUT_SEC
252
+ }
253
+ ]
254
+ });
255
+ modified = true;
256
+ }
257
+ }
258
+ }
259
+ if (typeof settings.skillOverrides !== "object" || settings.skillOverrides === null) {
260
+ settings.skillOverrides = {};
261
+ }
262
+ for (const [skillName, level] of Object.entries(CCG_SKILL_OVERRIDES)) {
263
+ if (settings.skillOverrides[skillName] !== level) {
264
+ settings.skillOverrides[skillName] = level;
265
+ modified = true;
266
+ }
267
+ }
185
268
  if (await fs.pathExists(statuslinePath)) {
186
269
  const existing = settings.statusLine?.command;
187
270
  if (!existing) {
@@ -250,6 +333,24 @@ async function uninstallHooks(installDir) {
250
333
  modified = true;
251
334
  }
252
335
  }
336
+ if (Array.isArray(settings.hooks?.UserPromptSubmit)) {
337
+ const before = settings.hooks.UserPromptSubmit.length;
338
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter((entry) => {
339
+ const hasCcg = entry?.hooks?.some(
340
+ (h) => typeof h?.command === "string" && (h.command.includes("ccg-loop-detector") || h.command.includes("ccg-skill-router"))
341
+ );
342
+ return !hasCcg;
343
+ });
344
+ if (settings.hooks.UserPromptSubmit.length !== before) modified = true;
345
+ if (settings.hooks.UserPromptSubmit.length === 0) {
346
+ delete settings.hooks.UserPromptSubmit;
347
+ modified = true;
348
+ }
349
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) {
350
+ delete settings.hooks;
351
+ modified = true;
352
+ }
353
+ }
253
354
  if (Array.isArray(settings.hooks?.SessionStart)) {
254
355
  const before = settings.hooks.SessionStart.length;
255
356
  settings.hooks.SessionStart = settings.hooks.SessionStart.filter((entry) => {
@@ -268,10 +369,42 @@ async function uninstallHooks(installDir) {
268
369
  modified = true;
269
370
  }
270
371
  }
372
+ for (const eventName of ["Stop", "SubagentStop"]) {
373
+ if (Array.isArray(settings.hooks?.[eventName])) {
374
+ const before = settings.hooks[eventName].length;
375
+ settings.hooks[eventName] = settings.hooks[eventName].filter((entry) => {
376
+ const hasCcg = entry?.hooks?.some(
377
+ (h) => typeof h?.command === "string" && h.command.includes("ccg-stop-gate")
378
+ );
379
+ return !hasCcg;
380
+ });
381
+ if (settings.hooks[eventName].length !== before) modified = true;
382
+ if (settings.hooks[eventName].length === 0) {
383
+ delete settings.hooks[eventName];
384
+ modified = true;
385
+ }
386
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) {
387
+ delete settings.hooks;
388
+ modified = true;
389
+ }
390
+ }
391
+ }
271
392
  if (typeof settings.statusLine?.command === "string" && settings.statusLine.command.includes("ccg-statusline")) {
272
393
  delete settings.statusLine;
273
394
  modified = true;
274
395
  }
396
+ if (settings.skillOverrides && typeof settings.skillOverrides === "object") {
397
+ for (const skillName of Object.keys(CCG_SKILL_OVERRIDES)) {
398
+ if (skillName in settings.skillOverrides) {
399
+ delete settings.skillOverrides[skillName];
400
+ modified = true;
401
+ }
402
+ }
403
+ if (Object.keys(settings.skillOverrides).length === 0) {
404
+ delete settings.skillOverrides;
405
+ modified = true;
406
+ }
407
+ }
275
408
  if (modified) {
276
409
  try {
277
410
  await fs.writeJSON(settingsPath, settings, { spaces: 2 });
@@ -1378,6 +1511,22 @@ async function installShim(ctx) {
1378
1511
  await fs.chmod(destCallPlugin, 493);
1379
1512
  }
1380
1513
  }
1514
+ const srcCheckPlugins = join(ctx.templateDir, "scripts", "check-plugins.cjs");
1515
+ if (await fs.pathExists(srcCheckPlugins)) {
1516
+ const destCheckPlugins = join(scriptDir, "check-plugins.cjs");
1517
+ await fs.copy(srcCheckPlugins, destCheckPlugins, { overwrite: true });
1518
+ if (process.platform !== "win32") {
1519
+ await fs.chmod(destCheckPlugins, 493);
1520
+ }
1521
+ }
1522
+ const srcSpecSuggestion = join(ctx.templateDir, "scripts", "spec-suggestion.cjs");
1523
+ if (await fs.pathExists(srcSpecSuggestion)) {
1524
+ const destSpecSuggestion = join(scriptDir, "spec-suggestion.cjs");
1525
+ await fs.copy(srcSpecSuggestion, destSpecSuggestion, { overwrite: true });
1526
+ if (process.platform !== "win32") {
1527
+ await fs.chmod(destSpecSuggestion, 493);
1528
+ }
1529
+ }
1381
1530
  if (process.platform === "win32") {
1382
1531
  const cmdMjs = destMjs.replace(/\//g, "\\");
1383
1532
  const cmdPath = join(binDir, "codeagent-wrapper.cmd");
@@ -4213,7 +4362,20 @@ async function readPackageVersion(pkgPath) {
4213
4362
  return null;
4214
4363
  }
4215
4364
  }
4365
+ async function readBuildVersion() {
4366
+ try {
4367
+ const mod = await import('../chunks/version-build.mjs');
4368
+ const v = mod?.BUILD_TIME_VERSION;
4369
+ return typeof v === "string" && /^\d+\.\d+\.\d+/.test(v) ? v : null;
4370
+ } catch {
4371
+ return null;
4372
+ }
4373
+ }
4216
4374
  async function getCurrentVersion() {
4375
+ const buildVersion = await readBuildVersion();
4376
+ if (buildVersion) {
4377
+ return buildVersion;
4378
+ }
4217
4379
  const relativePkgPath = fileURLToPath(new URL("../../package.json", import.meta.url));
4218
4380
  const relativeVersion = await readPackageVersion(relativePkgPath);
4219
4381
  if (relativeVersion) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccgx-workflow",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "description": "Multi-model orchestration for Claude Code. Codex + Gemini parallel collaboration with fresh-context subagent protocols, OS-level process isolation, and Plan-Critic-Verify quality tiers. Successor to ccg-workflow.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.17.1",
@@ -42,7 +42,7 @@ CCG 把双模型并行通道从 `Bash(codeagent-wrapper)` **默认切换**为 pl
42
42
  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 摘要。
43
43
  2. **降级 codeagent-wrapper**(BC fallback):plugin 未装 → fallback 到 Bash 调用,行为与 plugin 路径等价。
44
44
 
45
- **判定**:preflight `Bash` 跑 `ls ~/.claude/plugins/` 看有无 `codex@*` / `gemini@*` 子目录。helper `src/utils/plugin-detection.ts`。
45
+ **判定**: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)。
46
46
 
47
47
  ⚠️ Analyze 命令在主线 context 内,**允许** `Agent(...)`——与 subagent "禁止嵌套 spawn" 约束不冲突。
48
48
 
@@ -51,7 +51,7 @@ $ARGUMENTS
51
51
  1. **优先 plugin spawn**(默认):plugin 已装 → `Agent(subagent_type="codex:codex-rescue")`,session 复用通过 prompt 内 `--resume` flag 表达(subagent 映射为 codex `--resume-last`)。
52
52
  2. **降级 codeagent-wrapper**(BC fallback):plugin 未装 → Bash 调用,保留 `resume <SESSION_ID>` 显式会话管理。
53
53
 
54
- **判定**:preflight `Bash` 跑 `ls ~/.claude/plugins/` 看有无 `codex@*` / `gemini@*` 子目录。
54
+ **判定**: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)。
55
55
 
56
56
  **会话模型差异**:
57
57
  - **通道 A(plugin)**:session 是同一 Claude session 内的最近 codex/gemini thread(`--resume-last`)。同任务多 phase 顺序执行 OK;不支持跨任务跳点 resume by ID。
@@ -47,11 +47,10 @@ argument-hint: "<topic> [--max-rounds N] [--layer backend|frontend|fullstack]"
47
47
 
48
48
  1. 读 `$ARGUMENTS`,第一个非 flag token 即 topic(用引号包裹整段任务描述)
49
49
  2. 解析 `--max-rounds N` / `--layer X`
50
- 3. **检测 plugin 可用性(纯目录探测,禁止运行时探活)**:
51
- - 用 `Bash` 跑 `ls ~/.claude/plugins/ 2>/dev/null` `codex@*` / `gemini@*` 前缀子目录
52
- - 子目录内须含 `SKILL.md` / `plugin.json` / `package.json` / `manifest.json` 任一 marker `installed: true`
50
+ 3. **检测 plugin 可用性(解析 installed_plugins.json,禁止运行时探活)**:
51
+ - 用 `Bash` 跑 `node ~/.claude/.ccg/scripts/check-plugins.cjs`(解析 Claude Code 权威 `~/.claude/plugins/installed_plugins.json` 注册表)
52
+ - 返回 JSON `{"codex":"<ver>"|null,"gemini":"<ver>"|null}` + exit code(`0` = plugin 都在;非 `0` = 至少一个缺)→ 各自标 `installed: true/false`
53
53
  - ⛔ **严禁**调用 `/gemini:status` / `/codex:status` / 任何 broker / runtime / health 探活——broker 是**懒启动**的,启动后才有;当前 `brokerRunning: false` 不代表 plugin 不可用
54
- - 等价 helper:`detectPluginAvailability()`(`src/utils/plugin-detection.ts:156`)
55
54
  - 任一 plugin 真未装 → 该模型走 general-purpose 降级路径
56
55
  4. 调用 helper:`debateStateMachine(topic, { maxRounds, layer, pluginsAvailable })` 得到 `DebateRoundPlan[]`
57
56
 
@@ -27,9 +27,7 @@ CCG 把 6 核心命令的"双模型并行"通道从 `Bash(codeagent-wrapper)` **
27
27
  1. **优先 plugin spawn 路径**(默认):用户已装 `codex@openai-codex` 和 gemini plugin(推荐 `gemini@gemini-ccgx` fork,已含全部 patch;或上游 `gemini@google-gemini` 配 repatch)→ 用 `Agent(subagent_type="codex:codex-rescue")` + `Agent(subagent_type="gemini:gemini-rescue")` 并行 spawn,主线只接 plugin 自家 ≤200 token 摘要。
28
28
  2. **降级 codeagent-wrapper 路径**(BC fallback):plugin 未装 → fallback 到 `Bash(~/.claude/bin/codeagent-wrapper ...)`,行为与 plugin 路径等价。
29
29
 
30
- **判断方法**:preflight `Bash` 跑 `ls ~/.claude/plugins/ 2>/dev/null | grep -E '^(codex|gemini)@'`;两个 plugin 独立判定。
31
-
32
- **单一真相源**:`src/utils/plugin-detection.ts`(导出 `detectPlugin` / `detectPluginAvailability` / `bothPluginsInstalled`)。
30
+ **判断方法**:preflight `Bash` 跑 `node ~/.claude/.ccg/scripts/check-plugins.cjs`(解析 Claude Code 权威 `installed_plugins.json`)。返回 JSON `{"codex":"<ver>"|null,"gemini":"<ver>"|null}`,两个 plugin 独立判定,可 mix-and-match(仅 codex 装了 → backend 走 plugin、frontend 走 codeagent)。exit `0` = 两 plugin 都在;非 `0` = 至少一个缺。
33
31
 
34
32
  ⚠️ Execute 命令在主线 context 内,**允许**调 `Agent(...)`——与 subagent "引擎层禁止嵌套 spawn" 约束不冲突。
35
33
 
@@ -47,7 +47,7 @@ CCG 把双模型并行通道从 `Bash(codeagent-wrapper)` **默认切换**为 pl
47
47
  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 摘要。
48
48
  2. **降级 codeagent-wrapper**(BC fallback):plugin 未装 → fallback 到 Bash 调用,行为与 plugin 路径等价。
49
49
 
50
- **判定**:preflight `Bash` 跑 `ls ~/.claude/plugins/` 看有无 `codex@*` / `gemini@*` 子目录。helper `src/utils/plugin-detection.ts`。
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
  ⚠️ Optimize 命令在主线 context 内,**允许** `Agent(...)`——与 subagent "禁止嵌套 spawn" 约束不冲突。
53
53
 
@@ -57,9 +57,7 @@ CCG 把 6 核心命令的"双模型并行"通道从 `Bash(codeagent-wrapper)` **
57
57
  1. **优先 plugin spawn 路径**(默认):用户已装 `codex@openai-codex` 和 gemini plugin(推荐 `gemini@gemini-ccgx` fork,已含全部 patch;或上游 `gemini@google-gemini` 配 repatch)→ 用 `Agent(subagent_type="codex:codex-rescue")` + `Agent(subagent_type="gemini:gemini-rescue")` 并行 spawn,主线只接 plugin 自家 ≤200 token 摘要协议(`STATUS: ... / FINDINGS: ... / NOTES: ...`)。
58
58
  2. **降级 codeagent-wrapper 路径**(BC fallback):plugin 未装 → fallback 到 `Bash(~/.claude/bin/codeagent-wrapper --backend ... resume ... <<'EOF' ... EOF)`,与 plugin 路径行为等价。
59
59
 
60
- **判断方法**:preflight `Bash` 跑 `ls ~/.claude/plugins/ 2>/dev/null | grep -E '^codex@'` `... | grep -E '^gemini@'` 各一次。匹配到对应行 → plugin 已装。两个 plugin 独立判定,可分别 mix-and-match(仅 codex plugin 装了 → backend 走 plugin、frontend 走 codeagent)。
61
-
62
- **单一真相源**:plugin 检测逻辑 helper 见 `src/utils/plugin-detection.ts`(导出 `detectPlugin` / `detectPluginAvailability` / `bothPluginsInstalled`)。命令模板把判定结果渲染到执行计划,`Agent(...)` 调用与 `Bash(...)` 调用的具体语法见下面"多模型调用规范"段。
60
+ **判断方法**:preflight `Bash` 跑 `node ~/.claude/.ccg/scripts/check-plugins.cjs`(解析 Claude Code 权威 `installed_plugins.json`)。返回 JSON `{"codex":"<ver>"|null,"gemini":"<ver>"|null}`,两个 plugin 独立判定,可分别 mix-and-match(仅 codex plugin 装了 → backend 走 plugin、frontend 走 codeagent)。exit `0` = 两 plugin 都在;非 `0` = 至少一个缺。`Agent(...)` 调用与 `Bash(...)` 调用的具体语法见下面"多模型调用规范"段。
63
61
 
64
62
  **为什么默认 plugin**:nested-spawn 测试证明 plugin advisor 摘要协议(≤200 token)压制了 codeagent stdout 全文回灌主线的痛点,主线 context 增量从 +5%/调用 降到 +1.5%/调用。详见 `.ccg-research/07-multimodel-collaboration-rethink.md`。
65
63
 
@@ -21,7 +21,7 @@ argument-hint: "[代码或描述] [--adversarial] [--fix [--all] [--auto]] [--ro
21
21
 
22
22
  双模型并行审查,交叉验证综合反馈。无参数时自动审查当前 git 变更。
23
23
 
24
- **双模型并行通道**:默认走 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 摘要;plugin 未装 → fallback 到 codeagent-wrapper 路径(BC fallback)。preflight `Bash` 跑 `ls ~/.claude/plugins/` 检测,helper `src/utils/plugin-detection.ts`。
24
+ **双模型并行通道**:默认走 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 摘要;plugin 未装 → fallback 到 codeagent-wrapper 路径(BC fallback)。preflight `Bash` 跑 `node ~/.claude/.ccg/scripts/check-plugins.cjs`(解析 Claude Code 权威 `installed_plugins.json`,exit `0` + stdout `{"codex":"<ver>","gemini":"<ver>"}` = 两 plugin 都在)。
25
25
 
26
26
  `--adversarial` 模式下额外触发第三层"敌对视角"审查,由官方 codex plugin 的 `Agent(codex:codex-rescue)` 在 fresh context 中专门挑前两轮意见的漏洞,适合极重要 PR / 安全敏感变更。需用户已装 `codex@openai-codex` plugin,否则降级为双模型审查。
27
27
 
@@ -385,16 +385,19 @@ auto_round: <仅 --auto 模式,当前轮次 1-indexed>
385
385
 
386
386
  ### 5.4 多轮收敛(仅 `--fix --auto`)
387
387
 
388
- `--auto` 模式下,code-fixer 1 轮跑完后:
388
+ `--auto` 模式下,**每完成一轮 review + fix 都把状态持久化到 `.context/fix-log.jsonl`**——这样 `/clear`、session 重启、context 压缩都不会丢历史。轮次推进算法:
389
389
 
390
- 1. Read REVIEW-FIX.md,提取本轮 finding 数(critical / warning / info
391
- 2. 调用收敛判定(参考 `src/utils/code-fixer-worktree.ts:decideConverge`):
390
+ 1. 每轮 review 完成 → 主线 Read REVIEW.md finding `appendFixLogEntry(workdir, {kind:'review', ts:<ISO>, round:N, findings:{critical,warning,info}})` 持久化
391
+ 2. 每轮 fix 完成 → 同样 `appendFixLogEntry(workdir, {kind:'fix', ts, round:N, status, commits:[<sha7>...]})`
392
+ 3. 判定下一步:先 `loadConvergeHistory(workdir)` 从 disk 重建 ConvergeRound[](**不**用 context 内史,因为可能已丢),再调 `decideConverge(history)`(参考 `src/utils/code-fixer-worktree.ts:decideConverge`):
392
393
  - `converged`(critical+warning=0)→ 完成,输出收敛报告
393
394
  - `escalate`(达到 3 轮 cap 或 stall)→ `AskUserQuestion`:"继续手动修 / 接受现状 / 回滚全部"
394
395
  - `continue` → 重跑阶段 1-4 生成新 REVIEW.md,再 spawn code-fixer,`auto_round` +1
395
396
 
396
397
  **3 轮上限是 CCG 硬规约**(与 plan-checker / verify-work 一致),**禁止**绕过。
397
398
 
399
+ **为什么走 disk 不走 context**:fork 早期版本把 ConvergeRound[] 留在主线 context;用户在 round 2 `/clear` 后继续 `--auto` → 主线见空 history → 当成 round 1 重启 → 永远到不了 escalate cap。fix-log.jsonl 在 disk 上 append-only,跨会话稳如磐石。`.context/fix-log.jsonl` 已加入 `.gitignore`(项目本地审计,不入 repo)。
400
+
398
401
  ### 5.5 闭环修复总结
399
402
 
400
403
  ```markdown