ccgx-workflow 2.4.1 → 2.5.0

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.
Files changed (46) hide show
  1. package/README.md +134 -277
  2. package/README.zh-CN.md +134 -272
  3. package/dist/chunks/version-build.mjs +1 -1
  4. package/dist/cli.mjs +1 -1
  5. package/dist/index.d.mts +709 -16
  6. package/dist/index.d.ts +709 -16
  7. package/dist/index.mjs +1061 -30
  8. package/dist/shared/{ccgx-workflow.j1spUsik.mjs → ccgx-workflow.CdHnJLak.mjs} +106 -22
  9. package/package.json +1 -1
  10. package/templates/commands/agents/code-fixer.md +6 -6
  11. package/templates/commands/agents/phase-runner.md +46 -14
  12. package/templates/commands/agents/plan-checker.md +10 -0
  13. package/templates/commands/analyze.md +66 -25
  14. package/templates/commands/autonomous.md +428 -225
  15. package/templates/commands/cancel.md +9 -0
  16. package/templates/commands/codex-exec.md +12 -11
  17. package/templates/commands/context.md +14 -0
  18. package/templates/commands/debate.md +10 -6
  19. package/templates/commands/execute.md +76 -28
  20. package/templates/commands/optimize.md +53 -25
  21. package/templates/commands/plan.md +78 -28
  22. package/templates/commands/review.md +26 -19
  23. package/templates/commands/spec-impl.md +68 -127
  24. package/templates/commands/spec-plan.md +61 -82
  25. package/templates/commands/spec-research.md +35 -92
  26. package/templates/commands/spec-review.md +34 -119
  27. package/templates/commands/status.md +1 -0
  28. package/templates/commands/team-exec.md +45 -13
  29. package/templates/commands/team.md +64 -167
  30. package/templates/commands/test.md +56 -34
  31. package/templates/commands/verify-work.md +36 -13
  32. package/templates/commands/verify.md +35 -0
  33. package/templates/commands/workflow.md +22 -37
  34. package/templates/hooks/ccg-loop-detector.cjs +39 -8
  35. package/templates/hooks/ccg-statusline.js +142 -2
  36. package/templates/hooks/ccg-stop-gate.cjs +248 -19
  37. package/templates/hooks/ccg-subagent-context.cjs +505 -0
  38. package/templates/scripts/ccg-state-lock.cjs +510 -0
  39. package/templates/scripts/ccg-team-schedule.cjs +328 -0
  40. package/templates/scripts/ccgx-call-plugin.mjs +494 -141
  41. package/templates/scripts/invoke-model.mjs +28 -1
  42. package/templates/scripts/task-store.cjs +614 -0
  43. package/templates/skills/tools/verify-change/SKILL.md +7 -0
  44. package/templates/skills/tools/verify-module/SKILL.md +7 -0
  45. package/templates/skills/tools/verify-quality/SKILL.md +7 -0
  46. package/templates/skills/tools/verify-security/SKILL.md +8 -0
@@ -35,6 +35,18 @@ const ACTIVE_STATUSES = new Set(['queued', 'running']);
35
35
  const MAX_STATE_FILE_BYTES = 64 * 1024;
36
36
  const MAX_REPORT_JOBS = 5; // truncate to keep additionalContext tight
37
37
 
