ccgx-workflow 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.SJPbUy5_.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 +510 -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,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
- // 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
+ // 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
+ }