ccgx-workflow 2.4.1 → 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.
Files changed (46) hide show
  1. package/README.md +134 -277
  2. package/README.zh-CN.md +134 -272
  3. package/dist/chunks/version-build.mjs +1 -1
  4. package/dist/cli.mjs +1 -1
  5. package/dist/index.d.mts +709 -16
  6. package/dist/index.d.ts +709 -16
  7. package/dist/index.mjs +1061 -30
  8. package/dist/shared/{ccgx-workflow.j1spUsik.mjs → ccgx-workflow.CdHnJLak.mjs} +106 -22
  9. package/package.json +1 -1
  10. package/templates/commands/agents/code-fixer.md +6 -6
  11. package/templates/commands/agents/phase-runner.md +46 -14
  12. package/templates/commands/agents/plan-checker.md +10 -0
  13. package/templates/commands/analyze.md +66 -25
  14. package/templates/commands/autonomous.md +428 -225
  15. package/templates/commands/cancel.md +9 -0
  16. package/templates/commands/codex-exec.md +12 -11
  17. package/templates/commands/context.md +14 -0
  18. package/templates/commands/debate.md +10 -6
  19. package/templates/commands/execute.md +76 -28
  20. package/templates/commands/optimize.md +53 -25
  21. package/templates/commands/plan.md +78 -28
  22. package/templates/commands/review.md +26 -19
  23. package/templates/commands/spec-impl.md +68 -127
  24. package/templates/commands/spec-plan.md +61 -82
  25. package/templates/commands/spec-research.md +35 -92
  26. package/templates/commands/spec-review.md +34 -119
  27. package/templates/commands/status.md +1 -0
  28. package/templates/commands/team-exec.md +45 -13
  29. package/templates/commands/team.md +64 -167
  30. package/templates/commands/test.md +56 -34
  31. package/templates/commands/verify-work.md +36 -13
  32. package/templates/commands/verify.md +35 -0
  33. package/templates/commands/workflow.md +22 -37
  34. package/templates/hooks/ccg-loop-detector.cjs +39 -8
  35. package/templates/hooks/ccg-statusline.js +142 -2
  36. package/templates/hooks/ccg-stop-gate.cjs +248 -19
  37. package/templates/hooks/ccg-subagent-context.cjs +505 -0
  38. package/templates/scripts/ccg-state-lock.cjs +510 -0
  39. package/templates/scripts/ccg-team-schedule.cjs +328 -0
  40. package/templates/scripts/ccgx-call-plugin.mjs +494 -141
  41. package/templates/scripts/invoke-model.mjs +28 -1
  42. package/templates/scripts/task-store.cjs +614 -0
  43. package/templates/skills/tools/verify-change/SKILL.md +7 -0
  44. package/templates/skills/tools/verify-module/SKILL.md +7 -0
  45. package/templates/skills/tools/verify-quality/SKILL.md +7 -0
  46. 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
+ };