38
+ // ── Quality soft-feedback (P0-1) ────────────────────────────────────────
39
+ // On Stop we additionally surface unconverged quality state so the model
40
+ // does not silently end the turn mid-Ralph-loop / mid-UAT. Soft feedback
41
+ // only — same warn-don't-block semantics as the background-task scan.
42
+ const FIX_LOG_RELATIVE = path.join('.context', 'fix-log.jsonl');
43
+ const UAT_SUBDIR = path.join('.context', 'uat');
44
+ const MAX_FIXLOG_TAIL_BYTES = 16 * 1024; // tail-read only (~130 JSONL lines)
45
+ const MAX_UAT_FRONTMATTER_BYTES = 8 * 1024; // head-read per UAT.md
46
+ const MAX_UAT_DIRS = 10; // bounded scan of .context/uat/<task-id>/
47
+ const QUALITY_FRESHNESS_MS = 24 * 60 * 60 * 1000; // stale records stop nagging after 24h
48
+ const MAX_REPORT_QUALITY = 5;
49
+
38
50
  function readInput() {
39
51
  let raw = '';
40
52
  try {
@@ -91,6 +103,173 @@ function scanCcgJobs(cwd) {
91
103
  return active;
92
104
  }
93
105
 
106
+ // ── Quality soft-feedback scanners (P0-1) ───────────────────────────────
107
+
108
+ // Bounded tail read: never touch more than `maxBytes` no matter how big the
109
+ // file is. When the read starts mid-file, the first (partial) line is dropped.
110
+ // Any failure → null.
111
+ function readFileTail(filePath, maxBytes) {
112
+ let fd = null;
113
+ try {
114
+ const stat = fs.statSync(filePath);
115
+ if (!stat.isFile() || stat.size === 0) return null;
116
+ const readBytes = Math.min(stat.size, maxBytes);
117
+ const position = Math.max(0, stat.size - readBytes);
118
+ fd = fs.openSync(filePath, 'r');
119
+ const buf = Buffer.alloc(readBytes);
120
+ const bytesRead = fs.readSync(fd, buf, 0, readBytes, position);
121
+ let text = buf.toString('utf-8', 0, bytesRead);
122
+ if (position > 0) {
123
+ const nl = text.indexOf('\n');
124
+ text = nl === -1 ? '' : text.slice(nl + 1);
125
+ }
126
+ return text;
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ finally {
132
+ if (fd !== null) {
133
+ try { fs.closeSync(fd); }
134
+ catch { /* ignore */ }
135
+ }
136
+ }
137
+ }
138
+
139
+ // Bounded head read (for UAT.md frontmatter). Any failure → null.
140
+ function readFileHead(filePath, maxBytes) {
141
+ let fd = null;
142
+ try {
143
+ fd = fs.openSync(filePath, 'r');
144
+ const buf = Buffer.alloc(maxBytes);
145
+ const bytesRead = fs.readSync(fd, buf, 0, maxBytes, 0);
146
+ return buf.toString('utf-8', 0, bytesRead);
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ finally {
152
+ if (fd !== null) {
153
+ try { fs.closeSync(fd); }
154
+ catch { /* ignore */ }
155
+ }
156
+ }
157
+ }
158
+
159
+ // Scan .context/fix-log.jsonl (RFC-8 schema, consumed read-only) for an
160
+ // unconverged Ralph loop: the highest-round 'review' entry still has
161
+ // critical > 0 and is fresher than QUALITY_FRESHNESS_MS. Returns
162
+ // { round, critical, warning, info, ts, lastFixStatus } or null.
163
+ // Parsing mirrors readFixLog tolerance: bad lines are skipped; duplicate
164
+ // rounds → last entry wins (same as convergeHistoryFromLog).
165
+ function scanFixLogUnresolved(cwd, nowMs) {
166
+ const tail = readFileTail(path.join(cwd, FIX_LOG_RELATIVE), MAX_FIXLOG_TAIL_BYTES);
167
+ if (tail === null) return null;
168
+ const reviewByRound = new Map();
169
+ const fixStatusByRound = new Map();
170
+ for (const line of tail.split('\n')) {
171
+ const trimmed = line.trim();
172
+ if (!trimmed) continue;
173
+ let entry;
174
+ try {
175
+ entry = JSON.parse(trimmed);
176
+ }
177
+ catch {
178
+ continue; // malformed line — skip, never throw
179
+ }
180
+ if (!entry || typeof entry !== 'object') continue;
181
+ if (typeof entry.round !== 'number' || !Number.isInteger(entry.round) || entry.round < 1) continue;
182
+ if (entry.kind === 'review') {
183
+ const f = entry.findings;
184
+ if (!f || typeof f !== 'object'
185
+ || typeof f.critical !== 'number' || typeof f.warning !== 'number' || typeof f.info !== 'number') {
186
+ continue;
187
+ }
188
+ reviewByRound.set(entry.round, {
189
+ round: entry.round,
190
+ critical: f.critical,
191
+ warning: f.warning,
192
+ info: f.info,
193
+ ts: typeof entry.ts === 'string' ? entry.ts : '',
194
+ });
195
+ }
196
+ else if (entry.kind === 'fix' && typeof entry.status === 'string') {
197
+ fixStatusByRound.set(entry.round, entry.status);
198
+ }
199
+ }
200
+ if (reviewByRound.size === 0) return null;
201
+ let maxRound = 0;
202
+ for (const r of reviewByRound.keys()) {
203
+ if (r > maxRound) maxRound = r;
204
+ }
205
+ const last = reviewByRound.get(maxRound);
206
+ // critical === 0 means converged for soft-feedback purposes (Warnings do
207
+ // not nag — matches the --fix pass-through semantics).
208
+ if (!(last.critical > 0)) return null;
209
+ const tsMs = Date.parse(last.ts);
210
+ if (!Number.isFinite(tsMs) || nowMs - tsMs >= QUALITY_FRESHNESS_MS) return null;
211
+ return {
212
+ round: last.round,
213
+ critical: last.critical,
214
+ warning: last.warning,
215
+ info: last.info,
216
+ ts: last.ts,
217
+ lastFixStatus: fixStatusByRound.has(maxRound) ? fixStatusByRound.get(maxRound) : null,
218
+ };
219
+ }
220
+
221
+ // Scan .context/uat/<task-id>/UAT.md frontmatter (uat-session.ts schema,
222
+ // consumed read-only via regex — fields anchored: status / severity /
223
+ // symptom) for open gaps. Freshness uses file mtime (gap entries carry no
224
+ // timestamp). Returns [{ taskId, openCount, topSeverity, sampleSymptom }].
225
+ function scanUatGaps(cwd, nowMs) {
226
+ const uatDir = path.join(cwd, UAT_SUBDIR);
227
+ let entries;
228
+ try {
229
+ entries = fs.readdirSync(uatDir, { withFileTypes: true });
230
+ }
231
+ catch {
232
+ return [];
233
+ }
234
+ const severityRank = { critical: 4, high: 3, medium: 2, low: 1 };
235
+ const results = [];
236
+ const dirs = entries.filter((e) => e.isDirectory()).slice(0, MAX_UAT_DIRS);
237
+ for (const dir of dirs) {
238
+ const uatFile = path.join(uatDir, dir.name, 'UAT.md');
239
+ let stat;
240
+ try {
241
+ stat = fs.statSync(uatFile);
242
+ }
243
+ catch {
244
+ continue;
245
+ }
246
+ if (!stat.isFile()) continue;
247
+ if (nowMs - stat.mtimeMs >= QUALITY_FRESHNESS_MS) continue;
248
+ const head = readFileHead(uatFile, MAX_UAT_FRONTMATTER_BYTES);
249
+ if (head === null) continue;
250
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(head);
251
+ if (!fm) continue;
252
+ const gapLines = fm[1].match(/-\s*\{[^}]*status:\s*open[^}]*\}/g) || [];
253
+ if (gapLines.length === 0) continue;
254
+ let topSeverity = 'low';
255
+ let sampleSymptom = null;
256
+ for (const g of gapLines) {
257
+ const sev = /severity:\s*(critical|high|medium|low)/.exec(g);
258
+ const sevName = sev ? sev[1] : 'low';
259
+ if (severityRank[sevName] > severityRank[topSeverity]) topSeverity = sevName;
260
+ if (sampleSymptom === null) {
261
+ const sym = /symptom:\s*(?:"([^"]{1,80})|([^,}]{1,80}))/.exec(g);
262
+ if (sym) {
263
+ const text = (sym[1] || sym[2] || '').trim().slice(0, 80);
264
+ if (text) sampleSymptom = text;
265
+ }
266
+ }
267
+ }
268
+ results.push({ taskId: dir.name, openCount: gapLines.length, topSeverity, sampleSymptom });
269
+ }
270
+ return results;
271
+ }
272
+
94
273
  // Normalize harness-supplied background_tasks (field shape added in CC 2.1.145).
