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,510 +1,566 @@
1
- #!/usr/bin/env node
2
- // ccg-hook: session-state
3
- // SessionStart Hook — auto-inject CCG project memory into a fresh session.
4
- //
5
- // Problem this solves (CCG v4.0 dogfood Q6 + GSD gsd-session-state.sh parity):
6
- // After /clear or a brand-new session, the orchestrator has zero memory of
7
- // the project's roadmap state. Users had to manually paste a "resume" file
8
- // (see .ccg/SESSION-RESUME.md) to get going. This hook automates it: when a
9
- // session starts in a CCG project (cwd has .ccg/roadmap.md), it injects a
10
- // ≤200-token summary describing project name, active phase, and next action.
11
- //
12
- // Hook contract (Claude Code SessionStart event):
13
- // stdin : JSON with at least { hookEventName, session_id, cwd? }
14
- // cwd may be absent — we fall back to process.cwd().
15
- // stdout : JSON
16
- // { hookSpecificOutput: { hookEventName: 'SessionStart',
17
- // additionalContext: '<string>' } }
18
- // Empty / missing additionalContext means "no injection". For non-CCG
19
- // projects we exit cleanly without writing anything (noop).
20
- //
21
- // Failure policy: never throw; never block a session start. Any parse error or
22
- // missing file degrades to a smaller-but-still-useful summary, or to a noop.
23
-
24
- 'use strict'
25
-
26
- const fs = require('fs')
27
- const path = require('path')
28
- const crypto = require('crypto')
29
-
30
- // ---------------------------------------------------------------------------
31
- // Pure helpers (exported for unit tests via ccgSessionStateHookExports)
32
- // ---------------------------------------------------------------------------
33
-
34
- /**
35
- * Extract roadmap.md head metadata: project name, started, last updated.
36
- *
37
- * Roadmap convention (see .ccg/roadmap.md): bold-tagged key-value lines such as
38
- * **Project**: ccg-workflow v4.0
39
- * **Started**: 2026-05-03
40
- * **Last Updated**: 2026-05-04
41
- * Lines may appear in any order within the first ~20 lines. Anything we cannot
42
- * locate yields undefined — callers must tolerate that.
43
- */
44
- function parseRoadmapHead(text) {
45
- const head = text.split(/\r?\n/).slice(0, 30).join('\n')
46
- const grab = (label) => {
47
- const re = new RegExp(`\\*\\*${label}\\*\\*\\s*[::]\\s*(.+)`, 'i')
48
- const m = head.match(re)
49
- return m ? m[1].trim() : undefined
50
- }
51
- return {
52
- project: grab('Project'),
53
- started: grab('Started'),
54
- lastUpdated: grab('Last Updated'),
55
- }
56
- }
57
-
58
- /**
59
- * Parse phase headers. Each phase is denoted by `## Phase N: Title (status)`,
60
- * where `N` may include a dot (e.g. 1.5) and `status` is one of completed /
61
- * in_progress / pending / blocked / skipped.
62
- *
63
- * Returns array preserving file order. The "active" phase used for context
64
- * injection is the first one whose status is `in_progress`; if none, the
65
- * first `pending` phase; if all completed, null.
66
- */
67
- function parsePhases(text) {
68
- const re = /^##\s+Phase\s+([\d.]+)\s*:\s*(.+?)\s*(?:\[[^\]]+\])?\s*\(([^)]+)\)\s*$/gim
69
- const phases = []
70
- let match
71
- while ((match = re.exec(text)) !== null) {
72
- phases.push({
73
- n: match[1],
74
- title: match[2].trim(),
75
- status: match[3].trim().toLowerCase(),
76
- })
77
- }
78
- return phases
79
- }
80
-
81
- /**
82
- * Pick the phase whose state is most relevant for resume context.
83
- * 1. First in_progress phase (resume work mid-flight)
84
- * 2. Else first pending phase (next-up work)
85
- * 3. Else null (every phase completed)
86
- */
87
- function pickActivePhase(phases) {
88
- return (
89
- phases.find(p => p.status === 'in_progress')
90
- || phases.find(p => p.status === 'pending')
91
- || null
92
- )
93
- }
94
-
95
- /**
96
- * Map a roadmap phase entry to its `.context/<dir>/SUMMARY.md` directory name.
97
- *
98
- * Convention used by /ccg:autonomous + phase-runner: `phase-NN-<slug>` where NN
99
- * is two-digit (zero-padded for integers). Phase 1.5 keeps its decimal. Slug is
100
- * the title lowercased with non-alphanumerics collapsed to dashes.
101
- *
102
- * We do NOT guarantee this dir exists — caller must existsSync() before reading.
103
- */
104
- function phaseDirName(phase) {
105
- const n = phase.n
106
- const padded = /^\d+$/.test(n) ? n.padStart(2, '0') : n
107
- const slug = phase.title
108
- .toLowerCase()
109
- .replace(/[^a-z0-9]+/g, '-')
110
- .replace(/^-+|-+$/g, '')
111
- return slug ? `phase-${padded}-${slug}` : `phase-${padded}`
112
- }
113
-
114
- /**
115
- * Lift YAML frontmatter into a flat Record<string, string>. Only handles the
116
- * minimal subset that .context/<phase>/SUMMARY.md uses — scalar key/value pairs
117
- * and short inline lists. Anything fancier degrades to the raw string.
118
- *
119
- * We deliberately do NOT pull in src/utils/phase-context.ts here — this hook
120
- * runs as a standalone Node script under ~/.claude/hooks/ with no transpile
121
- * step, so it must be self-contained.
122
- */
123
- function parseSummaryFrontmatter(content) {
124
- const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
125
- if (!m) return null
126
- const out = {}
127
- for (const raw of m[1].split(/\r?\n/)) {
128
- const line = raw.trim()
129
- if (!line || line.startsWith('#')) continue
130
- const km = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/)
131
- if (!km) continue
132
- let value = km[2].trim()
133
- // Strip surrounding quotes
134
- if ((value.startsWith('"') && value.endsWith('"'))
135
- || (value.startsWith('\'') && value.endsWith('\''))) {
136
- value = value.slice(1, -1)
137
- }
138
- out[km[1]] = value
139
- }
140
- return out
141
- }
142
-
143
- /**
144
- * Compose the actual additionalContext string (capped to keep main-thread
145
- * context budget honored). Stays under ~200 tokens by hard-truncating at
146
- * 800 chars after composition.
147
- *
148
- * Inputs:
149
- * head — { project, started, lastUpdated } (any may be undefined)
150
- * active — phase object or null
151
- * summary — parsed SUMMARY.md frontmatter or null
152
- * counts — { total, completed }
153
- */
154
- function composeMessage(head, active, summary, counts) {
155
- const lines = []
156
- lines.push('[CCG] Project memory restored from .ccg/roadmap.md.')
157
-
158
- const projectLine = []
159
- if (head.project) projectLine.push(`Project: ${head.project}`)
160
- if (counts.total > 0) {
161
- projectLine.push(`Phases: ${counts.completed}/${counts.total} completed`)
162
- }
163
- if (projectLine.length) lines.push(projectLine.join(' | '))
164
-
165
- if (!active) {
166
- if (counts.total > 0 && counts.completed === counts.total) {
167
- lines.push('Status: All phases completed.')
168
- }
169
- else if (counts.total === 0) {
170
- lines.push('Status: roadmap.md present but no phases parsed.')
171
- }
172
- }
173
- else {
174
- const tag = active.status === 'in_progress' ? 'Active' : 'Next'
175
- lines.push(`${tag} phase: ${active.n} ${active.title} (${active.status})`)
176
- if (summary) {
177
- const provides = summary.provides
178
- const nextAction = summary['next-action'] || summary.next_action || summary.nextAction
179
- if (provides) lines.push(`Provides: ${provides}`)
180
- if (nextAction) lines.push(`Next action: ${nextAction}`)
181
- }
182
- }
183
-
184
- lines.push('Read .ccg/roadmap.md for full state. Continue from the active phase or ask the user where to start.')
185
-
186
- let msg = lines.join('\n')
187
- if (msg.length > 800) msg = `${msg.slice(0, 797)}...`
188
- return msg
189
- }
190
-
191
- // ---------------------------------------------------------------------------
192
- // v4.5 P1b startup reconciler (inlined CJS twin of src/utils/process-tree.ts).
193
- //
194
- // The hook MUST stay self-contained (see top-of-file comment). We duplicate
195
- // the minimal logic rather than `require('../../src/utils/process-tree')`
196
- // the hook is shipped to ~/.claude/hooks/ where TS source is unavailable.
197
- //
198
- // Behaviour matrix (mirrors process-tree.ts reconcileStaleJobs):
199
- // - .context/jobs/* missing → no-op (return empty)
200
- // - state.status terminal (done/failed/canceled) → no-op
201
- // - cli_pid alive → no-op
202
- // - cli_pid dead AND result.md present → adopt-result
203
- // - cli_pid dead AND no result.md → mark-failed-stale
204
- // - status=running but no cli_pid (legacy) mark-failed-no-result
205
- // ---------------------------------------------------------------------------
206
-
207
- function isAlivePid(pid) {
208
- if (!Number.isInteger(pid) || pid <= 0) return false
209
- try {
210
- process.kill(pid, 0)
211
- return true
212
- }
213
- catch (err) {
214
- if (err && err.code === 'EPERM') return true
215
- return false
216
- }
217
- }
218
-
219
- function atomicWriteFileSync(target, content) {
220
- const rand = crypto.randomBytes(6).toString('hex')
221
- const tmp = `${target}.tmp.${rand}`
222
- try {
223
- fs.writeFileSync(tmp, content, 'utf-8')
224
- fs.renameSync(tmp, target)
225
- }
226
- catch (err) {
227
- try { fs.unlinkSync(tmp) }
228
- catch { /* nothing to clean up */ }
229
- throw err
230
- }
231
- }
232
-
233
- function reconcileStaleJobs(cwd, options) {
234
- const opts = options || {}
235
- const isAlive = opts.isAliveFn || isAlivePid
236
- const now = typeof opts.nowMs === 'number' ? opts.nowMs : Date.now()
237
- const reuseAgeMs = typeof opts.pidReuseAgeMs === 'number'
238
- ? opts.pidReuseAgeMs
239
- : 24 * 60 * 60 * 1000
240
-
241
- const root = path.join(cwd, '.context', 'jobs')
242
- const report = { scanned: 0, entries: [] }
243
- if (!fs.existsSync(root)) return report
244
-
245
- let dirs
246
- try { dirs = fs.readdirSync(root) }
247
- catch { return report }
248
-
249
- for (const id of dirs) {
250
- const sub = path.join(root, id)
251
- let isDir = false
252
- try { isDir = fs.statSync(sub).isDirectory() }
253
- catch { continue }
254
- if (!isDir) continue
255
-
256
- const statePath = path.join(sub, 'state.json')
257
- if (!fs.existsSync(statePath)) continue
258
-
259
- let state
260
- try {
261
- state = JSON.parse(fs.readFileSync(statePath, 'utf-8'))
262
- }
263
- catch {
264
- // Corrupt skip silently; getJob() in src will surface to the user.
265
- continue
266
- }
267
- report.scanned += 1
268
-
269
- if (
270
- state.status === 'done'
271
- || state.status === 'failed'
272
- || state.status === 'canceled'
273
- ) {
274
- report.entries.push({ jobId: id, action: 'no-op', reason: 'terminal status' })
275
- continue
276
- }
277
-
278
- if (typeof state.cli_pid !== 'number') {
279
- const updated = Object.assign({}, state, {
280
- status: 'failed',
281
- summary: 'reconciler: legacy job without cli_pid; cannot verify liveness',
282
- last_update: new Date().toISOString(),
283
- })
284
- try { atomicWriteFileSync(statePath, JSON.stringify(updated, null, 2)) }
285
- catch { /* swallow — never block session start */ }
286
- report.entries.push({
287
- jobId: id,
288
- action: 'mark-failed-no-result',
289
- reason: 'no cli_pid recorded',
290
- })
291
- continue
292
- }
293
-
294
- const alive = isAlive(state.cli_pid)
295
- let pidProbablyReused = false
296
- if (alive && state.started_at) {
297
- const startedMs = Date.parse(state.started_at)
298
- if (Number.isFinite(startedMs) && (now - startedMs) > reuseAgeMs) {
299
- pidProbablyReused = true
300
- }
301
- }
302
-
303
- if (alive && !pidProbablyReused) {
304
- report.entries.push({ jobId: id, action: 'no-op', reason: 'cli_pid alive' })
305
- continue
306
- }
307
-
308
- const resultPath = path.join(sub, 'result.md')
309
- if (fs.existsSync(resultPath)) {
310
- const updated = Object.assign({}, state, {
311
- status: 'done',
312
- summary: 'reconciler: cli_pid not alive; adopted result.md after orphan recovery',
313
- last_update: new Date().toISOString(),
314
- })
315
- try { atomicWriteFileSync(statePath, JSON.stringify(updated, null, 2)) }
316
- catch { /* swallow */ }
317
- report.entries.push({
318
- jobId: id,
319
- action: 'adopt-result',
320
- reason: pidProbablyReused
321
- ? 'pid reuse suspected; result.md present'
322
- : 'cli_pid dead; result.md present',
323
- })
324
- continue
325
- }
326
-
327
- const updated = Object.assign({}, state, {
328
- status: 'failed',
329
- summary: pidProbablyReused
330
- ? 'reconciler: cli_pid suspected reused; no result.md found'
331
- : 'reconciler: cli_pid dead; no result.md found',
332
- last_update: new Date().toISOString(),
333
- })
334
- try { atomicWriteFileSync(statePath, JSON.stringify(updated, null, 2)) }
335
- catch { /* swallow */ }
336
- report.entries.push({
337
- jobId: id,
338
- action: 'mark-failed-stale',
339
- reason: pidProbablyReused
340
- ? 'pid reuse + no result'
341
- : 'cli_pid dead + no result',
342
- })
343
- }
344
-
345
- return report
346
- }
347
-
348
- /**
349
- * Compose a one-line reconciler summary for injection into additionalContext.
350
- * Returns null when nothing of interest happened (so the hook stays quiet for
351
- * fresh / clean sessions).
352
- */
353
- function summarizeReconciliation(report) {
354
- if (!report || report.scanned === 0) return null
355
- const counts = { 'mark-failed-stale': 0, 'mark-failed-no-result': 0, 'adopt-result': 0 }
356
- for (const e of report.entries) {
357
- if (counts[e.action] !== undefined) counts[e.action] += 1
358
- }
359
- const interesting = counts['mark-failed-stale']
360
- + counts['mark-failed-no-result']
361
- + counts['adopt-result']
362
- if (interesting === 0) return null
363
- const parts = []
364
- if (counts['mark-failed-stale'])
365
- parts.push(`${counts['mark-failed-stale']} stale-failed`)
366
- if (counts['mark-failed-no-result'])
367
- parts.push(`${counts['mark-failed-no-result']} no-pid-failed`)
368
- if (counts['adopt-result'])
369
- parts.push(`${counts['adopt-result']} adopted-result`)
370
- return `Reconciled ${interesting}/${report.scanned} jobs: ${parts.join(', ')}.`
371
- }
372
-
373
- /**
374
- * Build the additionalContext string for a given workdir. Returns null if the
375
- * cwd is not a CCG project (no .ccg/roadmap.md). Never throws.
376
- */
377
- function buildAdditionalContext(cwd) {
378
- const roadmapPath = path.join(cwd, '.ccg', 'roadmap.md')
379
- if (!fs.existsSync(roadmapPath)) return null
380
-
381
- let roadmapText
382
- try {
383
- roadmapText = fs.readFileSync(roadmapPath, 'utf8')
384
- }
385
- catch {
386
- return null
387
- }
388
-
389
- const head = parseRoadmapHead(roadmapText)
390
- const phases = parsePhases(roadmapText)
391
- const active = pickActivePhase(phases)
392
- const counts = {
393
- total: phases.length,
394
- completed: phases.filter(p => p.status === 'completed').length,
395
- }
396
-
397
- let summary = null
398
- if (active) {
399
- const dir = phaseDirName(active)
400
- const summaryPath = path.join(cwd, '.context', dir, 'SUMMARY.md')
401
- if (fs.existsSync(summaryPath)) {
402
- try {
403
- const text = fs.readFileSync(summaryPath, 'utf8')
404
- summary = parseSummaryFrontmatter(text)
405
- }
406
- catch {
407
- // Fall through with summary=null
408
- }
409
- }
410
- }
411
-
412
- let baseMsg = composeMessage(head, active, summary, counts)
413
-
414
- // v4.5 P1b: run startup reconciler over .context/jobs/* and append a one-line
415
- // summary if anything was reconciled. Reconciler never throws — it swallows
416
- // I/O errors so a flaky filesystem can't block session start.
417
- let reconcileLine = null
418
- try {
419
- const report = reconcileStaleJobs(cwd)
420
- reconcileLine = summarizeReconciliation(report)
421
- }
422
- catch {
423
- reconcileLine = null
424
- }
425
- if (reconcileLine) {
426
- baseMsg = `${baseMsg}\n${reconcileLine}`
427
- if (baseMsg.length > 800) baseMsg = `${baseMsg.slice(0, 797)}...`
428
- }
429
- return baseMsg
430
- }
431
-
432
- // ---------------------------------------------------------------------------
433
- // Entry point — only runs when this file is invoked directly (not on import).
434
- // ---------------------------------------------------------------------------
435
-
436
- function emit(additionalContext) {
437
- const out = {
438
- hookSpecificOutput: {
439
- hookEventName: 'SessionStart',
440
- additionalContext,
441
- },
442
- }
443
- process.stdout.write(JSON.stringify(out))
444
- }
445
-
446
- function main() {
447
- let input = ''
448
- // Timeout guard mirrors ccg-context-monitor: never hang on a stuck pipe.
449
- const timer = setTimeout(() => process.exit(0), 10000)
450
-
451
- process.stdin.setEncoding('utf8')
452
- process.stdin.on('data', chunk => (input += chunk))
453
- process.stdin.on('end', () => {
454
- clearTimeout(timer)
455
- let cwd = process.cwd()
456
- try {
457
- if (input.trim()) {
458
- const data = JSON.parse(input)
459
- if (typeof data.cwd === 'string' && data.cwd) cwd = data.cwd
460
- }
461
- }
462
- catch {
463
- // Bad JSON — fall back to process.cwd(). We still want to inject context
464
- // for the most common case (running in the project root).
465
- }
466
-
467
- let message = null
468
- try {
469
- message = buildAdditionalContext(cwd)
470
- }
471
- catch {
472
- message = null
473
- }
474
-
475
- if (!message) {
476
- // Non-CCG project: emit nothing visible. Empty object keeps Claude Code
477
- // happy and signals "no injection" without erroring.
478
- process.stdout.write('{}')
479
- process.exit(0)
480
- }
481
-
482
- emit(message)
483
- process.exit(0)
484
- })
485
-
486
- process.stdin.on('error', () => process.exit(0))
487
- }
488
-
489
- // Detect "imported as a module" (Node test harness) vs. "executed as script".
490
- // require.main === module is true only when invoked via `node ccg-session-state.js`.
491
- if (require.main === module) {
492
- main()
493
- }
494
-
495
- // Test surface kept on a single object so the production hook surface stays
496
- // minimal. Consumed by sessionStateHook.test.ts.
497
- module.exports = {
498
- parseRoadmapHead,
499
- parsePhases,
500
- pickActivePhase,
501
- phaseDirName,
502
- parseSummaryFrontmatter,
503
- composeMessage,
504
- buildAdditionalContext,
505
- // v4.5 P1b additions:
506
- isAlivePid,
507
- atomicWriteFileSync,
508
- reconcileStaleJobs,
509
- summarizeReconciliation,
510
- }
1
+ #!/usr/bin/env node
2
+ // ccg-hook: session-state
3
+ // SessionStart Hook — auto-inject CCG project memory into a fresh session.
4
+ //
5
+ // Problem this solves (CCG v4.0 dogfood Q6 + GSD gsd-session-state.sh parity):
6
+ // After /clear or a brand-new session, the orchestrator has zero memory of
7
+ // the project's roadmap state. Users had to manually paste a "resume" file
8
+ // (see .ccg/SESSION-RESUME.md) to get going. This hook automates it: when a
9
+ // session starts in a CCG project (cwd has .ccg/roadmap.md), it injects a
10
+ // ≤200-token summary describing project name, active phase, and next action.
11
+ //
12
+ // Hook contract (Claude Code SessionStart event):
13
+ // stdin : JSON with at least { hookEventName, session_id, cwd? }
14
+ // cwd may be absent — we fall back to process.cwd().
15
+ // stdout : JSON
16
+ // { hookSpecificOutput: { hookEventName: 'SessionStart',
17
+ // additionalContext: '<string>' } }
18
+ // Empty / missing additionalContext means "no injection". For non-CCG
19
+ // projects we exit cleanly without writing anything (noop).
20
+ //
21
+ // Failure policy: never throw; never block a session start. Any parse error or
22
+ // missing file degrades to a smaller-but-still-useful summary, or to a noop.
23
+
24
+ 'use strict'
25
+
26
+ const fs = require('fs')
27
+ const path = require('path')
28
+ const crypto = require('crypto')
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Pure helpers (exported for unit tests via ccgSessionStateHookExports)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Extract roadmap.md head metadata: project name, started, last updated.
36
+ *
37
+ * Roadmap convention (see .ccg/roadmap.md): bold-tagged key-value lines such as
38
+ * **Project**: ccg-workflow v4.0
39
+ * **Started**: 2026-05-03
40
+ * **Last Updated**: 2026-05-04
41
+ * Lines may appear in any order within the first ~20 lines. Anything we cannot
42
+ * locate yields undefined — callers must tolerate that.
43
+ */
44
+ function parseRoadmapHead(text) {
45
+ const head = text.split(/\r?\n/).slice(0, 30).join('\n')
46
+ const grab = (label) => {
47
+ const re = new RegExp(`\\*\\*${label}\\*\\*\\s*[::]\\s*(.+)`, 'i')
48
+ const m = head.match(re)
49
+ return m ? m[1].trim() : undefined
50
+ }
51
+ return {
52
+ project: grab('Project'),
53
+ started: grab('Started'),
54
+ lastUpdated: grab('Last Updated'),
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Parse phase headers. Each phase is denoted by `## Phase N: Title (status)`,
60
+ * where `N` may include a dot (e.g. 1.5) and `status` is one of completed /
61
+ * in_progress / pending / blocked / skipped.
62
+ *
63
+ * Returns array preserving file order. The "active" phase used for context
64
+ * injection is the first one whose status is `in_progress`; if none, the
65
+ * first `pending` phase; if all completed, null.
66
+ */
67
+ function parsePhases(text) {
68
+ const re = /^##\s+Phase\s+([\d.]+)\s*:\s*(.+?)\s*(?:\[[^\]]+\])?\s*\(([^)]+)\)\s*$/gim
69
+ const phases = []
70
+ let match
71
+ while ((match = re.exec(text)) !== null) {
72
+ phases.push({
73
+ n: match[1],
74
+ title: match[2].trim(),
75
+ status: match[3].trim().toLowerCase(),
76
+ })
77
+ }
78
+ return phases
79
+ }
80
+
81
+ /**
82
+ * Pick the phase whose state is most relevant for resume context.
83
+ * 1. First in_progress phase (resume work mid-flight)
84
+ * 2. Else first pending phase (next-up work)
85
+ * 3. Else null (every phase completed)
86
+ */
87
+ function pickActivePhase(phases) {
88
+ return (
89
+ phases.find(p => p.status === 'in_progress')
90
+ || phases.find(p => p.status === 'pending')
91
+ || null
92
+ )
93
+ }
94
+
95
+ /**
96
+ * Map a roadmap phase entry to its `.context/<dir>/SUMMARY.md` directory name.
97
+ *
98
+ * Convention used by /ccg:autonomous + phase-runner: `phase-NN-<slug>` where NN
99
+ * is two-digit (zero-padded for integers). Phase 1.5 keeps its decimal. Slug is
100
+ * the title lowercased with non-alphanumerics collapsed to dashes.
101
+ *
102
+ * We do NOT guarantee this dir exists — caller must existsSync() before reading.
103
+ */
104
+ function phaseDirName(phase) {
105
+ const n = phase.n
106
+ const padded = /^\d+$/.test(n) ? n.padStart(2, '0') : n
107
+ const slug = phase.title
108
+ .toLowerCase()
109
+ .replace(/[^a-z0-9]+/g, '-')
110
+ .replace(/^-+|-+$/g, '')
111
+ return slug ? `phase-${padded}-${slug}` : `phase-${padded}`
112
+ }
113
+
114
+ /**
115
+ * Lift YAML frontmatter into a flat Record<string, string>. Only handles the
116
+ * minimal subset that .context/<phase>/SUMMARY.md uses — scalar key/value pairs
117
+ * and short inline lists. Anything fancier degrades to the raw string.
118
+ *
119
+ * We deliberately do NOT pull in src/utils/phase-context.ts here — this hook
120
+ * runs as a standalone Node script under ~/.claude/hooks/ with no transpile
121
+ * step, so it must be self-contained.
122
+ */
123
+ function parseSummaryFrontmatter(content) {
124
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
125
+ if (!m) return null
126
+ const out = {}
127
+ for (const raw of m[1].split(/\r?\n/)) {
128
+ const line = raw.trim()
129
+ if (!line || line.startsWith('#')) continue
130
+ const km = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/)
131
+ if (!km) continue
132
+ let value = km[2].trim()
133
+ // Strip surrounding quotes
134
+ if ((value.startsWith('"') && value.endsWith('"'))
135
+ || (value.startsWith('\'') && value.endsWith('\''))) {
136
+ value = value.slice(1, -1)
137
+ }
138
+ out[km[1]] = value
139
+ }
140
+ return out
141
+ }
142
+
143
+ /**
144
+ * Compose the actual additionalContext string (capped to keep main-thread
145
+ * context budget honored). Stays under ~200 tokens by hard-truncating at
146
+ * 800 chars after composition.
147
+ *
148
+ * Inputs:
149
+ * head — { project, started, lastUpdated } (any may be undefined)
150
+ * active — phase object or null
151
+ * summary — parsed SUMMARY.md frontmatter or null
152
+ * counts — { total, completed }
153
+ */
154
+ function composeMessage(head, active, summary, counts) {
155
+ const lines = []
156
+ lines.push('[CCG] Project memory restored from .ccg/roadmap.md.')
157
+
158
+ const projectLine = []
159
+ if (head.project) projectLine.push(`Project: ${head.project}`)
160
+ if (counts.total > 0) {
161
+ projectLine.push(`Phases: ${counts.completed}/${counts.total} completed`)
162
+ }
163
+ if (projectLine.length) lines.push(projectLine.join(' | '))
164
+
165
+ if (!active) {
166
+ if (counts.total > 0 && counts.completed === counts.total) {
167
+ lines.push('Status: All phases completed.')
168
+ }
169
+ else if (counts.total === 0) {
170
+ lines.push('Status: roadmap.md present but no phases parsed.')
171
+ }
172
+ }
173
+ else {
174
+ const tag = active.status === 'in_progress' ? 'Active' : 'Next'
175
+ lines.push(`${tag} phase: ${active.n} ${active.title} (${active.status})`)
176
+ if (summary) {
177
+ const provides = summary.provides
178
+ const nextAction = summary['next-action'] || summary.next_action || summary.nextAction
179
+ if (provides) lines.push(`Provides: ${provides}`)
180
+ if (nextAction) lines.push(`Next action: ${nextAction}`)
181
+ }
182
+ }
183
+
184
+ lines.push('Read .ccg/roadmap.md for full state. Continue from the active phase or ask the user where to start.')
185
+
186
+ let msg = lines.join('\n')
187
+ if (msg.length > 800) msg = `${msg.slice(0, 797)}...`
188
+ return msg
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // v1.0.2phase-runner CLI self-reference guard.
193
+ //
194
+ // Why this exists:
195
+ // The launcher (templates/scripts/ccg-phase-runner-launcher.mjs) spawns
196
+ // `claude -p --agent ccg/phase-runner`. That child process is itself a fresh
197
+ // Claude Code session, so SessionStart hooks fire inside it too. Without a
198
+ // guard the hook would:
199
+ // 1. Inject the orchestrator-style "Project memory restored / Active phase
200
+ // / Phases X/Y completed" context into a subagent that already has its
201
+ // own focused phase prompt — confusing the model into orchestrator
202
+ // behaviour (e.g. running `/ccg:status` and waiting on its own job).
203
+ // 2. Run the reconciler over `.context/jobs/*`, see its own state.json
204
+ // with `cli_pid` alive, and (harmlessly) no-op — but the additionalContext
205
+ // injection is the actual self-reference loop trigger.
206
+ //
207
+ // The launcher injects two env vars that uniquely tag a phase-runner CLI
208
+ // subprocess: `CCG_JOB_ID` + `CCG_PHASE_RUNNER_TIER`. Either alone is too
209
+ // weak (CCG_JOB_ID could be set elsewhere); requiring both keeps the
210
+ // detection narrow.
211
+ // ---------------------------------------------------------------------------
212
+
213
+ function isPhaseRunnerSubprocess(env) {
214
+ const e = env || process.env
215
+ return Boolean(e.CCG_JOB_ID) && Boolean(e.CCG_PHASE_RUNNER_TIER)
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // v4.5 P1b — startup reconciler (inlined CJS twin of src/utils/process-tree.ts).
220
+ //
221
+ // The hook MUST stay self-contained (see top-of-file comment). We duplicate
222
+ // the minimal logic rather than `require('../../src/utils/process-tree')` —
223
+ // the hook is shipped to ~/.claude/hooks/ where TS source is unavailable.
224
+ //
225
+ // Behaviour matrix (mirrors process-tree.ts reconcileStaleJobs):
226
+ // - .context/jobs/* missing → no-op (return empty)
227
+ // - state.status terminal (done/failed/canceled) no-op
228
+ // - cli_pid alive → no-op
229
+ // - cli_pid dead AND result.md present → adopt-result
230
+ // - cli_pid dead AND no result.md → mark-failed-stale
231
+ // - status=running but no cli_pid (legacy) → mark-failed-no-result
232
+ // ---------------------------------------------------------------------------
233
+
234
+ function isAlivePid(pid) {
235
+ if (!Number.isInteger(pid) || pid <= 0) return false
236
+ try {
237
+ process.kill(pid, 0)
238
+ return true
239
+ }
240
+ catch (err) {
241
+ if (err && err.code === 'EPERM') return true
242
+ return false
243
+ }
244
+ }
245
+
246
+ function atomicWriteFileSync(target, content) {
247
+ const rand = crypto.randomBytes(6).toString('hex')
248
+ const tmp = `${target}.tmp.${rand}`
249
+ try {
250
+ fs.writeFileSync(tmp, content, 'utf-8')
251
+ fs.renameSync(tmp, target)
252
+ }
253
+ catch (err) {
254
+ try { fs.unlinkSync(tmp) }
255
+ catch { /* nothing to clean up */ }
256
+ throw err
257
+ }
258
+ }
259
+
260
+ function reconcileStaleJobs(cwd, options) {
261
+ const opts = options || {}
262
+ const isAlive = opts.isAliveFn || isAlivePid
263
+ const now = typeof opts.nowMs === 'number' ? opts.nowMs : Date.now()
264
+ const reuseAgeMs = typeof opts.pidReuseAgeMs === 'number'
265
+ ? opts.pidReuseAgeMs
266
+ : 24 * 60 * 60 * 1000
267
+
268
+ const root = path.join(cwd, '.context', 'jobs')
269
+ const report = { scanned: 0, entries: [] }
270
+ if (!fs.existsSync(root)) return report
271
+
272
+ let dirs
273
+ try { dirs = fs.readdirSync(root) }
274
+ catch { return report }
275
+
276
+ for (const id of dirs) {
277
+ const sub = path.join(root, id)
278
+ let isDir = false
279
+ try { isDir = fs.statSync(sub).isDirectory() }
280
+ catch { continue }
281
+ if (!isDir) continue
282
+
283
+ const statePath = path.join(sub, 'state.json')
284
+ if (!fs.existsSync(statePath)) continue
285
+
286
+ let state
287
+ try {
288
+ state = JSON.parse(fs.readFileSync(statePath, 'utf-8'))
289
+ }
290
+ catch {
291
+ // Corrupt — skip silently; getJob() in src will surface to the user.
292
+ continue
293
+ }
294
+ report.scanned += 1
295
+
296
+ if (
297
+ state.status === 'done'
298
+ || state.status === 'failed'
299
+ || state.status === 'canceled'
300
+ ) {
301
+ report.entries.push({ jobId: id, action: 'no-op', reason: 'terminal status' })
302
+ continue
303
+ }
304
+
305
+ if (typeof state.cli_pid !== 'number') {
306
+ const updated = Object.assign({}, state, {
307
+ status: 'failed',
308
+ summary: 'reconciler: legacy job without cli_pid; cannot verify liveness',
309
+ last_update: new Date().toISOString(),
310
+ })
311
+ try { atomicWriteFileSync(statePath, JSON.stringify(updated, null, 2)) }
312
+ catch { /* swallow never block session start */ }
313
+ report.entries.push({
314
+ jobId: id,
315
+ action: 'mark-failed-no-result',
316
+ reason: 'no cli_pid recorded',
317
+ })
318
+ continue
319
+ }
320
+
321
+ // v1.0.2 defense-in-depth: if the reconciler is somehow running inside the
322
+ // very phase-runner CLI subprocess that owns this state.json (the main()
323
+ // env-guard should already have short-circuited before reaching here),
324
+ // refuse to act on our own row. Without this, a future code path that
325
+ // tightens "cli_pid alive" handling could mistakenly mark our own job
326
+ // failed/adopted and burn the launcher's atomic state contract.
327
+ if (state.cli_pid === process.pid) {
328
+ report.entries.push({
329
+ jobId: id,
330
+ action: 'no-op',
331
+ reason: 'self-reference: state.cli_pid === process.pid',
332
+ })
333
+ continue
334
+ }
335
+
336
+ const alive = isAlive(state.cli_pid)
337
+ let pidProbablyReused = false
338
+ if (alive && state.started_at) {
339
+ const startedMs = Date.parse(state.started_at)
340
+ if (Number.isFinite(startedMs) && (now - startedMs) > reuseAgeMs) {
341
+ pidProbablyReused = true
342
+ }
343
+ }
344
+
345
+ if (alive && !pidProbablyReused) {
346
+ report.entries.push({ jobId: id, action: 'no-op', reason: 'cli_pid alive' })
347
+ continue
348
+ }
349
+
350
+ const resultPath = path.join(sub, 'result.md')
351
+ if (fs.existsSync(resultPath)) {
352
+ const updated = Object.assign({}, state, {
353
+ status: 'done',
354
+ summary: 'reconciler: cli_pid not alive; adopted result.md after orphan recovery',
355
+ last_update: new Date().toISOString(),
356
+ })
357
+ try { atomicWriteFileSync(statePath, JSON.stringify(updated, null, 2)) }
358
+ catch { /* swallow */ }
359
+ report.entries.push({
360
+ jobId: id,
361
+ action: 'adopt-result',
362
+ reason: pidProbablyReused
363
+ ? 'pid reuse suspected; result.md present'
364
+ : 'cli_pid dead; result.md present',
365
+ })
366
+ continue
367
+ }
368
+
369
+ const updated = Object.assign({}, state, {
370
+ status: 'failed',
371
+ summary: pidProbablyReused
372
+ ? 'reconciler: cli_pid suspected reused; no result.md found'
373
+ : 'reconciler: cli_pid dead; no result.md found',
374
+ last_update: new Date().toISOString(),
375
+ })
376
+ try { atomicWriteFileSync(statePath, JSON.stringify(updated, null, 2)) }
377
+ catch { /* swallow */ }
378
+ report.entries.push({
379
+ jobId: id,
380
+ action: 'mark-failed-stale',
381
+ reason: pidProbablyReused
382
+ ? 'pid reuse + no result'
383
+ : 'cli_pid dead + no result',
384
+ })
385
+ }
386
+
387
+ return report
388
+ }
389
+
390
+ /**
391
+ * Compose a one-line reconciler summary for injection into additionalContext.
392
+ * Returns null when nothing of interest happened (so the hook stays quiet for
393
+ * fresh / clean sessions).
394
+ */
395
+ function summarizeReconciliation(report) {
396
+ if (!report || report.scanned === 0) return null
397
+ const counts = { 'mark-failed-stale': 0, 'mark-failed-no-result': 0, 'adopt-result': 0 }
398
+ for (const e of report.entries) {
399
+ if (counts[e.action] !== undefined) counts[e.action] += 1
400
+ }
401
+ const interesting = counts['mark-failed-stale']
402
+ + counts['mark-failed-no-result']
403
+ + counts['adopt-result']
404
+ if (interesting === 0) return null
405
+ const parts = []
406
+ if (counts['mark-failed-stale'])
407
+ parts.push(`${counts['mark-failed-stale']} stale-failed`)
408
+ if (counts['mark-failed-no-result'])
409
+ parts.push(`${counts['mark-failed-no-result']} no-pid-failed`)
410
+ if (counts['adopt-result'])
411
+ parts.push(`${counts['adopt-result']} adopted-result`)
412
+ return `Reconciled ${interesting}/${report.scanned} jobs: ${parts.join(', ')}.`
413
+ }
414
+
415
+ /**
416
+ * Build the additionalContext string for a given workdir. Returns null if the
417
+ * cwd is not a CCG project (no .ccg/roadmap.md). Never throws.
418
+ */
419
+ function buildAdditionalContext(cwd) {
420
+ const roadmapPath = path.join(cwd, '.ccg', 'roadmap.md')
421
+ if (!fs.existsSync(roadmapPath)) return null
422
+
423
+ let roadmapText
424
+ try {
425
+ roadmapText = fs.readFileSync(roadmapPath, 'utf8')
426
+ }
427
+ catch {
428
+ return null
429
+ }
430
+
431
+ const head = parseRoadmapHead(roadmapText)
432
+ const phases = parsePhases(roadmapText)
433
+ const active = pickActivePhase(phases)
434
+ const counts = {
435
+ total: phases.length,
436
+ completed: phases.filter(p => p.status === 'completed').length,
437
+ }
438
+
439
+ let summary = null
440
+ if (active) {
441
+ const dir = phaseDirName(active)
442
+ const summaryPath = path.join(cwd, '.context', dir, 'SUMMARY.md')
443
+ if (fs.existsSync(summaryPath)) {
444
+ try {
445
+ const text = fs.readFileSync(summaryPath, 'utf8')
446
+ summary = parseSummaryFrontmatter(text)
447
+ }
448
+ catch {
449
+ // Fall through with summary=null
450
+ }
451
+ }
452
+ }
453
+
454
+ let baseMsg = composeMessage(head, active, summary, counts)
455
+
456
+ // v4.5 P1b: run startup reconciler over .context/jobs/* and append a one-line
457
+ // summary if anything was reconciled. Reconciler never throws — it swallows
458
+ // I/O errors so a flaky filesystem can't block session start.
459
+ let reconcileLine = null
460
+ try {
461
+ const report = reconcileStaleJobs(cwd)
462
+ reconcileLine = summarizeReconciliation(report)
463
+ }
464
+ catch {
465
+ reconcileLine = null
466
+ }
467
+ if (reconcileLine) {
468
+ baseMsg = `${baseMsg}\n${reconcileLine}`
469
+ if (baseMsg.length > 800) baseMsg = `${baseMsg.slice(0, 797)}...`
470
+ }
471
+ return baseMsg
472
+ }
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // Entry point — only runs when this file is invoked directly (not on import).
476
+ // ---------------------------------------------------------------------------
477
+
478
+ function emit(additionalContext) {
479
+ const out = {
480
+ hookSpecificOutput: {
481
+ hookEventName: 'SessionStart',
482
+ additionalContext,
483
+ },
484
+ }
485
+ process.stdout.write(JSON.stringify(out))
486
+ }
487
+
488
+ function main() {
489
+ // v1.0.2 phase-runner CLI self-reference guard. When this hook runs inside
490
+ // a launcher-spawned `claude -p --agent ccg/phase-runner` subprocess, emit
491
+ // an empty hookSpecificOutput and exit before reading roadmap/jobs. The
492
+ // subagent already has its own scoped prompt; injecting the orchestrator's
493
+ // "Project memory / Active phase / All phases completed" context confuses
494
+ // the model into running /ccg:status and waiting on its own running job
495
+ // (cli_pid it sees itself alive 2-min idle self-terminate, zero output).
496
+ if (isPhaseRunnerSubprocess(process.env)) {
497
+ process.stdout.write('{}')
498
+ process.exit(0)
499
+ }
500
+
501
+ let input = ''
502
+ // Timeout guard mirrors ccg-context-monitor: never hang on a stuck pipe.
503
+ const timer = setTimeout(() => process.exit(0), 10000)
504
+
505
+ process.stdin.setEncoding('utf8')
506
+ process.stdin.on('data', chunk => (input += chunk))
507
+ process.stdin.on('end', () => {
508
+ clearTimeout(timer)
509
+ let cwd = process.cwd()
510
+ try {
511
+ if (input.trim()) {
512
+ const data = JSON.parse(input)
513
+ if (typeof data.cwd === 'string' && data.cwd) cwd = data.cwd
514
+ }
515
+ }
516
+ catch {
517
+ // Bad JSON — fall back to process.cwd(). We still want to inject context
518
+ // for the most common case (running in the project root).
519
+ }
520
+
521
+ let message = null
522
+ try {
523
+ message = buildAdditionalContext(cwd)
524
+ }
525
+ catch {
526
+ message = null
527
+ }
528
+
529
+ if (!message) {
530
+ // Non-CCG project: emit nothing visible. Empty object keeps Claude Code
531
+ // happy and signals "no injection" without erroring.
532
+ process.stdout.write('{}')
533
+ process.exit(0)
534
+ }
535
+
536
+ emit(message)
537
+ process.exit(0)
538
+ })
539
+
540
+ process.stdin.on('error', () => process.exit(0))
541
+ }
542
+
543
+ // Detect "imported as a module" (Node test harness) vs. "executed as script".
544
+ // require.main === module is true only when invoked via `node ccg-session-state.js`.
545
+ if (require.main === module) {
546
+ main()
547
+ }
548
+
549
+ // Test surface — kept on a single object so the production hook surface stays
550
+ // minimal. Consumed by sessionStateHook.test.ts.
551
+ module.exports = {
552
+ parseRoadmapHead,
553
+ parsePhases,
554
+ pickActivePhase,
555
+ phaseDirName,
556
+ parseSummaryFrontmatter,
557
+ composeMessage,
558
+ buildAdditionalContext,
559
+ // v4.5 P1b additions:
560
+ isAlivePid,
561
+ atomicWriteFileSync,
562
+ reconcileStaleJobs,
563
+ summarizeReconciliation,
564
+ // v1.0.2 additions:
565
+ isPhaseRunnerSubprocess,
566
+ }