ccgx-workflow 1.0.0 → 1.0.2

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 (60) hide show
  1. package/README.md +37 -5
  2. package/README.zh-CN.md +35 -5
  3. package/dist/cli.mjs +1 -1
  4. package/dist/index.mjs +2 -2
  5. package/dist/shared/{ccgx-workflow.WgUzkiC3.mjs → ccgx-workflow.Bq9vAaEw.mjs} +17 -110
  6. package/package.json +2 -1
  7. package/templates/commands/agents/phase-runner.md +321 -321
  8. package/templates/commands/autonomous.md +792 -792
  9. package/templates/commands/cancel.md +132 -132
  10. package/templates/commands/debug.md +226 -226
  11. package/templates/commands/status.md +206 -206
  12. package/templates/commands/team.md +484 -0
  13. package/templates/hooks/ccg-session-state.cjs +566 -510
  14. package/templates/scripts/ccg-phase-runner-launcher.mjs +467 -467
  15. package/templates/scripts/invoke-model.mjs +64 -0
  16. package/templates/skills/domains/ai/SKILL.md +35 -35
  17. package/templates/skills/domains/ai/agent-dev.md +242 -242
  18. package/templates/skills/domains/ai/llm-security.md +288 -288
  19. package/templates/skills/domains/ai/rag-system.md +542 -542
  20. package/templates/skills/domains/architecture/SKILL.md +43 -43
  21. package/templates/skills/domains/architecture/api-design.md +225 -225
  22. package/templates/skills/domains/architecture/cloud-native.md +285 -285
  23. package/templates/skills/domains/architecture/security-arch.md +297 -297
  24. package/templates/skills/domains/data-engineering/SKILL.md +208 -208
  25. package/templates/skills/domains/development/SKILL.md +47 -47
  26. package/templates/skills/domains/development/cpp.md +246 -246
  27. package/templates/skills/domains/development/go.md +323 -323
  28. package/templates/skills/domains/development/java.md +277 -277
  29. package/templates/skills/domains/development/python.md +288 -288
  30. package/templates/skills/domains/development/rust.md +313 -313
  31. package/templates/skills/domains/development/shell.md +313 -313
  32. package/templates/skills/domains/development/typescript.md +277 -277
  33. package/templates/skills/domains/devops/SKILL.md +40 -40
  34. package/templates/skills/domains/devops/database.md +217 -217
  35. package/templates/skills/domains/devops/devsecops.md +198 -198
  36. package/templates/skills/domains/devops/git-workflow.md +181 -181
  37. package/templates/skills/domains/devops/testing.md +283 -283
  38. package/templates/skills/domains/frontend-design/SKILL.md +244 -244
  39. package/templates/skills/domains/frontend-design/claymorphism/SKILL.md +121 -121
  40. package/templates/skills/domains/frontend-design/component-patterns.md +202 -202
  41. package/templates/skills/domains/frontend-design/glassmorphism/SKILL.md +142 -142
  42. package/templates/skills/domains/frontend-design/liquid-glass/SKILL.md +139 -139
  43. package/templates/skills/domains/frontend-design/neubrutalism/SKILL.md +145 -145
  44. package/templates/skills/domains/frontend-design/state-management.md +680 -680
  45. package/templates/skills/domains/frontend-design/ui-aesthetics.md +110 -110
  46. package/templates/skills/domains/frontend-design/ux-principles.md +156 -156
  47. package/templates/skills/domains/infrastructure/SKILL.md +201 -201
  48. package/templates/skills/domains/mobile/SKILL.md +225 -225
  49. package/templates/skills/domains/orchestration/SKILL.md +30 -30
  50. package/templates/skills/domains/orchestration/multi-agent.md +228 -228
  51. package/templates/skills/domains/security/SKILL.md +73 -73
  52. package/templates/skills/domains/security/blue-team.md +436 -436
  53. package/templates/skills/domains/security/code-audit.md +265 -265
  54. package/templates/skills/domains/security/pentest.md +226 -226
  55. package/templates/skills/domains/security/red-team.md +374 -374
  56. package/templates/skills/domains/security/threat-intel.md +372 -372
  57. package/templates/skills/domains/security/vuln-research.md +369 -369
  58. package/templates/skills/tools/lib/shared.js +98 -98
  59. package/templates/skills/tools/override-refusal/SKILL.md +53 -53
  60. package/templates/skills/tools/override-refusal/scripts/refusal_rewriter.js +226 -226
