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
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // =============================================================================
3
- // ccgx-call-plugin.mjs 1.0.5
3
+ // ccgx-call-plugin.mjs 1.1.0
4
+ // -----------------------------------------------------------------------------
5
+ // 1.1.0: gemini-ccgx fork version gate (>=1.3.0) + gemini --prompt-file path,
6
+ // --timeout-ms passthrough + win32 tree-kill, upstream gemini-companion
7
+ // fallback (positional prompt + warnings), try/finally tmp cleanup.
4
8
  // -----------------------------------------------------------------------------
5
9
  // Pure-Node helper for invoking codex/gemini plugin companions WITHOUT any
6
10
  // shell-escape risk. Replaces the 1.0.4 heredoc-via-Bash pattern.
@@ -27,25 +31,44 @@
27
31
  // --prompt-file /tmp/ccg-codex-1234.txt
28
32
  // 3. Parse the JSON output emitted to stdout
29
33
  //
30
- // Output schema (always JSON, even on error):
34
+ // Output schema (always JSON, even on error) — THREE layers, flattened:
35
+ //
36
+ // Layer 1 (this helper, the OUTER envelope):
31
37
  // {
32
38
  // "status": "ok" | "error",
33
39
  // "vendor": "codex" | "gemini",
34
40
  // "version": "<plugin version>",
35
- // "durationMs": <number>,
41
+ // "durationMs": <number>, // helper-measured wall time
36
42
  // "exitCode": <number | null>,
37
- // "stdout": "<companion stdout>",
43
+ // "stdout": "<companion stdout>", // raw Layer-2 JSON string (kept for debug)
38
44
  // "stderr": "<companion stderr>",
39
- // "error": "<error message if status=error, else absent>"
45
+ // "error": "<message if status=error, else absent>",
46
+ // "warnings": ["..."], // only when flags were dropped (upstream
47
+ // // gemini-companion fallback), else absent
48
+ //
49
+ // // ---- flattened from Layer 2 (companion envelope) when --json & parseable:
50
+ // "text": "<model textual output>", // codex/gemini final answer (rawOutput)
51
+ // "threadId": "<session id | null>", // for resume; null if absent
52
+ // "stats": { ... } | null // tokens / latency etc, vendor-verbatim
40
53
  // }
41
54
  //
55
+ // Layer 2 = the companion's own JSON envelope, emitted on its stdout. For
56
+ // gemini-batch it is {status, threadId, rawOutput, stats, ...}; for
57
+ // codex-companion `task --json` it is the job-result envelope. The helper
58
+ // JSON.parse(stdout)s it and lifts rawOutput→text, session→threadId,
59
+ // stats→stats so the LLM never has to reach into a nested string.
60
+ // Layer 3 = the model's textual answer itself, already surfaced as `.text`.
61
+ //
42
62
  // CLI args:
43
- // <vendor> Required. 'codex' or 'gemini'.
44
- // --prompt-file <path> Required. Path to file containing prompt body.
45
- // --json Pass --json to companion (default: true).
46
- // --no-json Disable --json (text output).
47
- // --timeout-ms <N> Kill companion if total wall-time exceeds N ms (default: 7200000, 2h; pass 0 to disable).
48
- // --max-budget-usd <N> Forwarded to companion (default: 50).
63
+ // <vendor> Required. 'codex' or 'gemini'.
64
+ // --prompt-file <path> Required. Path to file containing prompt body.
65
+ // --json Pass --json to companion (default: true).
66
+ // --no-json Disable --json (text output).
67
+ // --timeout-ms <N> Kill companion if total wall-time exceeds N ms (default: 7200000, 2h; pass 0 to disable).
68
+ // --resume-last Resume the most recent thread instead of a fresh one (codex live; gemini requires fork gemini-ccgx >=1.3.0).
69
+ // --include-directories <l> Extra read dirs for gemini (comma list); requires fork gemini-ccgx >=1.3.0; ignored by codex.
70
+ // --mcp-allow <names> Selectively re-enable named MCP servers (comma list); requires fork gemini-ccgx >=1.3.0; empty = vendor default (codex keeps, gemini suppresses all).
71
+ // --max-budget-usd <N> NO-OP soft hint (no plugin reads it today; see C10).
49
72
  //
50
73
  // Cross-cutting:
51
74
  // - Pure stdlib (fs, child_process, path, os). No deps.
@@ -54,9 +77,10 @@
54
77
  // - spawn uses array args + windowsHide:true → zero shell escape surface.
55
78
  // =============================================================================
56
79
 
