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.
- package/README.md +37 -5
- package/README.zh-CN.md +35 -5
- package/dist/cli.mjs +1 -1
- package/dist/index.mjs +2 -2
- package/dist/shared/{ccgx-workflow.WgUzkiC3.mjs → ccgx-workflow.Bq9vAaEw.mjs} +17 -110
- package/package.json +2 -1
- package/templates/commands/agents/phase-runner.md +321 -321
- package/templates/commands/autonomous.md +792 -792
- package/templates/commands/cancel.md +132 -132
- package/templates/commands/debug.md +226 -226
- package/templates/commands/status.md +206 -206
- package/templates/commands/team.md +484 -0
- package/templates/hooks/ccg-session-state.cjs +566 -510
- package/templates/scripts/ccg-phase-runner-launcher.mjs +467 -467
- package/templates/scripts/invoke-model.mjs +64 -0
- package/templates/skills/domains/ai/SKILL.md +35 -35
- package/templates/skills/domains/ai/agent-dev.md +242 -242
- package/templates/skills/domains/ai/llm-security.md +288 -288
- package/templates/skills/domains/ai/rag-system.md +542 -542
- package/templates/skills/domains/architecture/SKILL.md +43 -43
- package/templates/skills/domains/architecture/api-design.md +225 -225
- package/templates/skills/domains/architecture/cloud-native.md +285 -285
- package/templates/skills/domains/architecture/security-arch.md +297 -297
- package/templates/skills/domains/data-engineering/SKILL.md +208 -208
- package/templates/skills/domains/development/SKILL.md +47 -47
- package/templates/skills/domains/development/cpp.md +246 -246
- package/templates/skills/domains/development/go.md +323 -323
- package/templates/skills/domains/development/java.md +277 -277
- package/templates/skills/domains/development/python.md +288 -288
- package/templates/skills/domains/development/rust.md +313 -313
- package/templates/skills/domains/development/shell.md +313 -313
- package/templates/skills/domains/development/typescript.md +277 -277
- package/templates/skills/domains/devops/SKILL.md +40 -40
- package/templates/skills/domains/devops/database.md +217 -217
- package/templates/skills/domains/devops/devsecops.md +198 -198
- package/templates/skills/domains/devops/git-workflow.md +181 -181
- package/templates/skills/domains/devops/testing.md +283 -283
- package/templates/skills/domains/frontend-design/SKILL.md +244 -244
- package/templates/skills/domains/frontend-design/claymorphism/SKILL.md +121 -121
- package/templates/skills/domains/frontend-design/component-patterns.md +202 -202
- package/templates/skills/domains/frontend-design/glassmorphism/SKILL.md +142 -142
- package/templates/skills/domains/frontend-design/liquid-glass/SKILL.md +139 -139
- package/templates/skills/domains/frontend-design/neubrutalism/SKILL.md +145 -145
- package/templates/skills/domains/frontend-design/state-management.md +680 -680
- package/templates/skills/domains/frontend-design/ui-aesthetics.md +110 -110
- package/templates/skills/domains/frontend-design/ux-principles.md +156 -156
- package/templates/skills/domains/infrastructure/SKILL.md +201 -201
- package/templates/skills/domains/mobile/SKILL.md +225 -225
- package/templates/skills/domains/orchestration/SKILL.md +30 -30
- package/templates/skills/domains/orchestration/multi-agent.md +228 -228
- package/templates/skills/domains/security/SKILL.md +73 -73
- package/templates/skills/domains/security/blue-team.md +436 -436
- package/templates/skills/domains/security/code-audit.md +265 -265
- package/templates/skills/domains/security/pentest.md +226 -226
- package/templates/skills/domains/security/red-team.md +374 -374
- package/templates/skills/domains/security/threat-intel.md +372 -372
- package/templates/skills/domains/security/vuln-research.md +369 -369
- package/templates/skills/tools/lib/shared.js +98 -98
- package/templates/skills/tools/override-refusal/SKILL.md +53 -53
- 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
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
: '
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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.2 — phase-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
|
+
}
|