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
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { h as collectSkills, j as resolvePluginBashCommand } from './shared/ccgx-workflow.
|
|
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.
|
|
3
|
-
import { existsSync,
|
|
4
|
-
import {
|
|
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.
|
|
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 });
|
|
@@ -1386,6 +1519,14 @@ async function installShim(ctx) {
|
|
|
1386
1519
|
await fs.chmod(destCheckPlugins, 493);
|
|
1387
1520
|
}
|
|
1388
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
|
+
}
|
|
1389
1530
|
if (process.platform === "win32") {
|
|
1390
1531
|
const cmdMjs = destMjs.replace(/\//g, "\\");
|
|
1391
1532
|
const cmdPath = join(binDir, "codeagent-wrapper.cmd");
|
|
@@ -4221,7 +4362,20 @@ async function readPackageVersion(pkgPath) {
|
|
|
4221
4362
|
return null;
|
|
4222
4363
|
}
|
|
4223
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
|
+
}
|
|
4224
4374
|
async function getCurrentVersion() {
|
|
4375
|
+
const buildVersion = await readBuildVersion();
|
|
4376
|
+
if (buildVersion) {
|
|
4377
|
+
return buildVersion;
|
|
4378
|
+
}
|
|
4225
4379
|
const relativePkgPath = fileURLToPath(new URL("../../package.json", import.meta.url));
|
|
4226
4380
|
const relativeVersion = await readPackageVersion(relativePkgPath);
|
|
4227
4381
|
if (relativeVersion) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccgx-workflow",
|
|
3
|
-
"version": "2.
|
|
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",
|
|
@@ -385,16 +385,19 @@ auto_round: <仅 --auto 模式,当前轮次 1-indexed>
|
|
|
385
385
|
|
|
386
386
|
### 5.4 多轮收敛(仅 `--fix --auto`)
|
|
387
387
|
|
|
388
|
-
`--auto`
|
|
388
|
+
`--auto` 模式下,**每完成一轮 review + fix 都把状态持久化到 `.context/fix-log.jsonl`**——这样 `/clear`、session 重启、context 压缩都不会丢历史。轮次推进算法:
|
|
389
389
|
|
|
390
|
-
1. Read REVIEW
|
|
391
|
-
2.
|
|
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
|
|
@@ -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 -->
|