95
274
  // Shape is best-effort: we accept either an array of strings (task ids) or an
96
275
  // array of objects with at minimum a `task_id` or `id` field.
@@ -133,21 +312,50 @@ function mergeTasks(ccgJobs, harnessTasks) {
133
312
  return merged;
134
313
  }
135
314
 
136
- function composeWarning(eventName, tasks) {
137
- const limited = tasks.slice(0, MAX_REPORT_JOBS);
138
- const omitted = tasks.length - limited.length;
315
+ // Third param is optional ({ fixLog, uatGaps }) — legacy 2-arg calls keep
316
+ // the exact pre-P0-1 output.
317
+ function composeWarning(eventName, tasks, quality) {
318
+ const q = quality || {};
319
+ const fixLog = q.fixLog || null;
320
+ const uatGaps = Array.isArray(q.uatGaps) ? q.uatGaps : [];
139
321
  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)`);
322
+ if (tasks.length > 0) {
323
+ const limited = tasks.slice(0, MAX_REPORT_JOBS);
324
+ const omitted = tasks.length - limited.length;
325
+ lines.push(`⚠️ ${tasks.length} background task(s) still active at ${eventName}.`);
326
+ lines.push('Do not declare the user-facing work "complete" until these settle.');
327
+ lines.push('Options: tell the user "background X is still running, check /ccg:status <id>", or wait and re-summarize.');
328
+ lines.push('');
329
+ for (const t of limited) {
330
+ const phase = t.phase ? ` phase=${t.phase}` : '';
331
+ const summary = t.summary ? ` — ${t.summary}` : '';
332
+ lines.push(`- ${t.jobId} [${t.status}, ${t.sourcedFrom}]${phase}${summary}`);
333
+ }
334
+ if (omitted > 0) {
335
+ lines.push(`- ... ${omitted} more (truncated)`);
336
+ }
337
+ }
338
+ if (fixLog || uatGaps.length > 0) {
339
+ if (tasks.length > 0) lines.push('');
340
+ lines.push('🔁 Unconverged quality issues (soft feedback — do not silently end the turn):');
341
+ if (fixLog) {
342
+ const fixNote = fixLog.lastFixStatus ? ` (last fix: ${fixLog.lastFixStatus})` : '';
343
+ if (fixLog.lastFixStatus === 'escalated') {
344
+ lines.push(`- fix-log round ${fixLog.round}/3: ${fixLog.critical} Critical still open${fixNote} — escalated, awaiting user decision; do not auto-resume.`);
345
+ }
346
+ else {
347
+ lines.push(`- fix-log round ${fixLog.round}/3: ${fixLog.critical} Critical still open${fixNote}. If a /ccg:review --fix Ralph loop is in flight, continue the next round automatically; if escalated/aborted, tell the user explicitly.`);
348
+ }
349
+ }
350
+ const limitedGaps = uatGaps.slice(0, MAX_REPORT_QUALITY);
351
+ for (const g of limitedGaps) {
352
+ const symptom = g.sampleSymptom ? ` — "${g.sampleSymptom}"` : '';
353
+ lines.push(`- UAT ${g.taskId}: ${g.openCount} open gap(s), top severity ${g.topSeverity}${symptom}. Resume /ccg:verify-work or surface to the user.`);
354
+ }
355
+ if (uatGaps.length > limitedGaps.length) {
356
+ lines.push(`- ... ${uatGaps.length - limitedGaps.length} more UAT task(s) with open gaps (truncated)`);
357
+ }
358
+ lines.push('If the user explicitly asked to stop, just acknowledge these instead of resuming.');
151
359
  }
152
360
  lines.push('</ccg-stop-gate>');
153
361
  return lines.join('\n');
@@ -173,16 +381,29 @@ function main() {
173
381
  const harnessTasks = normalizeHarnessTasks(input);
174
382
  const merged = mergeTasks(ccgJobs, harnessTasks);
175
383
 
176
- if (merged.length === 0) {
384
+ // Quality soft-feedback only fires on Stop. SubagentStop fires between
385
+ // code-fixer rounds where un-cleared Criticals are the expected state —
386
+ // scanning there would be pure noise.
387
+ const isStop = eventName === 'Stop';
388
+ const quality = isStop
389
+ ? { fixLog: scanFixLogUnresolved(cwd, Date.now()), uatGaps: scanUatGaps(cwd, Date.now()) }
390
+ : { fixLog: null, uatGaps: [] };
391
+ const hasQuality = quality.fixLog !== null || quality.uatGaps.length > 0;
392
+
393
+ if (merged.length === 0 && !hasQuality) {
177
394
  process.exit(0);
178
395
  return;
179
396
  }
180
397
 
398
+ // Stop / SubagentStop hook schema does NOT accept `hookSpecificOutput` —
399
+ // that field is reserved for PreToolUse / UserPromptSubmit / PostToolUse /
400
+ // PostToolBatch. The valid top-level fields for Stop are `continue`,
401
+ // `suppressOutput`, `stopReason`, `decision`, `reason`, `systemMessage`,
402
+ // `terminalSequence`. We use `systemMessage` to surface the warning to the
403
+ // model without setting `decision` (which would force the model to keep
404
+ // working — wrong tool for "warn, don't block" semantics).
181
405
  process.stdout.write(JSON.stringify({
182
- hookSpecificOutput: {
183
- hookEventName: eventName,
184
- additionalContext: composeWarning(eventName, merged),
185
- },
406
+ systemMessage: composeWarning(eventName, merged, quality),
186
407
  }));
187
408
  process.exit(0);
188
409
  }
@@ -199,4 +420,12 @@ module.exports = {
199
420
  detectEvent,
200
421
  ACTIVE_STATUSES,
201
422
  MAX_REPORT_JOBS,
423
+ // Quality soft-feedback (P0-1)
424
+ readFileTail,
425
+ readFileHead,
426
+ scanFixLogUnresolved,
427
+ scanUatGaps,
428
+ QUALITY_FRESHNESS_MS,
429
+ MAX_FIXLOG_TAIL_BYTES,
430
+ MAX_UAT_DIRS,
202
431
  };