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.
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ // CCG Stop Gate Hook — Stop / SubagentStop
3
+ //
4
+ // When Claude is about to end its turn, check whether there are still
5
+ // active background tasks the user expects to finish. Emit a warning into
6
+ // additionalContext so the model does not falsely claim "done" while:
7
+ // - a CCG job (.context/jobs/<id>/state.json) is still queued/running
8
+ // - Claude Code's own harness reports background_tasks (added in 2.1.145)
9
+ //
10
+ // Why this matters: fork's async trio (status/result/cancel) and
11
+ // phase-runner-launcher wrap codex/gemini calls in long-lived OS subprocesses.
12
+ // Without this hook, the main thread frequently said "✅ complete" while a
13
+ // launcher was still mid-task. Users had to keep checking /ccg:status.
14
+ //
15
+ // Design choices:
16
+ // - Warn, never block. Setting `decision: "block"` would force the model to
17
+ // keep working, which is the wrong tool when the user explicitly said
18
+ // "stop" or when the model finished its main answer and the background
19
+ // task is fire-and-forget. We surface the info; the model decides.
20
+ // - Source-of-truth merge: harness-reported background_tasks ∪ CCG jobs.
21
+ // The two sets overlap heavily but neither is a superset. Harness sees
22
+ // bash run_in_background processes; CCG sees its own job registry.
23
+ // - Bounded scan: list the .context/jobs/ directory but never read a file
24
+ // bigger than 64 KB. Performance must not regress.
25
+ //
26
+ // Performance budget: <100 ms even with many jobs. Hook runs on every Stop.
27
+
28
+ 'use strict';
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+
33
+ const JOBS_SUBDIR = path.join('.context', 'jobs');
34
+ const ACTIVE_STATUSES = new Set(['queued', 'running']);
35
+ const MAX_STATE_FILE_BYTES = 64 * 1024;
36
+ const MAX_REPORT_JOBS = 5; // truncate to keep additionalContext tight
37
+
38
+ function readInput() {
39
+ let raw = '';
40
+ try {
41
+ raw = fs.readFileSync(0, 'utf-8');
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ try {
47
+ return JSON.parse(raw || '{}');
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function readJobState(stateFile) {
55
+ try {
56
+ const stat = fs.statSync(stateFile);
57
+ if (!stat.isFile() || stat.size > MAX_STATE_FILE_BYTES) return null;
58
+ const raw = fs.readFileSync(stateFile, 'utf-8');
59
+ return JSON.parse(raw);
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ // Returns array of { jobId, status, phase, summary, sourcedFrom: 'ccg-jobs' }.
67
+ function scanCcgJobs(cwd) {
68
+ const jobsDir = path.join(cwd, JOBS_SUBDIR);
69
+ let entries;
70
+ try {
71
+ entries = fs.readdirSync(jobsDir, { withFileTypes: true });
72
+ }
73
+ catch {
74
+ return [];
75
+ }
76
+ const active = [];
77
+ for (const entry of entries) {
78
+ if (!entry.isDirectory()) continue;
79
+ const jobId = entry.name;
80
+ const state = readJobState(path.join(jobsDir, jobId, 'state.json'));
81
+ if (!state || typeof state.status !== 'string') continue;
82
+ if (!ACTIVE_STATUSES.has(state.status)) continue;
83
+ active.push({
84
+ jobId,
85
+ status: state.status,
86
+ phase: typeof state.phase === 'string' ? state.phase : null,
87
+ summary: typeof state.summary === 'string' ? state.summary.slice(0, 120) : null,
88
+ sourcedFrom: 'ccg-jobs',
89
+ });
90
+ }
91
+ return active;
92
+ }
93
+
94
+ // Normalize harness-supplied background_tasks (field shape added in CC 2.1.145).
95
+ // Shape is best-effort: we accept either an array of strings (task ids) or an
96
+ // array of objects with at minimum a `task_id` or `id` field.
97
+ function normalizeHarnessTasks(input) {
98
+ const raw = input && input.background_tasks;
99
+ if (!Array.isArray(raw)) return [];
100
+ const out = [];
101
+ for (const item of raw) {
102
+ if (typeof item === 'string') {
103
+ out.push({ jobId: item, status: 'unknown', phase: null, summary: null, sourcedFrom: 'harness' });
104
+ }
105
+ else if (item && typeof item === 'object') {
106
+ const jobId = item.task_id || item.id || item.name || '(unnamed)';
107
+ const status = typeof item.status === 'string' ? item.status : 'unknown';
108
+ out.push({
109
+ jobId,
110
+ status,
111
+ phase: typeof item.phase === 'string' ? item.phase : null,
112
+ summary: typeof item.description === 'string' ? item.description.slice(0, 120) : null,
113
+ sourcedFrom: 'harness',
114
+ });
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ function mergeTasks(ccgJobs, harnessTasks) {
121
+ // Dedupe by jobId across the two sources (CCG jobs win on conflict because
122
+ // their status is authoritative — harness only knows "active").
123
+ const seen = new Set();
124
+ const merged = [];
125
+ for (const j of ccgJobs) {
126
+ seen.add(j.jobId);
127
+ merged.push(j);
128
+ }
129
+ for (const h of harnessTasks) {
130
+ if (seen.has(h.jobId)) continue;
131
+ merged.push(h);
132
+ }
133
+ return merged;
134
+ }
135
+
136
+ function composeWarning(eventName, tasks) {
137
+ const limited = tasks.slice(0, MAX_REPORT_JOBS);
138
+ const omitted = tasks.length - limited.length;
139
+ const lines = ['<ccg-stop-gate>'];
140
+ lines.push(`⚠️ ${tasks.length} background task(s) still active at ${eventName}.`);
141
+ lines.push('Do not declare the user-facing work "complete" until these settle.');
142
+ lines.push('Options: tell the user "background X is still running, check /ccg:status <id>", or wait and re-summarize.');
143
+ lines.push('');
144
+ for (const t of limited) {
145
+ const phase = t.phase ? ` phase=${t.phase}` : '';
146
+ const summary = t.summary ? ` — ${t.summary}` : '';
147
+ lines.push(`- ${t.jobId} [${t.status}, ${t.sourcedFrom}]${phase}${summary}`);
148
+ }
149
+ if (omitted > 0) {
150
+ lines.push(`- ... ${omitted} more (truncated)`);
151
+ }
152
+ lines.push('</ccg-stop-gate>');
153
+ return lines.join('\n');
154
+ }
155
+
156
+ function detectEvent(input) {
157
+ // Claude Code Stop/SubagentStop payloads carry the event name in
158
+ // `hook_event_name`. Default to 'Stop' if absent — the model treats the two
159
+ // identically, this only affects the echoed hookEventName field in our
160
+ // response envelope.
161
+ if (input && typeof input.hook_event_name === 'string') {
162
+ if (input.hook_event_name === 'SubagentStop') return 'SubagentStop';
163
+ }
164
+ return 'Stop';
165
+ }
166
+
167
+ function main() {
168
+ const input = readInput();
169
+ const cwd = (input && typeof input.cwd === 'string' && input.cwd) ? input.cwd : process.cwd();
170
+ const eventName = detectEvent(input);
171
+
172
+ const ccgJobs = scanCcgJobs(cwd);
173
+ const harnessTasks = normalizeHarnessTasks(input);
174
+ const merged = mergeTasks(ccgJobs, harnessTasks);
175
+
176
+ if (merged.length === 0) {
177
+ process.exit(0);
178
+ return;
179
+ }
180
+
181
+ process.stdout.write(JSON.stringify({
182
+ hookSpecificOutput: {
183
+ hookEventName: eventName,
184
+ additionalContext: composeWarning(eventName, merged),
185
+ },
186
+ }));
187
+ process.exit(0);
188
+ }
189
+
190
+ if (require.main === module) {
191
+ main();
192
+ }
193
+
194
+ module.exports = {
195
+ scanCcgJobs,
196
+ normalizeHarnessTasks,
197
+ mergeTasks,
198
+ composeWarning,
199
+ detectEvent,
200
+ ACTIVE_STATUSES,
201
+ MAX_REPORT_JOBS,
202
+ };
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // CCG plugin detection helper (v2.3.1+)
3
+ //
4
+ // Reads Claude Code's authoritative plugin registry at
5
+ // ~/.claude/plugins/installed_plugins.json and reports whether the
6
+ // codex and gemini plugins required by ccgx Channel A (plugin spawn)
7
+ // are registered.
8
+ //
9
+ // Exit codes:
10
+ // 0 = both plugins ok (use Channel A)
11
+ // 1 = at least one missing (use Channel B / wrapper BC fallback)
12
+ // 2 = registry missing or unparsable (use Channel B)
13
+ //
14
+ // stdout (single line JSON):
15
+ // {"codex": "<version>"|null, "gemini": "<version>"|null, "error"?: "<msg>"}
16
+
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const path = require('path');
20
+
21
+ const registryPath = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json');
22
+
23
+ function emit(payload, exitCode) {
24
+ process.stdout.write(JSON.stringify(payload) + '\n');
25
+ process.exit(exitCode);
26
+ }
27
+
28
+ try {
29
+ if (!fs.existsSync(registryPath)) {
30
+ emit({ codex: null, gemini: null, error: 'registry-missing' }, 2);
31
+ }
32
+
33
+ const raw = fs.readFileSync(registryPath, 'utf8');
34
+ const data = JSON.parse(raw);
35
+ const plugins = (data && data.plugins) || {};
36
+
37
+ const codexKey = Object.keys(plugins).find((k) => k.startsWith('codex@'));
38
+ const geminiKey = Object.keys(plugins).find((k) => k.startsWith('gemini@'));
39
+
40
+ const codexEntry = codexKey ? plugins[codexKey] : null;
41
+ const geminiEntry = geminiKey ? plugins[geminiKey] : null;
42
+
43
+ const codex = Array.isArray(codexEntry) && codexEntry[0] ? (codexEntry[0].version || 'unknown') : null;
44
+ const gemini = Array.isArray(geminiEntry) && geminiEntry[0] ? (geminiEntry[0].version || 'unknown') : null;
45
+
46
+ emit({ codex, gemini }, codex && gemini ? 0 : 1);
47
+ } catch (e) {
48
+ emit({ codex: null, gemini: null, error: String(e && e.message || e) }, 2);
49
+ }
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env node
2
+ // CCG Spec Suggestion CLI — RFC-9 F2 follow-up.
3
+ //
4
+ // Standalone Node CJS reimplementation of src/utils/spec-suggestion.ts. The
5
+ // TS version lives in CCG's own dist and is great for the `ccgx-workflow`
6
+ // package consumers; but `/ccg:spec-impl` runs in the USER's project, which
7
+ // generally has no dependency on ccgx-workflow. The model can't `import` a
8
+ // helper that isn't installed.
9
+ //
10
+ // installer.ts copies this file to ~/.claude/.ccg/scripts/spec-suggestion.cjs
11
+ // (alongside check-plugins.cjs and friends). The spec-impl.md step calls:
12
+ //
13
+ // node ~/.claude/.ccg/scripts/spec-suggestion.cjs \
14
+ // --diff <path-to-diff> \
15
+ // --review <path-to-review-md> \
16
+ // --change-id <id>
17
+ //
18
+ // stdout = JSON array of candidates (same shape as TS SpecCandidate[]).
19
+ // exit 0 = ran (including empty result).
20
+ // exit 1 = bad args / read failure / internal error.
21
+ //
22
+ // Logic must stay in lockstep with src/utils/spec-suggestion.ts. The shared
23
+ // test cases in __tests__/specSuggestionCjs.test.ts pin both implementations
24
+ // against the same fixtures.
25
+
26
+ 'use strict';
27
+
28
+ const fs = require('node:fs');
29
+
30
+ // ─────────────────────────────────────────────────────────────────
31
+ // Constants (must mirror spec-suggestion.ts)
32
+ // ─────────────────────────────────────────────────────────────────
33
+ const MIN_RATIONALE_CHARS = 40;
34
+ const MAX_STATEMENT_CHARS = 200;
35
+
36
+ const FILLER_PHRASES = [
37
+ '写好的代码',
38
+ '注意安全',
39
+ '小心',
40
+ '保持代码整洁',
41
+ '遵循最佳实践',
42
+ '请仔细审查',
43
+ 'best practice',
44
+ 'be careful',
45
+ 'write good code',
46
+ 'clean code',
47
+ 'follow best practices',
48
+ ];
49
+
50
+ 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|`[^`]+`)/;
51
+
52
+ const CATEGORY_HINTS = [
53
+ { 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' },
54
+ { kw: /\b(component|hook|jsx|tsx|css|tailwind|store|reducer|redux|zustand|pinia|router\.(get|push)|route|svelte|vue|react)\b/i, category: 'frontend' },
55
+ { kw: /(?:认证|授权|加密|后端|接口|数据库|迁移)/, category: 'backend' },
56
+ { kw: /(?:前端|组件|页面|样式|交互|路由跳转)/, category: 'frontend' },
57
+ ];
58
+
59
+ // ─────────────────────────────────────────────────────────────────
60
+ // Pure helpers
61
+ // ─────────────────────────────────────────────────────────────────
62
+
63
+ function categorizeStatement(statement, rationale) {
64
+ const haystack = `${statement} ${rationale}`;
65
+ for (const hint of CATEGORY_HINTS) {
66
+ if (hint.kw.test(haystack)) return hint.category;
67
+ }
68
+ return 'cross-cutting';
69
+ }
70
+
71
+ function checkQuality(statement, rationale) {
72
+ if (!statement || statement.length < 10) return 'statement too short (< 10 chars)';
73
+ if (statement.length > MAX_STATEMENT_CHARS) return `statement too long (> ${MAX_STATEMENT_CHARS} chars) — break it down`;
74
+ if (rationale.length < MIN_RATIONALE_CHARS) return `rationale too thin (< ${MIN_RATIONALE_CHARS} chars) — explain *why*`;
75
+ const lower = `${statement} ${rationale}`.toLowerCase();
76
+ for (const filler of FILLER_PHRASES) {
77
+ if (lower.includes(filler.toLowerCase())) return `filler phrase detected: "${filler}"`;
78
+ }
79
+ if (!SPECIFIC_REFERENCE_PATTERN.test(`${statement} ${rationale}`)) {
80
+ return 'no specific file / API / command reference — add a concrete anchor';
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ function extractFromReview(reviewMd) {
86
+ const out = [];
87
+ const findingRe = /^[#\-*\s]*(?:Finding\s+)?[CW]-?\d+\b[^\n]*(?:Critical|Warning|critical|warning)[^\n]*$/gm;
88
+ let match;
89
+ while ((match = findingRe.exec(reviewMd)) !== null) {
90
+ const start = match.index;
91
+ const tail = reviewMd.slice(start, start + 1200);
92
+ const lines = tail.split('\n').slice(0, 12);
93
+ const header = lines[0].trim();
94
+ const body = lines.slice(1).join(' ').replace(/\s+/g, ' ').trim();
95
+ const sugMatch = body.match(/(?:建议[::]|Suggested(?:\s+fix)?[::]|应当?[::]|Must[::]|Should[::])\s*([^\.。;;]+)/i);
96
+ const statement = (sugMatch && sugMatch[1] ? sugMatch[1] : header).slice(0, MAX_STATEMENT_CHARS).trim();
97
+ const rationale = body.slice(0, 400).trim();
98
+ const category = categorizeStatement(statement, rationale);
99
+ out.push({
100
+ statement,
101
+ rationale,
102
+ source: { kind: 'review', excerpt: header },
103
+ category,
104
+ rejectionReason: checkQuality(statement, rationale),
105
+ });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ function extractFromDiff(gitDiff) {
111
+ const out = [];
112
+ const lines = gitDiff.split('\n');
113
+ let currentFile = '';
114
+ const fileRe = /^\+\+\+ b\/(.+)$/;
115
+ const constraintRe = /^\+\s*(?:\/\/|#|--|\*)\s*(?:MUST|必须|invariant|约束|不变量|注意[::])\s*(.+)/i;
116
+ for (let i = 0; i < lines.length; i++) {
117
+ const line = lines[i];
118
+ const fm = line.match(fileRe);
119
+ if (fm) {
120
+ currentFile = fm[1];
121
+ continue;
122
+ }
123
+ const cm = line.match(constraintRe);
124
+ if (!cm) continue;
125
+ const statement = cm[1].trim().slice(0, MAX_STATEMENT_CHARS);
126
+ const context = lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4))
127
+ .map(l => l.replace(/^[+\-]\s?/, ''))
128
+ .join('\n').slice(0, 400);
129
+ const rationale = `In ${currentFile}: ${context}`.slice(0, 400);
130
+ const category = categorizeStatement(statement, rationale);
131
+ out.push({
132
+ statement,
133
+ rationale,
134
+ source: { kind: 'diff', excerpt: `${currentFile} (${cm[0].slice(0, 80)}...)` },
135
+ category,
136
+ rejectionReason: checkQuality(statement, rationale),
137
+ });
138
+ }
139
+ return out;
140
+ }
141
+
142
+ function dedupeByStatement(candidates) {
143
+ const seen = new Set();
144
+ const out = [];
145
+ for (const c of candidates) {
146
+ const key = c.statement.toLowerCase().replace(/\s+/g, ' ').trim();
147
+ if (seen.has(key)) continue;
148
+ seen.add(key);
149
+ out.push(c);
150
+ }
151
+ return out;
152
+ }
153
+
154
+ function extractCandidates(input) {
155
+ const out = [];
156
+ if (input.reviewMd) out.push(...extractFromReview(input.reviewMd));
157
+ if (input.gitDiff) out.push(...extractFromDiff(input.gitDiff));
158
+ return dedupeByStatement(out);
159
+ }
160
+
161
+ // ─────────────────────────────────────────────────────────────────
162
+ // CLI
163
+ // ─────────────────────────────────────────────────────────────────
164
+
165
+ const MAX_INPUT_BYTES = 5 * 1024 * 1024; // 5MB cap per input file
166
+
167
+ function readBoundedFile(path) {
168
+ // Defensive: a 1GB diff file would otherwise blow process memory. Real diffs
169
+ // for a single change rarely exceed 200KB; 5MB is generous.
170
+ try {
171
+ const stat = fs.statSync(path);
172
+ if (!stat.isFile()) return null;
173
+ if (stat.size > MAX_INPUT_BYTES) {
174
+ process.stderr.write(`spec-suggestion: ${path} exceeds ${MAX_INPUT_BYTES} bytes (${stat.size}) — refusing\n`);
175
+ return null;
176
+ }
177
+ return fs.readFileSync(path, 'utf-8');
178
+ }
179
+ catch (err) {
180
+ process.stderr.write(`spec-suggestion: cannot read ${path}: ${err.message}\n`);
181
+ return null;
182
+ }
183
+ }
184
+
185
+ function parseArgs(argv) {
186
+ // Minimal flag parser — keep deps zero. Recognized:
187
+ // --diff <path> path to git diff file
188
+ // --review <path> path to REVIEW.md
189
+ // --change-id <id> optional change identifier (passed through, not used)
190
+ // --help print usage
191
+ const out = { diff: null, review: null, changeId: null, help: false };
192
+ for (let i = 2; i < argv.length; i++) {
193
+ const a = argv[i];
194
+ if (a === '--help' || a === '-h') {
195
+ out.help = true;
196
+ }
197
+ else if (a === '--diff' && i + 1 < argv.length) {
198
+ out.diff = argv[++i];
199
+ }
200
+ else if (a === '--review' && i + 1 < argv.length) {
201
+ out.review = argv[++i];
202
+ }
203
+ else if (a === '--change-id' && i + 1 < argv.length) {
204
+ out.changeId = argv[++i];
205
+ }
206
+ else {
207
+ process.stderr.write(`spec-suggestion: unknown arg: ${a}\n`);
208
+ out.help = true;
209
+ }
210
+ }
211
+ return out;
212
+ }
213
+
214
+ function printUsage() {
215
+ process.stderr.write([
216
+ 'Usage: node spec-suggestion.cjs --diff <path> --review <path> [--change-id <id>]',
217
+ '',
218
+ 'Extracts spec-evolution candidates from a git diff + REVIEW.md and prints',
219
+ 'a JSON array to stdout. Used by /ccg:spec-impl Step 10 (RFC-9).',
220
+ '',
221
+ 'At least one of --diff or --review must be provided.',
222
+ ].join('\n') + '\n');
223
+ }
224
+
225
+ function main(argv) {
226
+ const args = parseArgs(argv);
227
+ if (args.help || (!args.diff && !args.review)) {
228
+ printUsage();
229
+ return 1;
230
+ }
231
+
232
+ const gitDiff = args.diff ? readBoundedFile(args.diff) : '';
233
+ const reviewMd = args.review ? readBoundedFile(args.review) : '';
234
+
235
+ // If both paths were given but neither readable, fail loud. If only one was
236
+ // given and unreadable, also fail (the user expected it to be there).
237
+ if (args.diff && gitDiff === null) return 1;
238
+ if (args.review && reviewMd === null) return 1;
239
+
240
+ const candidates = extractCandidates({
241
+ gitDiff: gitDiff || undefined,
242
+ reviewMd: reviewMd || undefined,
243
+ changeId: args.changeId || undefined,
244
+ });
245
+
246
+ process.stdout.write(JSON.stringify(candidates, null, 2) + '\n');
247
+ return 0;
248
+ }
249
+
250
+ if (require.main === module) {
251
+ process.exit(main(process.argv));
252
+ }
253
+
254
+ module.exports = {
255
+ extractCandidates,
256
+ extractFromReview,
257
+ extractFromDiff,
258
+ categorizeStatement,
259
+ checkQuality,
260
+ dedupeByStatement,
261
+ parseArgs,
262
+ main,
263
+ MIN_RATIONALE_CHARS,
264
+ MAX_STATEMENT_CHARS,
265
+ MAX_INPUT_BYTES,
266
+ };