@@ -1,467 +1,467 @@
1
- // =============================================================================
2
- // ccg-phase-runner-launcher.mjs v4.5 P1b
3
- // -----------------------------------------------------------------------------
4
- // Supervised launcher for `claude -p --agent ccg/phase-runner ...` subprocess.
5
- //
6
- // Why this exists (codex C2 + C4):
7
- // The autonomous main thread cannot itself supervise an OS subprocess —
8
- // Claude is an LLM running tool calls, not a process manager. The naive
9
- // `Bash(claude -p ...)` shell call is fire-and-forget: if Claude crashes
10
- // after spawn, the subprocess can orphan; if the user hits Ctrl+C, nested
11
- // plugin processes survive; if the subprocess hangs, no one notices.
12
- //
13
- // This launcher wraps the spawn so that:
14
- // 1. Job state file is written atomically *before* spawn (parent_pid,
15
- // cli_pid, process_group_id, cwd, cmd, started_at).
16
- // 2. The CLI subprocess is launched in its own session/process group
17
- // (POSIX `detached: true` → setsid()) so the whole tree can be
18
- // signalled as a unit.
19
- // 3. On exit (success / error / signal), terminal state is written
20
- // atomically; the CCG status command can report the truth.
21
- // 4. On the launcher receiving SIGINT/SIGTERM (Ctrl+C from the parent),
22
- // the cancel.flag is observed cooperatively, then the process tree
23
- // is killed after the grace period.
24
- //
25
- // Usage (called by `Bash(node ~/.claude/.ccg/scripts/ccg-phase-runner-launcher.mjs ...)`):
26
- //
27
- // node ccg-phase-runner-launcher.mjs \
28
- // --job-id <id> \
29
- // --workdir <path> \
30
- // --prompt-file <path> \
31
- // --tier <fast|triple|debate> \
32
- // [--max-budget-usd <N>] \
33
- // [--grace-ms <N>] # SIGTERM -> SIGKILL grace, default 5000
34
- //
35
- // Exit code: forwarded from the inner `claude -p` child. Launcher own errors
36
- // surface as exit 64 (EX_USAGE) or 70 (EX_SOFTWARE).
37
- //
38
- // Cross-cutting:
39
- // - Pure stdlib (fs / child_process / crypto / path / os). No deps.
40
- // - State writes are temp + rename (atomicWriteFileSync, ported below).
41
- // - Stream-json output streams to .context/jobs/<id>/progress.jsonl as the
42
- // child runs; we don't transform it, we just plumb stdout/stderr through.
43
- //
44
- // ⚠ Schema contract for state.json (consumed by reconciler in src/utils/
45
- // process-tree.ts → SupervisedJobState):
46
- //
47
- // {
48
- // task_id, kind: "phase-runner", status,
49
- // started_at, last_update,
50
- // parent_pid, cli_pid, process_group_id, cwd, cmd
51
- // }
52
- // =============================================================================
53
-
54
- import { spawn } from 'node:child_process'
55
- import { randomBytes, randomUUID } from 'node:crypto'
56
- import {
57
- createWriteStream,
58
- existsSync,
59
- mkdirSync,
60
- readFileSync,
61
- realpathSync,
62
- renameSync,
63
- unlinkSync,
64
- writeFileSync,
65
- } from 'node:fs'
66
- import { join } from 'node:path'
67
- import { fileURLToPath } from 'node:url'
68
-
69
- // ---------------------------------------------------------------------------
70
- // Internal helpers (kept verbatim local to keep launcher dependency-free)
71
- // ---------------------------------------------------------------------------
72
-
73
- function atomicWriteFileSync(target, content) {
74
- const rand = randomBytes(6).toString('hex')
75
- const tmp = `${target}.tmp.${rand}`
76
- try {
77
- writeFileSync(tmp, content, 'utf-8')
78
- renameSync(tmp, target)
79
- }
80
- catch (err) {
81
- try { unlinkSync(tmp) }
82
- catch { /* nothing */ }
83
- throw err
84
- }
85
- }
86
-
87
- function isWindows() {
88
- return process.platform === 'win32'
89
- }
90
-
91
- function nowIso() {
92
- return new Date().toISOString()
93
- }
94
-
95
- function ensureDir(p) {
96
- if (!existsSync(p)) mkdirSync(p, { recursive: true })
97
- }
98
-
99
- // ---------------------------------------------------------------------------
100
- // Argument parsing — minimal, KISS, no external CLI lib.
101
- // ---------------------------------------------------------------------------
102
-
103
- function parseArgs(argv) {
104
- const opts = {
105
- jobId: null,
106
- workdir: null,
107
- promptFile: null,
108
- tier: 'triple',
109
- maxBudgetUsd: null,
110
- graceMs: 5000,
111
- }
112
- for (let i = 2; i < argv.length; i++) {
113
- const arg = argv[i]
114
- const next = () => {
115
- const v = argv[++i]
116
- if (v === undefined) {
117
- throw new Error(`flag ${arg} requires a value`)
118
- }
119
- return v
120
- }
121
- switch (arg) {
122
- case '--job-id': opts.jobId = next(); break
123
- case '--workdir': opts.workdir = next(); break
124
- case '--prompt-file': opts.promptFile = next(); break
125
- case '--tier': opts.tier = next(); break
126
- case '--max-budget-usd': opts.maxBudgetUsd = Number.parseFloat(next()); break
127
- case '--grace-ms': opts.graceMs = Number.parseInt(next(), 10); break
128
- case '--help':
129
- case '-h':
130
- printHelp()
131
- process.exit(0)
132
- default:
133
- throw new Error(`unknown flag: ${arg}`)
134
- }
135
- }
136
- if (!opts.jobId) throw new Error('--job-id is required')
137
- if (!opts.workdir) throw new Error('--workdir is required')
138
- if (!opts.promptFile) throw new Error('--prompt-file is required')
139
- if (!['fast', 'triple', 'debate'].includes(opts.tier)) {
140
- throw new Error(`invalid --tier: ${opts.tier}`)
141
- }
142
- return opts
143
- }
144
-
145
- function printHelp() {
146
- process.stderr.write(`Usage: ccg-phase-runner-launcher.mjs [flags]
147
-
148
- Required:
149
- --job-id <id> Job identifier (becomes .context/jobs/<id>/)
150
- --workdir <path> Phase workdir; subprocess cwd
151
- --prompt-file <path> Prompt body file (relative to workdir or absolute)
152
-
153
- Optional:
154
- --tier <fast|triple|debate> Quality tier; maps to --max-budget-usd
155
- (fast=1, triple=2, debate=5). Default: triple.
156
- --max-budget-usd <N> Override per-call budget cap.
157
- --grace-ms <N> SIGTERM->SIGKILL grace (default 5000).
158
-
159
- Exits with the inner claude exit code. Own errors: 64 (usage), 70 (software).
160
- `)
161
- }
162
-
163
- // ---------------------------------------------------------------------------
164
- // State helpers (tightly mirror src/utils/jobs.ts contract)
165
- // ---------------------------------------------------------------------------
166
-
167
- function jobDir(workdir, jobId) {
168
- return join(workdir, '.context', 'jobs', jobId)
169
- }
170
-
171
- function statePath(workdir, jobId) {
172
- return join(jobDir(workdir, jobId), 'state.json')
173
- }
174
-
175
- function progressPath(workdir, jobId) {
176
- return join(jobDir(workdir, jobId), 'progress.jsonl')
177
- }
178
-
179
- function cancelFlagPath(workdir, jobId) {
180
- return join(jobDir(workdir, jobId), 'cancel.flag')
181
- }
182
-
183
- function writeState(workdir, jobId, state) {
184
- ensureDir(jobDir(workdir, jobId))
185
- const updated = { ...state, last_update: nowIso() }
186
- atomicWriteFileSync(statePath(workdir, jobId), JSON.stringify(updated, null, 2))
187
- return updated
188
- }
189
-
190
- // ---------------------------------------------------------------------------
191
- // Build the `claude -p` argv. Mirrors `buildPhaseRunnerBashCommand` in
192
- // src/utils/quality-router.ts (single source of truth for what flags live in
193
- // production phase-runner spawn).
194
- // ---------------------------------------------------------------------------
195
-
196
- const TIER_BUDGET = { fast: 1.0, triple: 2.0, debate: 5.0 }
197
-
198
- function buildClaudeArgs({ promptFile, workdir, tier, maxBudgetUsd }) {
199
- const budget = maxBudgetUsd ?? TIER_BUDGET[tier]
200
- const promptBody = readFileSync(promptFile, 'utf-8')
201
- return [
202
- '-p', promptBody,
203
- '--agent', 'ccg/phase-runner',
204
- '--output-format', 'stream-json',
205
- '--include-partial-messages',
206
- '--verbose',
207
- '--max-budget-usd', String(budget),
208
- '--dangerously-skip-permissions',
209
- '--add-dir', workdir,
210
- ]
211
- }
212
-
213
- // ---------------------------------------------------------------------------
214
- // Main launcher
215
- // ---------------------------------------------------------------------------
216
-
217
- async function main(argv) {
218
- let opts
219
- try {
220
- opts = parseArgs(argv)
221
- }
222
- catch (err) {
223
- process.stderr.write(`launcher: ${err.message}\n`)
224
- printHelp()
225
- return 64 // EX_USAGE
226
- }
227
-
228
- const { jobId, workdir, graceMs } = opts
229
- ensureDir(jobDir(workdir, jobId))
230
-
231
- let claudeArgs
232
- try {
233
- claudeArgs = buildClaudeArgs(opts)
234
- }
235
- catch (err) {
236
- process.stderr.write(`launcher: cannot build claude args: ${err.message}\n`)
237
- writeState(workdir, jobId, {
238
- task_id: jobId,
239
- kind: 'phase-runner',
240
- status: 'failed',
241
- started_at: nowIso(),
242
- last_update: nowIso(),
243
- summary: `launcher build args failed: ${err.message}`,
244
- parent_pid: process.pid,
245
- cwd: workdir,
246
- })
247
- return 70 // EX_SOFTWARE
248
- }
249
-
250
- // Mint a broker tx_id (v4.5 P1d, codex C3). One V4 UUID per launcher
251
- // invocation; the CLI subprocess + any nested plugin Agents spawned inside
252
- // it inherit the same tx_id via env, so broker.log readers can correlate
253
- // every event back to this one logical phase-runner transaction.
254
- // crypto.randomUUID is the only acceptable source — Math.random / Date.now /
255
- // PID all leak entropy and are reused across processes.
256
- const txId = randomUUID()
257
- const brokerLogPath = join(workdir, '.context', 'broker.log')
258
-
259
- // Initial state — written *before* spawn so a crash between here and spawn
260
- // leaves a recoverable artifact for the reconciler.
261
- const initial = writeState(workdir, jobId, {
262
- task_id: jobId,
263
- kind: 'phase-runner',
264
- status: 'running',
265
- started_at: nowIso(),
266
- last_update: nowIso(),
267
- parent_pid: process.pid,
268
- cwd: workdir,
269
- cmd: `claude ${claudeArgs.slice(0, 6).join(' ')} ... [redacted prompt]`,
270
- broker_tx_id: txId,
271
- })
272
-
273
- // Spawn the child. `detached: true` on POSIX calls setsid() so the child
274
- // gets its own session/group → we can signal the whole tree later via -pgid.
275
- // On Windows, `detached: true` calls CreateProcess with DETACHED_PROCESS;
276
- // we still rely on `taskkill /T /F` for tree termination (codeagent-wrapper
277
- // precedent — see executor.go:1421).
278
- //
279
- // env inheritance: we extend process.env (parent of the launcher) with the
280
- // broker-correlation triplet so phase-runner subagent code + nested plugin
281
- // spawns can emit broker events under the same tx_id.
282
- const child = spawn('claude', claudeArgs, {
283
- cwd: workdir,
284
- detached: !isWindows(),
285
- windowsHide: true,
286
- // Pipe stdout to progress file; let stderr surface to the launcher's
287
- // stderr so users see auth / quota errors without grep-ing files.
288
- stdio: ['ignore', 'pipe', 'inherit'],
289
- env: {
290
- ...process.env,
291
- CCG_BROKER_TX_ID: txId,
292
- CCG_BROKER_LOG_PATH: brokerLogPath,
293
- CCG_OUTER_CLI_PID: String(process.pid),
294
- CCG_JOB_ID: jobId,
295
- CCG_PHASE_RUNNER_TIER: opts.tier,
296
- },
297
- })
298
-
299
- // Persist cli_pid + process_group_id ASAP — race window before spawn returns
300
- // pid is closed by the time `child.pid` is set (synchronously in Node).
301
- const pgid = !isWindows() ? child.pid : undefined
302
- writeState(workdir, jobId, {
303
- ...initial,
304
- cli_pid: child.pid,
305
- process_group_id: pgid,
306
- })
307
-
308
- // Stream stdout to progress.jsonl. We append to keep crash-resume semantics
309
- // (don't truncate prior bytes if the launcher itself was restarted).
310
- const progressFd = openAppendStream(progressPath(workdir, jobId))
311
- child.stdout.on('data', (chunk) => {
312
- progressFd.write(chunk)
313
- // Mirror to the launcher's stdout for the parent Bash poller.
314
- process.stdout.write(chunk)
315
- })
316
-
317
- // Cooperative cancel + signal-driven kill-tree.
318
- let cancelInjected = false
319
- const tickCancelPoll = setInterval(() => {
320
- if (existsSync(cancelFlagPath(workdir, jobId)) && !cancelInjected) {
321
- cancelInjected = true
322
- // Step 1 cooperative: many subagents poll cancel.flag themselves.
323
- // Step 2 kill-tree: we still want to enforce after the grace period.
324
- scheduleKillTree(child, graceMs)
325
- }
326
- }, 1000)
327
-
328
- const onSignal = (sig) => {
329
- process.stderr.write(`launcher: received ${sig}; writing cancel.flag + grace ${graceMs}ms\n`)
330
- try {
331
- atomicWriteFileSync(
332
- cancelFlagPath(workdir, jobId),
333
- `cancel-requested-at: ${nowIso()}\nrequested-by: launcher signal ${sig}\n`,
334
- )
335
- }
336
- catch (err) {
337
- process.stderr.write(`launcher: cancel.flag write failed: ${err.message}\n`)
338
- }
339
- scheduleKillTree(child, graceMs)
340
- }
341
- process.on('SIGINT', () => onSignal('SIGINT'))
342
- process.on('SIGTERM', () => onSignal('SIGTERM'))
343
-
344
- // Await child exit and write terminal state.
345
- const exit = await new Promise((resolve) => {
346
- child.once('exit', (code, signal) => {
347
- resolve({ code, signal })
348
- })
349
- child.once('error', (err) => {
350
- process.stderr.write(`launcher: spawn error: ${err.message}\n`)
351
- resolve({ code: 70, signal: null })
352
- })
353
- })
354
-
355
- clearInterval(tickCancelPoll)
356
- progressFd.end()
357
-
358
- const terminalStatus
359
- = exit.code === 0
360
- ? 'done'
361
- : cancelInjected
362
- ? 'canceled'
363
- : 'failed'
364
-
365
- writeState(workdir, jobId, {
366
- ...initial,
367
- cli_pid: child.pid,
368
- process_group_id: pgid,
369
- status: terminalStatus,
370
- summary: `exit code ${exit.code}${exit.signal ? ` (signal ${exit.signal})` : ''}`,
371
- })
372
-
373
- return typeof exit.code === 'number' ? exit.code : 70
374
- }
375
-
376
- // ---------------------------------------------------------------------------
377
- // kill-tree implementation (POSIX -pgid + Windows taskkill /T /F).
378
- // Inline rather than imported because this script is shipped as a flat .mjs
379
- // to ~/.claude/.ccg/scripts/ — no transpile pipeline.
380
- // ---------------------------------------------------------------------------
381
-
382
- function scheduleKillTree(child, graceMs) {
383
- if (!child || !child.pid) return
384
- const pid = child.pid
385
-
386
- // Phase 1: gentle SIGTERM (POSIX) or taskkill /T (Windows, no /F).
387
- try {
388
- if (isWindows()) {
389
- spawn('taskkill', ['/T', '/PID', String(pid)], {
390
- stdio: 'ignore',
391
- windowsHide: true,
392
- })
393
- }
394
- else {
395
- // detached children get their own pgid == pid, so kill(-pid) hits the group.
396
- try { process.kill(-pid, 'SIGTERM') }
397
- catch { try { process.kill(pid, 'SIGTERM') } catch { /* gone */ } }
398
- }
399
- }
400
- catch {
401
- // Don't let signal failure prevent the forced phase.
402
- }
403
-
404
- // Phase 2 timer: if the child hasn't exited within graceMs, force kill.
405
- setTimeout(() => {
406
- if (child.exitCode !== null) return
407
- try {
408
- if (isWindows()) {
409
- spawn('taskkill', ['/T', '/F', '/PID', String(pid)], {
410
- stdio: 'ignore',
411
- windowsHide: true,
412
- })
413
- }
414
- else {
415
- try { process.kill(-pid, 'SIGKILL') }
416
- catch { try { process.kill(pid, 'SIGKILL') } catch { /* gone */ } }
417
- }
418
- }
419
- catch {
420
- // Best effort — exhausted options.
421
- }
422
- }, graceMs).unref?.()
423
- }
424
-
425
- // ---------------------------------------------------------------------------
426
- // Append-mode write stream wrapper (no fs.WriteStream import for clarity)
427
- // ---------------------------------------------------------------------------
428
-
429
- function openAppendStream(path) {
430
- return createWriteStream(path, { flags: 'a' })
431
- }
432
-
433
- // ---------------------------------------------------------------------------
434
- // Entry point — only run main() when invoked as a script (not when imported
435
- // for unit tests). Detection via import.meta.url vs the resolved argv[1].
436
- // ---------------------------------------------------------------------------
437
-
438
- function isMainModule() {
439
- if (!process.argv[1]) return false
440
- try {
441
- const here = fileURLToPath(import.meta.url)
442
- return realpathSync(here) === realpathSync(process.argv[1])
443
- }
444
- catch {
445
- return false
446
- }
447
- }
448
-
449
- if (isMainModule()) {
450
- main(process.argv)
451
- .then((code) => {
452
- process.exit(code)
453
- })
454
- .catch((err) => {
455
- process.stderr.write(`launcher: fatal: ${err.stack || err.message}\n`)
456
- process.exit(70)
457
- })
458
- }
459
-
460
- // Test surface — exposed for unit-test consumption via dynamic import().
461
- // Only the pure helpers; main() is integration-tested separately.
462
- export const ccgPhaseRunnerLauncherExports = {
463
- parseArgs,
464
- buildClaudeArgs,
465
- TIER_BUDGET,
466
- atomicWriteFileSync,
467
- }
1
+ // =============================================================================
2
+ // ccg-phase-runner-launcher.mjs v4.5 P1b
3
+ // -----------------------------------------------------------------------------
4
+ // Supervised launcher for `claude -p --agent ccg/phase-runner ...` subprocess.
5
+ //
6
+ // Why this exists (codex C2 + C4):
7
+ // The autonomous main thread cannot itself supervise an OS subprocess —
8
+ // Claude is an LLM running tool calls, not a process manager. The naive
9
+ // `Bash(claude -p ...)` shell call is fire-and-forget: if Claude crashes
10
+ // after spawn, the subprocess can orphan; if the user hits Ctrl+C, nested
11
+ // plugin processes survive; if the subprocess hangs, no one notices.
12
+ //
13
+ // This launcher wraps the spawn so that:
14
+ // 1. Job state file is written atomically *before* spawn (parent_pid,
15
+ // cli_pid, process_group_id, cwd, cmd, started_at).
16
+ // 2. The CLI subprocess is launched in its own session/process group
17
+ // (POSIX `detached: true` → setsid()) so the whole tree can be
18
+ // signalled as a unit.
19
+ // 3. On exit (success / error / signal), terminal state is written
20
+ // atomically; the CCG status command can report the truth.
21
+ // 4. On the launcher receiving SIGINT/SIGTERM (Ctrl+C from the parent),
22
+ // the cancel.flag is observed cooperatively, then the process tree
23
+ // is killed after the grace period.
24
+ //
25
+ // Usage (called by `Bash(node ~/.claude/.ccg/scripts/ccg-phase-runner-launcher.mjs ...)`):
26
+ //
27
+ // node ccg-phase-runner-launcher.mjs \
28
+ // --job-id <id> \
29
+ // --workdir <path> \
30
+ // --prompt-file <path> \
31
+ // --tier <fast|triple|debate> \
32
+ // [--max-budget-usd <N>] \
33
+ // [--grace-ms <N>] # SIGTERM -> SIGKILL grace, default 5000
34
+ //
35
+ // Exit code: forwarded from the inner `claude -p` child. Launcher own errors
36
+ // surface as exit 64 (EX_USAGE) or 70 (EX_SOFTWARE).
37
+ //
38
+ // Cross-cutting:
39
+ // - Pure stdlib (fs / child_process / crypto / path / os). No deps.
40
+ // - State writes are temp + rename (atomicWriteFileSync, ported below).
41
+ // - Stream-json output streams to .context/jobs/<id>/progress.jsonl as the
42
+ // child runs; we don't transform it, we just plumb stdout/stderr through.
43
+ //
44
+ // ⚠ Schema contract for state.json (consumed by reconciler in src/utils/
45
+ // process-tree.ts → SupervisedJobState):
46
+ //
47
+ // {
48
+ // task_id, kind: "phase-runner", status,
49
+ // started_at, last_update,
50
+ // parent_pid, cli_pid, process_group_id, cwd, cmd
51
+ // }
52
+ // =============================================================================
53
+
54
+ import { spawn } from 'node:child_process'
55
+ import { randomBytes, randomUUID } from 'node:crypto'
56
+ import {
57
+ createWriteStream,
58
+ existsSync,
59
+ mkdirSync,
60
+ readFileSync,
61
+ realpathSync,
62
+ renameSync,
63
+ unlinkSync,
64
+ writeFileSync,
65
+ } from 'node:fs'
66
+ import { join } from 'node:path'
67
+ import { fileURLToPath } from 'node:url'
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Internal helpers (kept verbatim local to keep launcher dependency-free)
71
+ // ---------------------------------------------------------------------------
72
+
73
+ function atomicWriteFileSync(target, content) {
74
+ const rand = randomBytes(6).toString('hex')
75
+ const tmp = `${target}.tmp.${rand}`
76
+ try {
77
+ writeFileSync(tmp, content, 'utf-8')
78
+ renameSync(tmp, target)
79
+ }
80
+ catch (err) {
81
+ try { unlinkSync(tmp) }
82
+ catch { /* nothing */ }
83
+ throw err
84
+ }
85
+ }
86
+
87
+ function isWindows() {
88
+ return process.platform === 'win32'
89
+ }
90
+
91
+ function nowIso() {
92
+ return new Date().toISOString()
93
+ }
94
+
95
+ function ensureDir(p) {
96
+ if (!existsSync(p)) mkdirSync(p, { recursive: true })
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Argument parsing — minimal, KISS, no external CLI lib.
101
+ // ---------------------------------------------------------------------------
102
+
103
+ function parseArgs(argv) {
104
+ const opts = {
105
+ jobId: null,
106
+ workdir: null,
107
+ promptFile: null,
108
+ tier: 'triple',
109
+ maxBudgetUsd: null,
110
+ graceMs: 5000,
111
+ }
112
+ for (let i = 2; i < argv.length; i++) {
113
+ const arg = argv[i]
114
+ const next = () => {
115
+ const v = argv[++i]
116
+ if (v === undefined) {
117
+ throw new Error(`flag ${arg} requires a value`)
118
+ }
119
+ return v
120
+ }
121
+ switch (arg) {
122
+ case '--job-id': opts.jobId = next(); break
123
+ case '--workdir': opts.workdir = next(); break
124
+ case '--prompt-file': opts.promptFile = next(); break
125
+ case '--tier': opts.tier = next(); break
126
+ case '--max-budget-usd': opts.maxBudgetUsd = Number.parseFloat(next()); break
127
+ case '--grace-ms': opts.graceMs = Number.parseInt(next(), 10); break
128
+ case '--help':
129
+ case '-h':
130
+ printHelp()
131
+ process.exit(0)
132
+ default:
133
+ throw new Error(`unknown flag: ${arg}`)
134
+ }
135
+ }
136
+ if (!opts.jobId) throw new Error('--job-id is required')
137
+ if (!opts.workdir) throw new Error('--workdir is required')
138
+ if (!opts.promptFile) throw new Error('--prompt-file is required')
139
+ if (!['fast', 'triple', 'debate'].includes(opts.tier)) {
140
+ throw new Error(`invalid --tier: ${opts.tier}`)
141
+ }
142
+ return opts
143
+ }
144
+
145
+ function printHelp() {
146
+ process.stderr.write(`Usage: ccg-phase-runner-launcher.mjs [flags]
147
+
148
+ Required:
149
+ --job-id <id> Job identifier (becomes .context/jobs/<id>/)
150
+ --workdir <path> Phase workdir; subprocess cwd
151
+ --prompt-file <path> Prompt body file (relative to workdir or absolute)
152
+
153
+ Optional:
154
+ --tier <fast|triple|debate> Quality tier; maps to --max-budget-usd
155
+ (fast=1, triple=2, debate=5). Default: triple.
156
+ --max-budget-usd <N> Override per-call budget cap.
157
+ --grace-ms <N> SIGTERM->SIGKILL grace (default 5000).
158
+
159
+ Exits with the inner claude exit code. Own errors: 64 (usage), 70 (software).
160
+ `)
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // State helpers (tightly mirror src/utils/jobs.ts contract)
165
+ // ---------------------------------------------------------------------------
166
+
167
+ function jobDir(workdir, jobId) {
168
+ return join(workdir, '.context', 'jobs', jobId)
169
+ }
170
+
171
+ function statePath(workdir, jobId) {
172
+ return join(jobDir(workdir, jobId), 'state.json')
173
+ }
174
+
175
+ function progressPath(workdir, jobId) {
176
+ return join(jobDir(workdir, jobId), 'progress.jsonl')
177
+ }
178
+
179
+ function cancelFlagPath(workdir, jobId) {
180
+ return join(jobDir(workdir, jobId), 'cancel.flag')
181
+ }
182
+
183
+ function writeState(workdir, jobId, state) {
184
+ ensureDir(jobDir(workdir, jobId))
185
+ const updated = { ...state, last_update: nowIso() }
186
+ atomicWriteFileSync(statePath(workdir, jobId), JSON.stringify(updated, null, 2))
187
+ return updated
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Build the `claude -p` argv. Mirrors `buildPhaseRunnerBashCommand` in
192
+ // src/utils/quality-router.ts (single source of truth for what flags live in
193
+ // production phase-runner spawn).
194
+ // ---------------------------------------------------------------------------
195
+
196
+ const TIER_BUDGET = { fast: 1.0, triple: 2.0, debate: 5.0 }
197
+
198
+ function buildClaudeArgs({ promptFile, workdir, tier, maxBudgetUsd }) {
199
+ const budget = maxBudgetUsd ?? TIER_BUDGET[tier]
200
+ const promptBody = readFileSync(promptFile, 'utf-8')
201
+ return [
202
+ '-p', promptBody,
203
+ '--agent', 'ccg/phase-runner',
204
+ '--output-format', 'stream-json',
205
+ '--include-partial-messages',
206
+ '--verbose',
207
+ '--max-budget-usd', String(budget),
208
+ '--dangerously-skip-permissions',
209
+ '--add-dir', workdir,
210
+ ]
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Main launcher
215
+ // ---------------------------------------------------------------------------
216
+
217
+ async function main(argv) {
218
+ let opts
219
+ try {
220
+ opts = parseArgs(argv)
221
+ }
222
+ catch (err) {
223
+ process.stderr.write(`launcher: ${err.message}\n`)
224
+ printHelp()
225
+ return 64 // EX_USAGE
226
+ }
227
+
228
+ const { jobId, workdir, graceMs } = opts
229
+ ensureDir(jobDir(workdir, jobId))
230
+
231
+ let claudeArgs
232
+ try {
233
+ claudeArgs = buildClaudeArgs(opts)
234
+ }
235
+ catch (err) {
236
+ process.stderr.write(`launcher: cannot build claude args: ${err.message}\n`)
237
+ writeState(workdir, jobId, {
238
+ task_id: jobId,
239
+ kind: 'phase-runner',
240
+ status: 'failed',
241
+ started_at: nowIso(),
242
+ last_update: nowIso(),
243
+ summary: `launcher build args failed: ${err.message}`,
244
+ parent_pid: process.pid,
245
+ cwd: workdir,
246
+ })
247
+ return 70 // EX_SOFTWARE
248
+ }
249
+
250
+ // Mint a broker tx_id (v4.5 P1d, codex C3). One V4 UUID per launcher
251
+ // invocation; the CLI subprocess + any nested plugin Agents spawned inside
252
+ // it inherit the same tx_id via env, so broker.log readers can correlate
253
+ // every event back to this one logical phase-runner transaction.
254
+ // crypto.randomUUID is the only acceptable source — Math.random / Date.now /
255
+ // PID all leak entropy and are reused across processes.
256
+ const txId = randomUUID()
257
+ const brokerLogPath = join(workdir, '.context', 'broker.log')
258
+
259
+ // Initial state — written *before* spawn so a crash between here and spawn
260
+ // leaves a recoverable artifact for the reconciler.
261
+ const initial = writeState(workdir, jobId, {
262
+ task_id: jobId,
263
+ kind: 'phase-runner',
264
+ status: 'running',
265
+ started_at: nowIso(),
266
+ last_update: nowIso(),
267
+ parent_pid: process.pid,
268
+ cwd: workdir,
269
+ cmd: `claude ${claudeArgs.slice(0, 6).join(' ')} ... [redacted prompt]`,
270
+ broker_tx_id: txId,
271
+ })
272
+
273
+ // Spawn the child. `detached: true` on POSIX calls setsid() so the child
274
+ // gets its own session/group → we can signal the whole tree later via -pgid.
275
+ // On Windows, `detached: true` calls CreateProcess with DETACHED_PROCESS;
276
+ // we still rely on `taskkill /T /F` for tree termination (codeagent-wrapper
277
+ // precedent — see executor.go:1421).
278
+ //
279
+ // env inheritance: we extend process.env (parent of the launcher) with the
280
+ // broker-correlation triplet so phase-runner subagent code + nested plugin
281
+ // spawns can emit broker events under the same tx_id.
282
+ const child = spawn('claude', claudeArgs, {
283
+ cwd: workdir,
284
+ detached: !isWindows(),
285
+ windowsHide: true,
286
+ // Pipe stdout to progress file; let stderr surface to the launcher's
287
+ // stderr so users see auth / quota errors without grep-ing files.
288
+ stdio: ['ignore', 'pipe', 'inherit'],
289
+ env: {
290
+ ...process.env,
291
+ CCG_BROKER_TX_ID: txId,
292
+ CCG_BROKER_LOG_PATH: brokerLogPath,
293
+ CCG_OUTER_CLI_PID: String(process.pid),
294
+ CCG_JOB_ID: jobId,
295
+ CCG_PHASE_RUNNER_TIER: opts.tier,
296
+ },
297
+ })
298
+
299
+ // Persist cli_pid + process_group_id ASAP — race window before spawn returns
300
+ // pid is closed by the time `child.pid` is set (synchronously in Node).
301
+ const pgid = !isWindows() ? child.pid : undefined
302
+ writeState(workdir, jobId, {
303
+ ...initial,
304
+ cli_pid: child.pid,
305
+ process_group_id: pgid,
306
+ })
307
+
308
+ // Stream stdout to progress.jsonl. We append to keep crash-resume semantics
309
+ // (don't truncate prior bytes if the launcher itself was restarted).
310
+ const progressFd = openAppendStream(progressPath(workdir, jobId))
311
+ child.stdout.on('data', (chunk) => {
312
+ progressFd.write(chunk)
313
+ // Mirror to the launcher's stdout for the parent Bash poller.
314
+ process.stdout.write(chunk)
315
+ })
316
+
317
+ // Cooperative cancel + signal-driven kill-tree.
318
+ let cancelInjected = false
319
+ const tickCancelPoll = setInterval(() => {
320
+ if (existsSync(cancelFlagPath(workdir, jobId)) && !cancelInjected) {
321
+ cancelInjected = true
322
+ // Step 1 cooperative: many subagents poll cancel.flag themselves.
323
+ // Step 2 kill-tree: we still want to enforce after the grace period.
324
+ scheduleKillTree(child, graceMs)
325
+ }
326
+ }, 1000)
327
+
328
+ const onSignal = (sig) => {
329
+ process.stderr.write(`launcher: received ${sig}; writing cancel.flag + grace ${graceMs}ms\n`)
330
+ try {
331
+ atomicWriteFileSync(
332
+ cancelFlagPath(workdir, jobId),
333
+ `cancel-requested-at: ${nowIso()}\nrequested-by: launcher signal ${sig}\n`,
334
+ )
335
+ }
336
+ catch (err) {
337
+ process.stderr.write(`launcher: cancel.flag write failed: ${err.message}\n`)
338
+ }
339
+ scheduleKillTree(child, graceMs)
340
+ }
341
+ process.on('SIGINT', () => onSignal('SIGINT'))
342
+ process.on('SIGTERM', () => onSignal('SIGTERM'))
343
+
344
+ // Await child exit and write terminal state.
345
+ const exit = await new Promise((resolve) => {
346
+ child.once('exit', (code, signal) => {
347
+ resolve({ code, signal })
348
+ })
349
+ child.once('error', (err) => {
350
+ process.stderr.write(`launcher: spawn error: ${err.message}\n`)
351
+ resolve({ code: 70, signal: null })
352
+ })
353
+ })
354
+
355
+ clearInterval(tickCancelPoll)
356
+ progressFd.end()
357
+
358
+ const terminalStatus
359
+ = exit.code === 0
360
+ ? 'done'
361
+ : cancelInjected
362
+ ? 'canceled'
363
+ : 'failed'
364
+
365
+ writeState(workdir, jobId, {
366
+ ...initial,
367
+ cli_pid: child.pid,
368
+ process_group_id: pgid,
369
+ status: terminalStatus,
370
+ summary: `exit code ${exit.code}${exit.signal ? ` (signal ${exit.signal})` : ''}`,
371
+ })
372
+
373
+ return typeof exit.code === 'number' ? exit.code : 70
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // kill-tree implementation (POSIX -pgid + Windows taskkill /T /F).
378
+ // Inline rather than imported because this script is shipped as a flat .mjs
379
+ // to ~/.claude/.ccg/scripts/ — no transpile pipeline.
380
+ // ---------------------------------------------------------------------------
381
+
382
+ function scheduleKillTree(child, graceMs) {
383
+ if (!child || !child.pid) return
384
+ const pid = child.pid
385
+
386
+ // Phase 1: gentle SIGTERM (POSIX) or taskkill /T (Windows, no /F).
387
+ try {
388
+ if (isWindows()) {
389
+ spawn('taskkill', ['/T', '/PID', String(pid)], {
390
+ stdio: 'ignore',
391
+ windowsHide: true,
392
+ })
393
+ }
394
+ else {
395
+ // detached children get their own pgid == pid, so kill(-pid) hits the group.
396
+ try { process.kill(-pid, 'SIGTERM') }
397
+ catch { try { process.kill(pid, 'SIGTERM') } catch { /* gone */ } }
398
+ }
399
+ }
400
+ catch {
401
+ // Don't let signal failure prevent the forced phase.
402
+ }
403
+
404
+ // Phase 2 timer: if the child hasn't exited within graceMs, force kill.
405
+ setTimeout(() => {
406
+ if (child.exitCode !== null) return
407
+ try {
408
+ if (isWindows()) {
409
+ spawn('taskkill', ['/T', '/F', '/PID', String(pid)], {
410
+ stdio: 'ignore',
411
+ windowsHide: true,
412
+ })
413
+ }
414
+ else {
415
+ try { process.kill(-pid, 'SIGKILL') }
416
+ catch { try { process.kill(pid, 'SIGKILL') } catch { /* gone */ } }
417
+ }
418
+ }
419
+ catch {
420
+ // Best effort — exhausted options.
421
+ }
422
+ }, graceMs).unref?.()
423
+ }
424
+
425
+ // ---------------------------------------------------------------------------
426
+ // Append-mode write stream wrapper (no fs.WriteStream import for clarity)
427
+ // ---------------------------------------------------------------------------
428
+
429
+ function openAppendStream(path) {
430
+ return createWriteStream(path, { flags: 'a' })
431
+ }
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // Entry point — only run main() when invoked as a script (not when imported
435
+ // for unit tests). Detection via import.meta.url vs the resolved argv[1].
436
+ // ---------------------------------------------------------------------------
437
+
438
+ function isMainModule() {
439
+ if (!process.argv[1]) return false
440
+ try {
441
+ const here = fileURLToPath(import.meta.url)
442
+ return realpathSync(here) === realpathSync(process.argv[1])
443
+ }
444
+ catch {
445
+ return false
446
+ }
447
+ }
448
+
449
+ if (isMainModule()) {
450
+ main(process.argv)
451
+ .then((code) => {
452
+ process.exit(code)
453
+ })
454
+ .catch((err) => {
455
+ process.stderr.write(`launcher: fatal: ${err.stack || err.message}\n`)
456
+ process.exit(70)
457
+ })
458
+ }
459
+
460
+ // Test surface — exposed for unit-test consumption via dynamic import().
461
+ // Only the pure helpers; main() is integration-tested separately.
462
+ export const ccgPhaseRunnerLauncherExports = {
463
+ parseArgs,
464
+ buildClaudeArgs,
465
+ TIER_BUDGET,
466
+ atomicWriteFileSync,
467
+ }