57
- import { spawn } from 'node:child_process'
58
- import { existsSync, readFileSync, realpathSync, unlinkSync } from 'node:fs'
59
- import { homedir, platform } from 'node:os'
80
+ import { randomUUID } from 'node:crypto'
81
+ import { spawn, spawnSync } from 'node:child_process'
82
+ import { existsSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs'
83
+ import { homedir, tmpdir } from 'node:os'
60
84
  import { basename, join, resolve, sep } from 'node:path'
61
85
  import { fileURLToPath } from 'node:url'
62
86
 
@@ -94,6 +118,49 @@ const VENDOR_ENTRY_SCRIPTS = {
94
118
  gemini: ['gemini-batch.mjs', 'gemini-companion.mjs'],
95
119
  }
96
120
 
121
+ // ---------------------------------------------------------------------------
122
+ // gemini-ccgx fork capability gate.
123
+ //
124
+ // The fork's flag surface is version-dependent:
125
+ // >=1.3.0 : --prompt-file / --resume-last / --mcp-allow / --include-directories
126
+ // / --timeout-ms / --idle-timeout-ms are all live
127
+ // <1.3.0 : --resume-last is a SILENT NO-OP, --mcp-allow and
128
+ // --include-directories THROW unknown-flag, prompt must inline
129
+ // via -p (subject to the Windows ~32K argv ceiling)
130
+ // ---------------------------------------------------------------------------
131
+
132
+ const MIN_GEMINI_FORK_VERSION = '1.3.0'
133
+
134
+ // Hard ceiling for prompts inlined into a single argv element. Windows'
135
+ // CreateProcess command line tops out at 32767 chars and Node spawn throws
136
+ // ENAMETOOLONG synchronously past it; 30000 leaves headroom for the
137
+ // companion path + remaining flags.
138
+ const INLINE_PROMPT_MAX_CHARS = 30000
139
+
140
+ // Dependency-free semver compare → -1 / 0 / 1. Numeric triplets only;
141
+ // non-numeric segments (and 'unknown') count as 0, so an unparseable
142
+ // version conservatively sorts below every real release.
143
+ function compareSemver(a, b) {
144
+ const pa = String(a).split('.')
145
+ const pb = String(b).split('.')
146
+ for (let i = 0; i < 3; i++) {
147
+ const x = Number.parseInt(pa[i], 10)
148
+ const y = Number.parseInt(pb[i], 10)
149
+ const xv = Number.isFinite(x) ? x : 0
150
+ const yv = Number.isFinite(y) ? y : 0
151
+ if (xv !== yv) return xv < yv ? -1 : 1
152
+ }
153
+ return 0
154
+ }
155
+
156
+ function isGeminiBatchFork(disc) {
157
+ return disc.entryName === 'gemini-batch.mjs'
158
+ }
159
+
160
+ function geminiForkReady(disc) {
161
+ return isGeminiBatchFork(disc) && compareSemver(disc.version, MIN_GEMINI_FORK_VERSION) >= 0
162
+ }
163
+
97
164
  // ---------------------------------------------------------------------------
98
165
  // Argument parsing — minimal, KISS, no external CLI lib.
99
166
  // ---------------------------------------------------------------------------
@@ -123,6 +190,9 @@ function parseArgs(argv) {
123
190
  // task class is supposed to stream (e.g. future protocol additions)
124
191
  // can pass --idle-timeout-ms <N> explicitly.
125
192
  idleTimeoutMs: 0,
193
+ // C10: NO-OP soft hint. Audited 2026-06: neither codex-companion 1.0.x nor
194
+ // gemini-batch 1.2.x has a single read point for CODEX/GEMINI_MAX_BUDGET_USD.
195
+ // Kept only so callers/templates don't break; real cost lives in stats.tokens.
126
196
  maxBudgetUsd: 50,
127
197
  // 1.0.5 regression fix: pre-1.0.5 callers always passed --write directly
128
198
  // to codex-companion. Default true preserves BC; --no-write opt-out for
@@ -131,6 +201,19 @@ function parseArgs(argv) {
131
201
  model: null,
132
202
  effort: null,
133
203
  cwd: null,
204
+ // C5: resume the most recent thread (codex --resume-last is live; gemini
205
+ // forwards `gemini -r latest` with fork gemini-ccgx >=1.3.0 — older forks
206
+ // are hard-blocked by checkGeminiPreflight instead of silently no-op'ing).
207
+ resumeLast: false,
208
+ // C9: extra read dirs forwarded to gemini-cli --include-directories (comma
209
+ // list, fork gemini-ccgx >=1.3.0); codex ignores (its sandbox is
210
+ // single-cwd, no --add-dir surface in companion).
211
+ includeDirectories: null,
212
+ // C8: selective MCP allowlist (comma list of server names, fork
213
+ // gemini-ccgx >=1.3.0). Empty/null = vendor default (codex keeps its
214
+ // config, gemini-batch suppresses ALL via its __ccgx_no_mcp__ sentinel).
215
+ // Set to re-enable only retrieval servers.
216
+ mcpAllow: null,
134
217
  }
135
218
 
136
219
  const positional = []
@@ -153,6 +236,9 @@ function parseArgs(argv) {
153
236
  case '--model': opts.model = next(); break
154
237
  case '--effort': opts.effort = next(); break
155
238
  case '--cwd': opts.cwd = next(); break
239
+ case '--resume-last': opts.resumeLast = true; break
240
+ case '--include-directories': opts.includeDirectories = next(); break
241
+ case '--mcp-allow': opts.mcpAllow = next(); break
156
242
  case '--help':
157
243
  case '-h':
158
244
  printHelp()
@@ -188,14 +274,20 @@ Optional:
188
274
  --no-json Disable --json (text output)
189
275
  --timeout-ms <N> Kill companion if total wall-time exceeds N ms (default: 7200000, 2h; pass 0 to disable)
190
276
  --idle-timeout-ms <N> Kill companion if no stdout/stderr output for N ms (default: 0 = disabled). NOTE: codex-companion and gemini-batch both emit NOTHING to stdout/stderr during execution (progress lives in internal state files); idle defaults to disabled to avoid false-positive kills of healthy long tasks. Set to a positive value only if your task class is known to stream.
191
- --max-budget-usd <N> Per-call cost cap (default: 50)
277
+ --max-budget-usd <N> NO-OP soft hint (default: 50). No plugin reads it today; kept for BC. Real cost surfaces in output .stats.tokens.
192
278
  --write Enable workspace-write sandbox (default: true; codex needs this to spawn read commands too under default approval policy)
193
279
  --no-write Force read-only sandbox (companion approval-policy will decline most commands)
194
- --model <name> Override model (codex only; gemini ignores)
195
- --effort <level> Reasoning effort: none|minimal|low|medium|high|xhigh (codex only)
280
+ --model <name> Override model. codex: forwarded as --model. gemini: forwarded as -m to gemini-cli (NOT ignored). flash/fast aliases route to the lighter tier.
281
+ --effort <level> Reasoning effort: none|minimal|low|medium|high|xhigh (codex only; gemini ignores)
196
282
  --cwd <path> Override working directory for the companion call. codex sandbox is cwd-bound; set this when auditing a repo other than the caller's cwd.
197
-
198
- Outputs JSON to stdout: {status, vendor, version, durationMs, exitCode, stdout, stderr, error?}
283
+ --resume-last Resume the most recent thread instead of starting fresh. codex: live (--resume-last). gemini: requires fork gemini-ccgx >=1.3.0 (older installs hard-error with an upgrade hint). Prefer explicit threadId in parallel runs (latest can pick the wrong thread).
284
+ --include-directories <list> Extra read-only dirs for gemini-cli (comma list). Requires fork gemini-ccgx >=1.3.0. codex ignores (no --add-dir in companion).
285
+ --mcp-allow <names> Selectively re-enable named MCP servers (comma list). Requires fork gemini-ccgx >=1.3.0. Empty = vendor default (codex keeps config; gemini suppresses ALL). Pass only retrieval servers to keep gemini batch startup fast.
286
+
287
+ Outputs JSON to stdout (flattened 3-layer envelope):
288
+ {status, vendor, version, durationMs, exitCode, stdout, stderr, text, threadId, stats, warnings?, error?}
289
+ .text = model output (codex/gemini rawOutput), .threadId = resume handle, .stats = tokens/latency.
290
+ .warnings only appears when flags were dropped (upstream gemini-companion fallback).
199
291
  `)
200
292
  }
201
293
 
@@ -243,10 +335,12 @@ function discoverCompanion(vendor, homeDir = homedir()) {
243
335
  // BC: vendors with only the legacy companion still resolve correctly.
244
336
  const entryCandidates = VENDOR_ENTRY_SCRIPTS[vendor] ?? [`${vendor}-companion.mjs`]
245
337
  let companionPath = null
338
+ let entryName = null
246
339
  for (const name of entryCandidates) {
247
340
  const candidate = join(installPath, 'scripts', name)
248
341
  if (existsSync(candidate)) {
249
342
  companionPath = candidate
343
+ entryName = name
250
344
  break
251
345
  }
252
346
  }
@@ -256,6 +350,9 @@ function discoverCompanion(vendor, homeDir = homedir()) {
256
350
 
257
351
  return {
258
352
  companionPath,
353
+ // entryName drives the version gate + arg-building branches downstream
354
+ // (gemini-batch fork vs upstream gemini-companion fallback).
355
+ entryName,
259
356
  version: typeof inst?.version === 'string' ? inst.version : 'unknown',
260
357
  }
261
358
  }
@@ -282,6 +379,178 @@ function emitError(vendor, version, error, extra = {}) {
282
379
  })
283
380
  }
284
381
 
382
+ // ---------------------------------------------------------------------------
383
+ // C7: flatten the Layer-2 companion envelope into stable top-level fields.
384
+ //
385
+ // Both vendors emit a JSON envelope on stdout when --json is set:
386
+ // gemini-batch: { status, threadId, rawOutput, stats, ... }
387
+ // codex-companion task --json: a job-result envelope (rawOutput/text + stats)
388
+ // The LLM should never have to reach into a nested JSON string, so we lift the
389
+ // model output → .text, session id → .threadId, stats → .stats. Best-effort:
390
+ // if stdout isn't parseable JSON (e.g. --no-json), we leave .text null and the
391
+ // raw stdout string remains available on .stdout.
392
+ // ---------------------------------------------------------------------------
393
+
394
+ function flattenCompanionEnvelope(stdout) {
395
+ const flat = { text: null, threadId: null, stats: null }
396
+ if (typeof stdout !== 'string' || stdout.trim() === '') return flat
397
+ let env
398
+ try {
399
+ env = JSON.parse(stdout)
400
+ }
401
+ catch {
402
+ return flat // not JSON (text mode / partial output) — leave nulls
403
+ }
404
+ if (!env || typeof env !== 'object') return flat
405
+ // Model output: gemini → rawOutput; codex → text or rawOutput.
406
+ if (typeof env.text === 'string') flat.text = env.text
407
+ else if (typeof env.rawOutput === 'string') flat.text = env.rawOutput
408
+ // Session/thread id for resume.
409
+ if (typeof env.threadId === 'string') flat.threadId = env.threadId
410
+ else if (typeof env.sessionId === 'string') flat.threadId = env.sessionId
411
+ // Stats (tokens/latency) verbatim.
412
+ if (env.stats !== undefined) flat.stats = env.stats
413
+ return flat
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Gemini preflight gate — fail fast BEFORE writing tmp files or spawning.
418
+ //
419
+ // Two classes of rejection (both better than the runtime alternative):
420
+ // 1. <1.3.0 fork + fork-only flag requested: --resume-last would be a
421
+ // SILENT no-op and --mcp-allow / --include-directories would make
422
+ // gemini-batch throw unknown-flag. Hard-error with an upgrade hint.
423
+ // 2. Inline-prompt path (fork <1.3.0 `-p`, or upstream companion
424
+ // positional) + prompt >30000 chars: Windows spawn would die with a
425
+ // bare ENAMETOOLONG at the ~32K argv ceiling. Reject with context.
426
+ // Returns an error string (caller emits + exits 78 EX_CONFIG) or null.
427
+ // ---------------------------------------------------------------------------
428
+
429
+ function checkGeminiPreflight(opts, disc, promptBody) {
430
+ if (opts.vendor !== 'gemini') return null
431
+ if (isGeminiBatchFork(disc) && !geminiForkReady(disc)) {
432
+ const requested = []
433
+ if (opts.resumeLast) requested.push('--resume-last')
434
+ if (opts.mcpAllow) requested.push('--mcp-allow')
435
+ if (opts.includeDirectories) requested.push('--include-directories')
436
+ if (requested.length > 0) {
437
+ return `installed gemini-ccgx ${disc.version} does not support ${requested.join(', ')}`
438
+ + ` (--resume-last is a silent no-op, the others throw unknown-flag);`
439
+ + ` update the plugin to >=${MIN_GEMINI_FORK_VERSION}`
440
+ }
441
+ if (promptBody.length > INLINE_PROMPT_MAX_CHARS) {
442
+ return `prompt is ${promptBody.length} chars but gemini-ccgx ${disc.version} only accepts it`
443
+ + ` inlined via -p (Windows spawn throws ENAMETOOLONG past the ~32K argv ceiling);`
444
+ + ` update the plugin to >=${MIN_GEMINI_FORK_VERSION} for --prompt-file support`
445
+ }
446
+ }
447
+ if (disc.entryName === 'gemini-companion.mjs' && promptBody.length > INLINE_PROMPT_MAX_CHARS) {
448
+ return `prompt is ${promptBody.length} chars but upstream gemini-companion only accepts it`
449
+ + ` as a positional argv element (Windows spawn throws ENAMETOOLONG past the ~32K argv ceiling);`
450
+ + ` install the gemini-ccgx fork >=${MIN_GEMINI_FORK_VERSION} for --prompt-file support`
451
+ }
452
+ return null
453
+ }
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // C1/C5/C8/C9: build the companion argv array. Pure function (no I/O) so unit
457
+ // tests can assert the exact vendor-specific flag surface. Returns
458
+ // { args, warnings } — warnings records flags dropped on the upstream
459
+ // gemini-companion fallback so the caller can surface them in the envelope.
460
+ //
461
+ // codex → task --prompt-file <tmp> [--json] [--write]
462
+ // [--model m] [--effort e] [--resume-last] [--cwd p]
463
+ // (NEVER -p, NEVER inlined prompt)
464
+ // gemini gemini-batch >=1.3.0 → task --prompt-file <tmp> [--json] [--write]
465
+ // [--model m] [--resume-last] [--include-directories l] [--mcp-allow n]
466
+ // [--timeout-ms n] [--idle-timeout-ms n] [--cwd p]
467
+ // gemini gemini-batch <1.3.0 → task -p <promptBody> [--json] [--write]
468
+ // [--model m] [--cwd p]
469
+ // (fork-only flags + oversized prompts hard-blocked by checkGeminiPreflight)
470
+ // gemini gemini-companion (upstream fallback) → task <promptBody> [--model m]
471
+ // [--cwd p] (fork-only flags dropped + recorded in warnings)
472
+ // ---------------------------------------------------------------------------
473
+
474
+ function buildCompanionArgs(opts, disc, tmpPromptPath, promptBody) {
475
+ const warnings = []
476
+ const args = [disc.companionPath, 'task']
477
+ const companionFallback = opts.vendor === 'gemini' && disc.entryName === 'gemini-companion.mjs'
478
+ const forkReady = opts.vendor === 'gemini' && geminiForkReady(disc)
479
+
480
+ if (companionFallback) {
481
+ // Upstream ACP companion: positional prompt (it has no -p / --prompt-file)
482
+ // and a minimal flag surface — forward only --model/--cwd, drop the
483
+ // fork-only flags and record each drop for the result envelope.
484
+ args.push(promptBody)
485
+ if (opts.model) args.push('--model', opts.model)
486
+ if (opts.cwd) args.push('--cwd', opts.cwd)
487
+ const dropped = []
488
+ if (opts.resumeLast) dropped.push('--resume-last')
489
+ if (opts.includeDirectories) dropped.push('--include-directories')
490
+ if (opts.mcpAllow) dropped.push('--mcp-allow')
491
+ for (const flag of dropped) {
492
+ warnings.push(`${flag} dropped: upstream gemini-companion does not support it (install gemini-ccgx >=${MIN_GEMINI_FORK_VERSION})`)
493
+ }
494
+ return { args, warnings }
495
+ }
496
+
497
+ if (opts.vendor === 'codex') {
498
+ // --prompt-file is a first-class codex valueOption (companion:614). This
499
+ // removes both the `-p`→positional pollution and the Windows 32K argv risk.
500
+ args.push('--prompt-file', tmpPromptPath)
501
+ }
502
+ else if (forkReady) {
503
+ // fork gemini-ccgx >=1.3.0 ships the same --prompt-file valueOption, so
504
+ // gemini joins codex off the argv path (no 32K ceiling, no -p).
505
+ args.push('--prompt-file', tmpPromptPath)
506
+ }
507
+ else {
508
+ // gemini-batch <1.3.0 has no --prompt-file (throws on unknown flags) and
509
+ // requires -p (body piped over stdin internally). Size is pre-checked by
510
+ // checkGeminiPreflight, so this never exceeds INLINE_PROMPT_MAX_CHARS.
511
+ args.push('-p', promptBody)
512
+ }
513
+ if (opts.json) args.push('--json')
514
+ if (opts.write) args.push('--write')
515
+ // --model: codex forwards as --model; gemini-batch forwards as -m internally.
516
+ if (opts.model) args.push('--model', opts.model)
517
+ // --effort is codex-only (gemini-batch consumes-and-ignores it; keep it off
518
+ // the gemini path to avoid relying on that no-op).
519
+ if (opts.effort && opts.vendor === 'codex') args.push('--effort', opts.effort)
520
+ // C5: resume the most recent thread. codex --resume-last is live; gemini
521
+ // forwards `gemini -r latest` with fork gemini-ccgx >=1.3.0. Older forks are
522
+ // hard-blocked by checkGeminiPreflight — never emitted here defensively.
523
+ if (opts.resumeLast && (opts.vendor === 'codex' || forkReady)) args.push('--resume-last')
524
+ // C9: gemini-only extra read dirs (codex companion has no --add-dir surface),
525
+ // fork gemini-ccgx >=1.3.0 only (older forks throw unknown-flag).
526
+ if (opts.includeDirectories && forkReady) {
527
+ args.push('--include-directories', opts.includeDirectories)
528
+ }
529
+ // C8: selective MCP allowlist (gemini fork >=1.3.0 only). Forward real
530
+ // server names so retrieval MCPs (fast-context/context7) survive instead of
531
+ // being blanket-suppressed by gemini-batch's __ccgx_no_mcp__ sentinel.
532
+ // codex-companion 1.0.4 has no MCP-selection flag (its task valueOptions are
533
+ // model/effort/cwd/prompt-file), so MCP scoping is not forwardable to codex
534
+ // — emitting a flag there is a silent no-op, so we don't.
535
+ if (opts.mcpAllow && forkReady) {
536
+ args.push('--mcp-allow', opts.mcpAllow)
537
+ }
538
+ // Timeout passthrough (fork >=1.3.0): hand gemini-batch a slightly SHORTER
539
+ // deadline than the helper's own so its internal taskkill /T /F tree-kill
540
+ // fires first — a helper-side SIGTERM on win32 cannot reach gemini-cli's
541
+ // grandchildren (MCP servers), the fork's in-process tree-kill can.
542
+ if (forkReady && opts.timeoutMs > 0) {
543
+ args.push('--timeout-ms', String(Math.max(60000, opts.timeoutMs - 10000)))
544
+ }
545
+ if (forkReady && opts.idleTimeoutMs > 0) {
546
+ args.push('--idle-timeout-ms', String(Math.max(60000, opts.idleTimeoutMs - 10000)))
547
+ }
548
+ // --cwd pass-through: codex sandbox is cwd-bound. For cross-repo audits the
549
+ // caller must set this to the repo being audited.
550
+ if (opts.cwd) args.push('--cwd', opts.cwd)
551
+ return { args, warnings }
552
+ }
553
+
285
554
  // ---------------------------------------------------------------------------
286
555
  // Main: spawn companion with array args (no shell)
287
556
  // ---------------------------------------------------------------------------
@@ -306,35 +575,6 @@ async function main(argv) {
306
575
  process.exit(66) // EX_NOINPUT
307
576
  }
308
577
 
309
- // 2.1.1: auto-delete the prompt file after a successful read iff the
310
- // PHYSICAL path sits inside the caller's `<cwd>/.context/tmp/` AND the
311
- // filename starts with `ccg-`. This is the workflow-managed throwaway-
312
- // prompt directory (see templates/commands/review.md — Step 1 writes
313
- // prompts here).
314
- //
315
- // CRITICAL — codex audit 2026-05-12 raised: lexical `resolve()` is
316
- // insufficient. If `.context` or `.context/tmp` is a symlink/junction
317
- // pointing outside the workspace, OR the caller passes a path whose
318
- // lexical form is `<cwd>/.context/tmp/ccg-x.txt` but its physical
319
- // target is `/etc/passwd` (or worse, `C:\Windows\System32\...`), the
320
- // lexical-only check would still authorize the unlink. realpathSync
321
- // canonicalizes ALL symlinks/junctions/`..` segments before the
322
- // whitelist compare — both for the candidate file AND the safeRoot.
323
- // Two ANDed gates: physical prefix + filename prefix.
324
- try {
325
- const resolvedFile = realpathSync(opts.promptFile)
326
- const safeRoot = realpathSync(resolve(process.cwd(), '.context', 'tmp')) + sep
327
- if (resolvedFile.startsWith(safeRoot) && basename(resolvedFile).startsWith('ccg-')) {
328
- unlinkSync(resolvedFile)
329
- }
330
- }
331
- catch {
332
- // Best-effort. ENOENT (.context/tmp absent, file already gone, etc.)
333
- // is the dominant non-error case here; we deliberately don't surface
334
- // it. Real failures (permission etc.) will leave a file behind that
335
- // .gitignore catches and the user's next run overwrites.
336
- }
337
-
338
578
  // Discover companion via SSoT (no glob, no head -1)
339
579
  const disc = discoverCompanion(opts.vendor)
340
580
  if (disc.error) {
@@ -342,106 +582,212 @@ async function main(argv) {
342
582
  process.exit(69) // EX_UNAVAILABLE
343
583
  }
344
584
 
345
- // Build companion argv as ARRAY no shell layer ever
346
- const companionArgs = [
347
- disc.companionPath,
348
- 'task',
349
- '-p',
350
- promptBody,
351
- ]
352
- if (opts.json) companionArgs.push('--json')
353
- // codex-companion flag pass-through (1.0.5 regression fix). gemini-companion
354
- // ignores unknown flags safely; both vendors accept this surface.
355
- if (opts.write) companionArgs.push('--write')
356
- if (opts.model) companionArgs.push('--model', opts.model)
357
- if (opts.effort) companionArgs.push('--effort', opts.effort)
358
- // --cwd pass-through: codex sandbox is cwd-bound. For cross-repo audits
359
- // the caller must set this to the repo being audited (codex-companion
360
- // does not expose --add-dir; multi-workspace requires deeper changes).
361
- if (opts.cwd) companionArgs.push('--cwd', opts.cwd)
585
+ // Version/flag/size gate fail fast before tmp files or spawn.
586
+ const gateError = checkGeminiPreflight(opts, disc, promptBody)
587
+ if (gateError) {
588
+ emitError(opts.vendor, disc.version, gateError)
589
+ process.exit(78) // EX_CONFIG
590
+ }
362
591
 
363
- const startedAt = Date.now()
592
+ // C1: write promptBody to a helper-owned temp file so the companion receives
593
+ // it via `--prompt-file <tmp>` (NOT inlined into argv). This kills two bugs:
594
+ // (a) Windows' 32767-char command-line ceiling — a big review/spec diff
595
+ // inlined as a single argv element used to blow the spawn outright.
596
+ // (b) codex-companion treats a bare `-p` as an UNKNOWN short flag → it
597
+ // falls into positionals and gets join(' ')'d into the prompt, so every
598
+ // codex prompt was silently prefixed with "-p ". --prompt-file is a
599
+ // first-class valueOption (codex-companion.mjs:614, resolved vs cwd).
600
+ // gemini joins this path with fork gemini-ccgx >=1.3.0 (same --prompt-file
601
+ // flag). Older gemini-batch (<1.3.0) and the upstream gemini-companion
602
+ // fallback still inline the prompt into argv — for those the 32K risk is
603
+ // bounded by checkGeminiPreflight's explicit >30000-char rejection above.
604
+ let tmpPromptPath = null
605
+ try {
606
+ tmpPromptPath = join(tmpdir(), `ccgx-prompt-${randomUUID()}.txt`)
607
+ writeFileSync(tmpPromptPath, promptBody, 'utf-8')
608
+ }
609
+ catch (err) {
610
+ emitError(opts.vendor, disc.version, `temp prompt write failed: ${err.message}`)
611
+ process.exit(73) // EX_CANTCREAT
612
+ }
364
613
 
365
- // spawn 'node' with array args. Node will look up its own executable;
366
- // companionPath is passed as a literal arg, no shell interpretation.
367
- const child = spawn(process.execPath, companionArgs, {
368
- stdio: ['ignore', 'pipe', 'pipe'],
369
- windowsHide: true,
370
- env: {
371
- ...process.env,
372
- // Forward budget cap if companion respects it (codex does)
373
- CODEX_MAX_BUDGET_USD: String(opts.maxBudgetUsd),
374
- GEMINI_MAX_BUDGET_USD: String(opts.maxBudgetUsd),
375
- },
376
- })
614
+ const result = await executeCompanion(opts, disc, tmpPromptPath, promptBody)
615
+ emitJson(result)
616
+ process.exit(result.status === 'ok' ? 0 : 1)
617
+ }
377
618
 
378
- let stdoutBuf = ''
379
- let stderrBuf = ''
380
- let lastActivityAt = Date.now()
381
- child.stdout.on('data', (chunk) => {
382
- stdoutBuf += chunk.toString('utf-8')
383
- lastActivityAt = Date.now()
384
- })
385
- child.stderr.on('data', (chunk) => {
386
- stderrBuf += chunk.toString('utf-8')
387
- lastActivityAt = Date.now()
388
- })
619
+ // ---------------------------------------------------------------------------
620
+ // executeCompanion: spawn → timers → wait → flatten → result envelope.
621
+ //
622
+ // The try/finally makes temp-prompt cleanup unconditional: a SYNCHRONOUS spawn
623
+ // throw (e.g. Windows ENAMETOOLONG on an oversized argv) must not leak
624
+ // tmpPromptPath, not just the normal exit path. `deps` is a test seam
625
+ // (spawn/spawnSync injection); production callers pass nothing.
626
+ // ---------------------------------------------------------------------------
627
+
628
+ async function executeCompanion(opts, disc, tmpPromptPath, promptBody, deps = {}) {
629
+ const spawnImpl = deps.spawn ?? spawn
630
+ const spawnSyncImpl = deps.spawnSync ?? spawnSync
631
+
632
+ // Build companion argv as ARRAY — no shell layer ever (pure + testable).
633
+ const { args: companionArgs, warnings } = buildCompanionArgs(opts, disc, tmpPromptPath, promptBody)
389
634
 
390
- // 2.1.1: two-layer timeout. wall-time is the safety ceiling; idle is the
391
- // primary "stuck" detector — a healthy long task keeps producing tool-call
392
- // / progress chunks, a truly hung companion produces nothing. SIGTERM is
393
- // best-effort on Windows where the kernel doesn't propagate to the
394
- // grandchild tree; companion processes on this code path are expected to
395
- // do their own subtree cleanup (taskkill /T) when they receive SIGTERM.
396
- let timedOut = false
397
- let timeoutReason = null
398
- const killChild = (reason) => {
399
- timedOut = true
400
- timeoutReason = reason
401
- try { child.kill('SIGTERM') } catch { /* ignore */ }
402
- setTimeout(() => {
403
- try { child.kill('SIGKILL') } catch { /* ignore */ }
404
- }, 5000).unref?.()
405
- }
406
- const wallTimer = opts.timeoutMs > 0
407
- ? setTimeout(() => killChild(`wall-time ${opts.timeoutMs}ms`), opts.timeoutMs)
408
- : null
409
- // Check idle every 30s. A 30s sampling granularity adds at most 30s slop
410
- // to the configured idle threshold, which is negligible at 600s default.
411
- const idleChecker = opts.idleTimeoutMs > 0
412
- ? setInterval(() => {
413
- const silent = Date.now() - lastActivityAt
414
- if (silent >= opts.idleTimeoutMs) {
415
- killChild(`idle ${silent}ms exceeds ${opts.idleTimeoutMs}ms`)
635
+ const startedAt = Date.now()
636
+ try {
637
+ // spawn 'node' with array args. Node will look up its own executable;
638
+ // companionPath is passed as a literal arg, no shell interpretation.
639
+ // NOTE (C10): we no longer inject CODEX/GEMINI_MAX_BUDGET_USD audited
640
+ // 2026-06, zero read points in either plugin. --max-budget-usd is a no-op
641
+ // soft hint; real cost lives in the flattened .stats.tokens.
642
+ const child = spawnImpl(process.execPath, companionArgs, {
643
+ stdio: ['ignore', 'pipe', 'pipe'],
644
+ windowsHide: true,
645
+ env: process.env,
646
+ })
647
+
648
+ let stdoutBuf = ''
649
+ let stderrBuf = ''
650
+ let lastActivityAt = Date.now()
651
+ child.stdout.on('data', (chunk) => {
652
+ stdoutBuf += chunk.toString('utf-8')
653
+ lastActivityAt = Date.now()
654
+ })
655
+ child.stderr.on('data', (chunk) => {
656
+ stderrBuf += chunk.toString('utf-8')
657
+ lastActivityAt = Date.now()
658
+ })
659
+
660
+ // 2.1.1: two-layer timeout. wall-time is the safety ceiling; idle is the
661
+ // primary "stuck" detector — a healthy long task keeps producing tool-call
662
+ // / progress chunks, a truly hung companion produces nothing.
663
+ //
664
+ // C6: a bare child.kill() is INSUFFICIENT for codex. The codex turn runs in
665
+ // a DETACHED broker process (codex.mjs spawns it), so killing the foreground
666
+ // companion leaves the turn burning tokens, the job stuck "running", and the
667
+ // next --resume-last blocked by "Task X is still running". The proper
668
+ // teardown is codex-companion's own `cancel` subcommand, which does
669
+ // turn/interrupt + taskkill /T /F on the broker tree (codex-companion.mjs
670
+ // handleCancel:920-979). So on timeout we run `cancel` FIRST, then fall back
671
+ // to SIGTERM/SIGKILL.
672
+ //
673
+ // gemini has no detached broker and no `cancel` subcommand, but gemini-cli
674
+ // spawns its own children (MCP servers, ripgrep): on win32 a bare SIGTERM
675
+ // kills only the foreground node and orphans that tree, so we tree-kill via
676
+ // taskkill /pid <pid> /T /F. Non-win32 keeps the signal path (process
677
+ // groups reap children there). With fork >=1.3.0 the forwarded --timeout-ms
678
+ // (10s shorter) normally fires gemini-batch's own tree-kill before this.
679
+ let timedOut = false
680
+ let timeoutReason = null
681
+ const killChild = (reason) => {
682
+ timedOut = true
683
+ timeoutReason = reason
684
+ if (opts.vendor === 'codex') {
685
+ try {
686
+ const cancelArgs = [disc.companionPath, 'cancel', '--json']
687
+ if (opts.cwd) cancelArgs.push('--cwd', opts.cwd)
688
+ const canceller = spawnImpl(process.execPath, cancelArgs, {
689
+ stdio: 'ignore',
690
+ windowsHide: true,
691
+ env: process.env,
692
+ })
693
+ canceller.unref?.()
694
+ }
695
+ catch { /* fall through to signal kill */ }
696
+ }
697
+ else if (process.platform === 'win32') {
698
+ try {
699
+ spawnSyncImpl('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
700
+ stdio: 'ignore',
701
+ windowsHide: true,
702
+ })
703
+ return
416
704
  }
417
- }, 30000)
418
- : null
419
- if (idleChecker?.unref) idleChecker.unref()
705
+ catch { /* fall through to signal kill */ }
706
+ }
707
+ try { child.kill('SIGTERM') } catch { /* ignore */ }
708
+ setTimeout(() => {
709
+ try { child.kill('SIGKILL') } catch { /* ignore */ }
710
+ }, 5000).unref?.()
711
+ }
712
+ const wallTimer = opts.timeoutMs > 0
713
+ ? setTimeout(() => killChild(`wall-time ${opts.timeoutMs}ms`), opts.timeoutMs)
714
+ : null
715
+ // Check idle every 30s. A 30s sampling granularity adds at most 30s slop
716
+ // to the configured idle threshold, which is negligible at 600s default.
717
+ const idleChecker = opts.idleTimeoutMs > 0
718
+ ? setInterval(() => {
719
+ const silent = Date.now() - lastActivityAt
720
+ if (silent >= opts.idleTimeoutMs) {
721
+ killChild(`idle ${silent}ms exceeds ${opts.idleTimeoutMs}ms`)
722
+ }
723
+ }, 30000)
724
+ : null
725
+ if (idleChecker?.unref) idleChecker.unref()
726
+
727
+ const exit = await new Promise((resolve) => {
728
+ child.once('exit', (code, signal) => resolve({ code, signal }))
729
+ child.once('error', (err) => resolve({ code: 70, signal: null, errorMsg: err.message }))
730
+ })
731
+ if (wallTimer) clearTimeout(wallTimer)
732
+ if (idleChecker) clearInterval(idleChecker)
733
+
734
+ const durationMs = Date.now() - startedAt
735
+ const status = (!timedOut && exit.code === 0) ? 'ok' : 'error'
736
+ const flat = flattenCompanionEnvelope(stdoutBuf)
737
+ const result = {
738
+ status,
739
+ vendor: opts.vendor,
740
+ version: disc.version,
741
+ durationMs,
742
+ exitCode: exit.code,
743
+ stdout: stdoutBuf,
744
+ stderr: stderrBuf,
745
+ // C7: flattened Layer-2 fields so the LLM parses once.
746
+ text: flat.text,
747
+ threadId: flat.threadId,
748
+ stats: flat.stats,
749
+ }
750
+ if (warnings.length > 0) result.warnings = warnings
751
+ if (timedOut) result.error = `companion killed: ${timeoutReason}`
752
+ else if (exit.errorMsg) result.error = `spawn error: ${exit.errorMsg}`
753
+ else if (exit.code !== 0) result.error = `companion exited with code ${exit.code}`
754
+ return result
755
+ }
756
+ finally {
757
+ // C1 cleanup, in finally so it runs on BOTH the normal post-exit path and
758
+ // the synchronous-throw path (spawn ENAMETOOLONG/EPERM). Deferring past
759
+ // child exit is mandatory on the normal path — codex reads --prompt-file
760
+ // lazily, so an early unlink would race the child's open(). realpathSync
761
+ // canonicalizes symlinks/junctions/`..` before the whitelist compare on
762
+ // BOTH the candidate and safeRoot (codex audit 2026-05-12: lexical
763
+ // resolve() is insufficient).
764
+ cleanupTempFiles(tmpPromptPath, opts.promptFile)
765
+ }
766
+ }
420
767
 
421
- const exit = await new Promise((resolve) => {
422
- child.once('exit', (code, signal) => resolve({ code, signal }))
423
- child.once('error', (err) => resolve({ code: 70, signal: null, errorMsg: err.message }))
424
- })
425
- if (wallTimer) clearTimeout(wallTimer)
426
- if (idleChecker) clearInterval(idleChecker)
427
-
428
- const durationMs = Date.now() - startedAt
429
- const status = (!timedOut && exit.code === 0) ? 'ok' : 'error'
430
- const result = {
431
- status,
432
- vendor: opts.vendor,
433
- version: disc.version,
434
- durationMs,
435
- exitCode: exit.code,
436
- stdout: stdoutBuf,
437
- stderr: stderrBuf,
438
- }
439
- if (timedOut) result.error = `companion killed: ${timeoutReason}`
440
- else if (exit.errorMsg) result.error = `spawn error: ${exit.errorMsg}`
441
- else if (exit.code !== 0) result.error = `companion exited with code ${exit.code}`
768
+ // ---------------------------------------------------------------------------
769
+ // Temp-file cleanup best-effort, never throws.
770
+ // - tmpPromptPath: helper-owned throwaway (always safe to unlink).
771
+ // - sourcePromptFile: caller's file; only unlink when its PHYSICAL path is
772
+ // inside <cwd>/.context/tmp/ AND basename starts with ccg- (workflow-
773
+ // managed throwaway, see templates/commands/review.md Step 1).
774
+ // ---------------------------------------------------------------------------
442
775
 
443
- emitJson(result)
444
- process.exit(status === 'ok' ? 0 : 1)
776
+ function cleanupTempFiles(tmpPromptPath, sourcePromptFile) {
777
+ if (tmpPromptPath) {
778
+ try { unlinkSync(tmpPromptPath) } catch { /* best-effort */ }
779
+ }
780
+ try {
781
+ const resolvedFile = realpathSync(sourcePromptFile)
782
+ const safeRoot = realpathSync(resolve(process.cwd(), '.context', 'tmp')) + sep
783
+ if (resolvedFile.startsWith(safeRoot) && basename(resolvedFile).startsWith('ccg-')) {
784
+ unlinkSync(resolvedFile)
785
+ }
786
+ }
787
+ catch {
788
+ // ENOENT (.context/tmp absent / file already gone) is the dominant
789
+ // non-error case; real failures leave a file the .gitignore catches.
790
+ }
445
791
  }
446
792
 
447
793
  // ---------------------------------------------------------------------------
@@ -470,5 +816,12 @@ if (isMainModule()) {
470
816
  export const ccgxCallPluginExports = {
471
817
  parseArgs,
472
818
  discoverCompanion,
819
+ buildCompanionArgs,
820
+ flattenCompanionEnvelope,
821
+ checkGeminiPreflight,
822
+ compareSemver,
823
+ executeCompanion,
824
+ INLINE_PROMPT_MAX_CHARS,
825
+ MIN_GEMINI_FORK_VERSION,
473
826
  VENDOR_KEYS,
474
827
  }