ccgx-workflow 2.4.2 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -277
- package/README.zh-CN.md +134 -272
- package/dist/chunks/version-build.mjs +1 -1
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +709 -16
- package/dist/index.d.ts +709 -16
- package/dist/index.mjs +1061 -30
- package/dist/shared/{ccgx-workflow.eLNeCiD8.mjs → ccgx-workflow.CdHnJLak.mjs} +106 -22
- package/package.json +1 -1
- package/templates/commands/agents/code-fixer.md +6 -6
- package/templates/commands/agents/phase-runner.md +46 -14
- package/templates/commands/agents/plan-checker.md +10 -0
- package/templates/commands/analyze.md +66 -25
- package/templates/commands/autonomous.md +428 -225
- package/templates/commands/cancel.md +9 -0
- package/templates/commands/codex-exec.md +12 -11
- package/templates/commands/context.md +14 -0
- package/templates/commands/debate.md +10 -6
- package/templates/commands/execute.md +76 -28
- package/templates/commands/optimize.md +53 -25
- package/templates/commands/plan.md +78 -28
- package/templates/commands/review.md +26 -19
- package/templates/commands/spec-impl.md +68 -127
- package/templates/commands/spec-plan.md +61 -82
- package/templates/commands/spec-research.md +35 -92
- package/templates/commands/spec-review.md +34 -119
- package/templates/commands/status.md +1 -0
- package/templates/commands/team-exec.md +45 -13
- package/templates/commands/team.md +64 -167
- package/templates/commands/test.md +56 -34
- package/templates/commands/verify-work.md +36 -13
- package/templates/commands/verify.md +35 -0
- package/templates/commands/workflow.md +22 -37
- package/templates/hooks/ccg-loop-detector.cjs +39 -8
- package/templates/hooks/ccg-statusline.js +142 -2
- package/templates/hooks/ccg-stop-gate.cjs +241 -16
- package/templates/hooks/ccg-subagent-context.cjs +505 -0
- package/templates/scripts/ccg-state-lock.cjs +510 -0
- package/templates/scripts/ccg-team-schedule.cjs +328 -0
- package/templates/scripts/ccgx-call-plugin.mjs +494 -141
- package/templates/scripts/invoke-model.mjs +28 -1
- package/templates/scripts/task-store.cjs +614 -0
- package/templates/skills/tools/verify-change/SKILL.md +7 -0
- package/templates/skills/tools/verify-module/SKILL.md +7 -0
- package/templates/skills/tools/verify-quality/SKILL.md +7 -0
- package/templates/skills/tools/verify-security/SKILL.md +8 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ccg-hook: subagent-context
|
|
3
|
+
// PreToolUse Hook (matcher: Task|Agent) — inject the active phase spec into a
|
|
4
|
+
// subagent's prompt via hookSpecificOutput.updatedInput.
|
|
5
|
+
//
|
|
6
|
+
// Problem this solves (fork adaptation of upstream subagent-context.js):
|
|
7
|
+
// PreToolUse `additionalContext` only reaches the CALLING (lead) session —
|
|
8
|
+
// the subagent does not exist yet when the hook fires, so it never sees it.
|
|
9
|
+
// `updatedInput` replaces the tool input itself, which means the injected
|
|
10
|
+
// spec lands inside the subagent's prompt. We spread the original tool_input
|
|
11
|
+
// so subagent_type / description / model / team_name / name survive intact.
|
|
12
|
+
//
|
|
13
|
+
// Fork-specific hardening over upstream:
|
|
14
|
+
// - injection source is .context/ (CONTEXT.md / SUMMARY.md frontmatter) +
|
|
15
|
+
// .ccg/roadmap.md + the P1-4 task container .context/tasks/ — not the
|
|
16
|
+
// upstream .ccg/tasks layout
|
|
17
|
+
// - <ccg-phase-context> marker makes the injection idempotent (an
|
|
18
|
+
// orchestrator re-forwarding an already-injected prompt does not stack)
|
|
19
|
+
// - hard total budget MAX_INJECTION_CHARS (closing tag is never truncated)
|
|
20
|
+
// - non-CCG projects short-circuit with ZERO stdout (no output at all)
|
|
21
|
+
//
|
|
22
|
+
// Hook contract (Claude Code PreToolUse event):
|
|
23
|
+
// stdin : JSON { session_id, cwd, hook_event_name, tool_name, tool_input }
|
|
24
|
+
// stdout : { hookSpecificOutput: { hookEventName: 'PreToolUse',
|
|
25
|
+
// updatedInput: { ...tool_input, prompt } } }
|
|
26
|
+
// Deliberately NO permissionDecision — updatedInput works on its
|
|
27
|
+
// own and we must not change permission-approval semantics.
|
|
28
|
+
//
|
|
29
|
+
// Failure policy: never throw, never block a spawn. Any parse/read error
|
|
30
|
+
// degrades to a smaller injection or to a silent no-op (exit 0, no stdout).
|
|
31
|
+
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const path = require('path');
|
|
36
|
+
const os = require('os');
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Constants (all exported for tests)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const INJECTION_MARKER = '<ccg-phase-context>';
|
|
43
|
+
const INJECTION_END = '</ccg-phase-context>';
|
|
44
|
+
|
|
45
|
+
/** Hard total budget for the composed injection, tags + header included. */
|
|
46
|
+
const MAX_INJECTION_CHARS = 2048;
|
|
47
|
+
|
|
48
|
+
/** Per-section cap; over-long frontmatter is truncated with a marker. */
|
|
49
|
+
const MAX_SECTION_CHARS = 1400;
|
|
50
|
+
|
|
51
|
+
/** Claude Code 2.x historical name `Task`, current name `Agent` — both. */
|
|
52
|
+
const AGENT_TOOL_NAMES = new Set(['Task', 'Agent']);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Agents exempt from injection (substring match):
|
|
56
|
+
* - get-current-datetime: trivial, not worth a 2KB prompt tax
|
|
57
|
+
* - phase-runner: its prompt is baked by /ccg:autonomous and already
|
|
58
|
+
* carries the full phase context (injection is pure redundancy); P1-5
|
|
59
|
+
* additionally requires the exemption so `--resume` workflow runs keep
|
|
60
|
+
* deterministic `agent()` cache keys (no resumeFromRunId drift).
|
|
61
|
+
*/
|
|
62
|
+
const SUBAGENT_DENYLIST = ['get-current-datetime', 'phase-runner'];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read-side terminal synonyms — must mirror task-store.cjs
|
|
66
|
+
* TERMINAL_STATUS_SYNONYMS (the P1-4 write side is canonical; readers are
|
|
67
|
+
* tolerant because the status field is model-written free text in practice).
|
|
68
|
+
*/
|
|
69
|
+
const TERMINAL_TASK_STATUSES = new Set([
|
|
70
|
+
'completed', 'complete', 'done', 'finished', 'closed',
|
|
71
|
+
'archived', 'cancelled', 'canceled', 'abandoned', 'failed',
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Size guards on the JSON files this hook reads. The STATE.json projection is
|
|
76
|
+
* a handful of fields (mirrors ccg-statusline.js STATE_MAX_BYTES); a task.json
|
|
77
|
+
* is a record plus an artifacts list. Anything bigger is corrupt/abusive and
|
|
78
|
+
* is treated like unreadable JSON (null / skip).
|
|
79
|
+
*/
|
|
80
|
+
const MAX_STATE_BYTES = 16384;
|
|
81
|
+
const MAX_TASK_BYTES = 65536;
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// CCG project detection
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Path-normalized directory equality. Windows paths are case-insensitive
|
|
89
|
+
* (C:\Users\X vs c:\users\x), so the $HOME stop guard must not compare raw
|
|
90
|
+
* strings — aligned with ccg-statusline.js.
|
|
91
|
+
*/
|
|
92
|
+
function isSameDir(a, b) {
|
|
93
|
+
if (!a || !b) return false;
|
|
94
|
+
try {
|
|
95
|
+
const na = path.resolve(a);
|
|
96
|
+
const nb = path.resolve(b);
|
|
97
|
+
if (process.platform === 'win32') return na.toLowerCase() === nb.toLowerCase();
|
|
98
|
+
return na === nb;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Walk up from startDir (max 10 levels, stopping at $HOME / fs root) looking
|
|
106
|
+
* for a CCG project root: a directory containing .ccg/roadmap.md or a
|
|
107
|
+
* .context/ dir. Returns the directory path or null. Non-CCG projects
|
|
108
|
+
* short-circuit here, so the hook costs ~nothing outside CCG repos.
|
|
109
|
+
*/
|
|
110
|
+
function findCcgRoot(startDir) {
|
|
111
|
+
let dir;
|
|
112
|
+
try {
|
|
113
|
+
dir = path.resolve(startDir || '.');
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
let home = '';
|
|
118
|
+
try {
|
|
119
|
+
home = os.homedir();
|
|
120
|
+
} catch {
|
|
121
|
+
home = '';
|
|
122
|
+
}
|
|
123
|
+
for (let i = 0; i < 10; i++) {
|
|
124
|
+
try {
|
|
125
|
+
if (
|
|
126
|
+
fs.existsSync(path.join(dir, '.ccg', 'roadmap.md'))
|
|
127
|
+
|| fs.existsSync(path.join(dir, '.context'))
|
|
128
|
+
) {
|
|
129
|
+
return dir;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
// $HOME stop guard (checked AFTER the candidate test, so a CCG root at
|
|
135
|
+
// $HOME itself still resolves) — aligned with ccg-statusline.js.
|
|
136
|
+
if (home && isSameDir(dir, home)) return null;
|
|
137
|
+
const parent = path.dirname(dir);
|
|
138
|
+
if (parent === dir) return null;
|
|
139
|
+
dir = parent;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Roadmap / phase resolution (reuses ccg-session-state.cjs parsers — sibling
|
|
146
|
+
// require; its require.main guard ensures importing never runs its main()).
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve the active phase from <root>/.ccg/roadmap.md. Returns
|
|
151
|
+
* { project?, n, title, status, dirName, total, completed } or null when the
|
|
152
|
+
* roadmap is missing, unparseable, or has no active (in_progress/pending)
|
|
153
|
+
* phase. Never throws — a stale sibling hook without exports degrades to null.
|
|
154
|
+
*/
|
|
155
|
+
function getActivePhaseInfo(root) {
|
|
156
|
+
let ss;
|
|
157
|
+
try {
|
|
158
|
+
ss = require(path.join(__dirname, 'ccg-session-state.cjs'));
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
let text;
|
|
163
|
+
try {
|
|
164
|
+
text = fs.readFileSync(path.join(root, '.ccg', 'roadmap.md'), 'utf-8');
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const head = ss.parseRoadmapHead(text);
|
|
170
|
+
const phases = ss.parsePhases(text);
|
|
171
|
+
const active = ss.pickActivePhase(phases);
|
|
172
|
+
if (!active) return null;
|
|
173
|
+
return {
|
|
174
|
+
project: head && head.project ? head.project : undefined,
|
|
175
|
+
n: active.n,
|
|
176
|
+
title: active.title,
|
|
177
|
+
status: active.status,
|
|
178
|
+
dirName: ss.phaseDirName(active),
|
|
179
|
+
total: phases.length,
|
|
180
|
+
completed: phases.filter(p => p.status === 'completed').length,
|
|
181
|
+
};
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Return the raw frontmatter block (between the leading `---` fences) of a
|
|
189
|
+
* file, trimmed, or null when the file is missing or has no frontmatter.
|
|
190
|
+
* We inject the RAW text — the subagent parses it itself; the hook must not
|
|
191
|
+
* re-serialize (it cannot import src/utils/phase-context.ts).
|
|
192
|
+
*/
|
|
193
|
+
function readFrontmatterBlock(filePath) {
|
|
194
|
+
let content;
|
|
195
|
+
try {
|
|
196
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
201
|
+
if (!m) return null;
|
|
202
|
+
const block = m[1].trim();
|
|
203
|
+
return block || null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Active task (P1-4 seam — read-only consumer of the .context/tasks container)
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
function isTerminalTaskStatus(status) {
|
|
211
|
+
if (typeof status !== 'string') return false;
|
|
212
|
+
return TERMINAL_TASK_STATUSES.has(status.trim().toLowerCase());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Hard cap on the number of `.context/tasks/` entries the fallback scan may
|
|
217
|
+
* inspect (hook performance budget — every entry costs a readFileSync +
|
|
218
|
+
* JSON.parse). Oversized containers are sorted then truncated so the kept
|
|
219
|
+
* window is deterministic across platforms.
|
|
220
|
+
*/
|
|
221
|
+
const MAX_TASK_DIRS = 24;
|
|
222
|
+
|
|
223
|
+
/** Shape a raw task.json record into the injection contract. */
|
|
224
|
+
function shapeTask(dirId, task) {
|
|
225
|
+
const id = typeof task.id === 'string' && task.id ? task.id : dirId;
|
|
226
|
+
return {
|
|
227
|
+
id,
|
|
228
|
+
title: typeof task.title === 'string' && task.title ? task.title : id,
|
|
229
|
+
status: String(task.status || 'unknown'),
|
|
230
|
+
phase: typeof task.active_phase === 'string' && task.active_phase
|
|
231
|
+
? task.active_phase
|
|
232
|
+
: undefined,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Read + parse a JSON file with a statSync size guard. Returns the parsed
|
|
238
|
+
* object, or null on any failure (missing, not a file, oversized, corrupt
|
|
239
|
+
* JSON, non-object). Never throws.
|
|
240
|
+
*/
|
|
241
|
+
function readJsonBounded(filePath, maxBytes) {
|
|
242
|
+
try {
|
|
243
|
+
const st = fs.statSync(filePath);
|
|
244
|
+
if (!st.isFile() || st.size > maxBytes) return null;
|
|
245
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
246
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
247
|
+
return parsed;
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* O(1) fast path: `.context/STATE.json` is the task-store derived projection
|
|
255
|
+
* (P1-4); its `active_task_id` points at the active task without scanning the
|
|
256
|
+
* container. Returns the shaped task, or null when STATE.json is missing /
|
|
257
|
+
* invalid / oversized / from a foreign schema version, the pointed task.json
|
|
258
|
+
* is unreadable, or the pointed task is terminal (stale projection) — callers
|
|
259
|
+
* then fall back to the bounded scan.
|
|
260
|
+
*/
|
|
261
|
+
function readActiveTaskFromState(root) {
|
|
262
|
+
const state = readJsonBounded(path.join(root, '.context', 'STATE.json'), MAX_STATE_BYTES);
|
|
263
|
+
if (!state) return null;
|
|
264
|
+
// Frozen projection schema v1 — an unknown newer schema degrades cleanly to
|
|
265
|
+
// the fallback scan instead of trusting fields whose semantics may differ.
|
|
266
|
+
if (state.schema_version !== undefined && state.schema_version !== 1) return null;
|
|
267
|
+
const id = state.active_task_id;
|
|
268
|
+
if (typeof id !== 'string' || !id) return null;
|
|
269
|
+
const task = readJsonBounded(path.join(root, '.context', 'tasks', id, 'task.json'), MAX_TASK_BYTES);
|
|
270
|
+
if (!task) return null;
|
|
271
|
+
if (isTerminalTaskStatus(task.status)) return null;
|
|
272
|
+
return shapeTask(id, task);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Read the active task from <root>/.context/tasks/.
|
|
277
|
+
* Two tiers (W2 — hook performance budget):
|
|
278
|
+
* 1. Fast path: STATE.json `active_task_id` (O(1), see above).
|
|
279
|
+
* 2. Fallback scan — a read-only mirror of task-store.cjs getActiveTask():
|
|
280
|
+
* skip the archive/ subtree, tolerate corrupt task.json, newest
|
|
281
|
+
* non-terminal by created_at wins; capped at MAX_TASK_DIRS entries.
|
|
282
|
+
* Returns { id, title, status, phase? } or null. Zero cost when the container
|
|
283
|
+
* does not exist yet (single failed readdir).
|
|
284
|
+
*/
|
|
285
|
+
function readActiveTask(root) {
|
|
286
|
+
const fast = readActiveTaskFromState(root);
|
|
287
|
+
if (fast) return fast;
|
|
288
|
+
|
|
289
|
+
const tasksDir = path.join(root, '.context', 'tasks');
|
|
290
|
+
let entries;
|
|
291
|
+
try {
|
|
292
|
+
entries = fs.readdirSync(tasksDir);
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
if (entries.length > MAX_TASK_DIRS) {
|
|
297
|
+
entries = entries.slice().sort().slice(0, MAX_TASK_DIRS);
|
|
298
|
+
}
|
|
299
|
+
const tasks = [];
|
|
300
|
+
for (const id of entries) {
|
|
301
|
+
if (id === 'archive') continue;
|
|
302
|
+
// missing / corrupt / oversized task.json — skip, never throw
|
|
303
|
+
const task = readJsonBounded(path.join(tasksDir, id, 'task.json'), MAX_TASK_BYTES);
|
|
304
|
+
if (!task) continue;
|
|
305
|
+
if (typeof task.id !== 'string' || !task.id) task.id = id;
|
|
306
|
+
if (typeof task.created_at !== 'string') task.created_at = '';
|
|
307
|
+
tasks.push(task);
|
|
308
|
+
}
|
|
309
|
+
tasks.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
310
|
+
for (const task of tasks) {
|
|
311
|
+
if (isTerminalTaskStatus(task.status)) continue;
|
|
312
|
+
return shapeTask(task.id, task);
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// Injection composition
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
function truncateSection(text) {
|
|
322
|
+
if (text.length <= MAX_SECTION_CHARS) return text;
|
|
323
|
+
return `${text.slice(0, MAX_SECTION_CHARS)}\n...(truncated)`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Compose the injection block, greedily packing sections by priority within
|
|
328
|
+
* the MAX_INJECTION_CHARS hard budget (a section that does not fit is skipped
|
|
329
|
+
* and later, shorter sections are still tried):
|
|
330
|
+
* (a) roadmap position line
|
|
331
|
+
* (b) CONTEXT.md frontmatter (frozen decisions)
|
|
332
|
+
* (c) active task line (P1-4 container)
|
|
333
|
+
* (d) SUMMARY.md frontmatter (progress — rerun/resume scenarios)
|
|
334
|
+
* Returns null when no section is available. Hard invariant: the returned
|
|
335
|
+
* string length is <= MAX_INJECTION_CHARS and ends with INJECTION_END.
|
|
336
|
+
*/
|
|
337
|
+
function composeInjection(root) {
|
|
338
|
+
const candidates = [];
|
|
339
|
+
const info = getActivePhaseInfo(root);
|
|
340
|
+
|
|
341
|
+
if (info) {
|
|
342
|
+
const project = info.project || path.basename(root);
|
|
343
|
+
candidates.push(
|
|
344
|
+
`Roadmap: ${project} | phases ${info.completed}/${info.total} completed`
|
|
345
|
+
+ ` | active: Phase ${info.n} ${info.title} (${info.status})`,
|
|
346
|
+
);
|
|
347
|
+
const contextFm = readFrontmatterBlock(
|
|
348
|
+
path.join(root, '.context', info.dirName, 'CONTEXT.md'),
|
|
349
|
+
);
|
|
350
|
+
if (contextFm) {
|
|
351
|
+
candidates.push(
|
|
352
|
+
`--- .context/${info.dirName}/CONTEXT.md (frozen decisions) ---\n${truncateSection(contextFm)}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const task = readActiveTask(root);
|
|
358
|
+
if (task) {
|
|
359
|
+
let line = `Task: ${task.title} (${task.status})`;
|
|
360
|
+
if (task.phase) line += ` | phase: ${task.phase}`;
|
|
361
|
+
line += ` — .context/tasks/${task.id}`;
|
|
362
|
+
candidates.push(line);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (info) {
|
|
366
|
+
const summaryFm = readFrontmatterBlock(
|
|
367
|
+
path.join(root, '.context', info.dirName, 'SUMMARY.md'),
|
|
368
|
+
);
|
|
369
|
+
if (summaryFm) {
|
|
370
|
+
candidates.push(
|
|
371
|
+
`--- .context/${info.dirName}/SUMMARY.md (progress) ---\n${truncateSection(summaryFm)}`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (candidates.length === 0) return null;
|
|
377
|
+
|
|
378
|
+
const header = '[CCG] Auto-injected phase spec (source: .context/ + .ccg/).'
|
|
379
|
+
+ ' Honor the decisions/constraints below; do not re-litigate them.';
|
|
380
|
+
// Joined shape: MARKER \n header \n seg1 \n ... \n segN \n END
|
|
381
|
+
// Fixed overhead = marker + header + end + 2 joining newlines; each chosen
|
|
382
|
+
// segment then costs its own length + 1 joining newline.
|
|
383
|
+
let budget = MAX_INJECTION_CHARS
|
|
384
|
+
- (INJECTION_MARKER.length + header.length + INJECTION_END.length + 2);
|
|
385
|
+
const chosen = [];
|
|
386
|
+
for (const seg of candidates) {
|
|
387
|
+
const cost = seg.length + 1;
|
|
388
|
+
if (cost <= budget) {
|
|
389
|
+
chosen.push(seg);
|
|
390
|
+
budget -= cost;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (chosen.length === 0) return null;
|
|
394
|
+
|
|
395
|
+
return [INJECTION_MARKER, header, ...chosen, INJECTION_END].join('\n');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// Decision (pure function — primary test entry)
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* decide(stdinJson) → { action: 'skip' } | { action: 'inject', updatedInput }.
|
|
404
|
+
* Skip conditions (any one):
|
|
405
|
+
* - tool_name outside Task|Agent (defense in depth behind the matcher)
|
|
406
|
+
* - tool_input.prompt is not a non-empty string
|
|
407
|
+
* - subagent_type hits SUBAGENT_DENYLIST
|
|
408
|
+
* - prompt already carries INJECTION_MARKER (idempotency)
|
|
409
|
+
* - cwd is not inside a CCG project
|
|
410
|
+
* - nothing to inject (no roadmap phase, no CONTEXT/SUMMARY, no task)
|
|
411
|
+
* On inject, updatedInput spreads the original tool_input so every other
|
|
412
|
+
* field (subagent_type / description / model / team_name / name) survives.
|
|
413
|
+
*/
|
|
414
|
+
function decide(input) {
|
|
415
|
+
const data = input && typeof input === 'object' ? input : {};
|
|
416
|
+
const toolInput = data.tool_input && typeof data.tool_input === 'object'
|
|
417
|
+
? data.tool_input
|
|
418
|
+
: {};
|
|
419
|
+
|
|
420
|
+
if (!AGENT_TOOL_NAMES.has(data.tool_name)) return { action: 'skip' };
|
|
421
|
+
if (typeof toolInput.prompt !== 'string' || !toolInput.prompt) return { action: 'skip' };
|
|
422
|
+
|
|
423
|
+
const subagentType = String(toolInput.subagent_type || '');
|
|
424
|
+
if (SUBAGENT_DENYLIST.some(d => subagentType.includes(d))) return { action: 'skip' };
|
|
425
|
+
if (toolInput.prompt.includes(INJECTION_MARKER)) return { action: 'skip' };
|
|
426
|
+
|
|
427
|
+
const cwd = typeof data.cwd === 'string' && data.cwd
|
|
428
|
+
? data.cwd
|
|
429
|
+
: (process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
430
|
+
const root = findCcgRoot(cwd);
|
|
431
|
+
if (!root) return { action: 'skip' };
|
|
432
|
+
|
|
433
|
+
const injection = composeInjection(root);
|
|
434
|
+
if (!injection) return { action: 'skip' };
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
action: 'inject',
|
|
438
|
+
updatedInput: Object.assign({}, toolInput, {
|
|
439
|
+
prompt: `${injection}\n\n---\n\n${toolInput.prompt}`,
|
|
440
|
+
}),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
// Entry point — only runs when invoked directly (not when required by tests
|
|
446
|
+
// or by a sibling hook).
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
function main() {
|
|
450
|
+
try {
|
|
451
|
+
let raw;
|
|
452
|
+
try {
|
|
453
|
+
raw = fs.readFileSync(0, 'utf-8');
|
|
454
|
+
} catch {
|
|
455
|
+
process.exit(0);
|
|
456
|
+
}
|
|
457
|
+
let parsed;
|
|
458
|
+
try {
|
|
459
|
+
parsed = JSON.parse(raw || '{}');
|
|
460
|
+
} catch {
|
|
461
|
+
process.exit(0);
|
|
462
|
+
}
|
|
463
|
+
const d = decide(parsed);
|
|
464
|
+
if (d.action === 'inject') {
|
|
465
|
+
// Deliberately no permissionDecision: updatedInput is valid standalone
|
|
466
|
+
// and omitting it keeps the user's permission-approval flow untouched.
|
|
467
|
+
process.stdout.write(JSON.stringify({
|
|
468
|
+
hookSpecificOutput: {
|
|
469
|
+
hookEventName: 'PreToolUse',
|
|
470
|
+
updatedInput: d.updatedInput,
|
|
471
|
+
},
|
|
472
|
+
}));
|
|
473
|
+
}
|
|
474
|
+
// skip → zero stdout: non-CCG projects pay nothing, Claude Code treats
|
|
475
|
+
// empty output as "no changes".
|
|
476
|
+
process.exit(0);
|
|
477
|
+
} catch {
|
|
478
|
+
process.exit(0);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (require.main === module) {
|
|
483
|
+
main();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Test surface — consumed by src/utils/__tests__/subagentContext.test.ts.
|
|
487
|
+
module.exports = {
|
|
488
|
+
findCcgRoot,
|
|
489
|
+
getActivePhaseInfo,
|
|
490
|
+
readFrontmatterBlock,
|
|
491
|
+
readActiveTask,
|
|
492
|
+
readActiveTaskFromState,
|
|
493
|
+
composeInjection,
|
|
494
|
+
decide,
|
|
495
|
+
INJECTION_MARKER,
|
|
496
|
+
INJECTION_END,
|
|
497
|
+
MAX_INJECTION_CHARS,
|
|
498
|
+
MAX_SECTION_CHARS,
|
|
499
|
+
MAX_TASK_DIRS,
|
|
500
|
+
MAX_STATE_BYTES,
|
|
501
|
+
MAX_TASK_BYTES,
|
|
502
|
+
AGENT_TOOL_NAMES,
|
|
503
|
+
SUBAGENT_DENYLIST,
|
|
504
|
+
TERMINAL_TASK_STATUSES,
|
|
505
|
+
};
|