composition.ts
138 lines · scripts/resolvers/composition.ts at 3f79d81
1import { toShellPath, type TemplateContext } from './types';
2import * as path from 'path';
3import { getHostConfig } from '../../hosts';
4import { runtimeRootPrelude } from './runtime-root';
6/** Claude's scoped hook enforces the parent publication boundary during /autoplan. */
7export function generateAutoplanPublicationHook(ctx: TemplateContext, args?: string[]): string {
8 if (ctx.skillName !== 'autoplan' || args?.length) {
9 throw new Error('AUTOPLAN_PUBLICATION_HOOK is only valid in autoplan without arguments');
10 }
11 if (ctx.host !== 'claude') return '';
13 // Use the same installed runtime path as Claude's existing skill hooks.
14 // Emitting this through a host-specific resolver also keeps other hosts free
15 // of the generator's destructive-command advisory for safety hooks.
16 const unavailable = JSON.stringify({ hookSpecificOutput: {
17 hookEventName: 'PreToolUse', permissionDecision: 'deny',
18 permissionDecisionReason: 'Autoplan publication guard is unavailable. Restore the installed autoplan/bin/phase-publication-hook before continuing this skill.',
19 } });
20 const shellWord = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
21 const script = `S="${toShellPath(ctx.paths.skillRoot)}/autoplan/bin/phase-publication-hook"
22if [ -f "$S" ]; then exec bash "$S"; fi
23printf '%s\\n' ${shellWord(unavailable)}`;
24 const command = `bash -c ${shellWord(script)}`;
25 // Keep each tool literal: the early-question launchers admit these hooks
26 // through their existing finite matcher policy.
27 const entries = ['Read', 'Agent'].map(tool => ` - matcher: "${tool}"
28 hooks:
29 - type: command
30 command: ${JSON.stringify(command)}
31 statusMessage: "Checking Autoplan phase publication..."`).join('\n');
32 return `hooks:\n PreToolUse:\n${entries}`;
33}
35/**
36 * {{CEO_MODE_HANDOFF_HOOK}}: /plan-ceo-review's PostToolUse hook that shows the
37 * Step 0E mode handoff line to the user as a system message (Claude only), so
38 * the line is visible even when the model paraphrases it in chat.
39 */
40export function generateCeoModeHandoffHook(ctx: TemplateContext, args?: string[]): string {
41 if (ctx.skillName !== 'plan-ceo-review' || args?.length) {
42 throw new Error('CEO_MODE_HANDOFF_HOOK is only valid in plan-ceo-review without arguments');
43 }
44 if (ctx.host !== 'claude') return '';
45 const shellWord = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
46 const script = `S="${toShellPath(ctx.paths.skillRoot)}/plan-ceo-review/bin/mode-handoff-hook"
47if [ -f "$S" ]; then exec bash "$S"; fi
48exit 0`;
49 return `hooks:
50 PostToolUse:
51 - matcher: "Bash"
52 hooks:
53 - type: command
54 command: ${JSON.stringify(`bash -c ${shellWord(script)}`)}`;
55}
57/**
58 * {{INVOKE_SKILL:skill-name}} — emits prose instructing Claude to read
59 * another skill's SKILL.md and follow it, skipping preamble sections.
60 *
61 * Supports optional skip= parameter for additional sections to skip:
62 * {{INVOKE_SKILL:plan-ceo-review:skip=Outside Voice,Design Outside Voices}}
63 */
64export function generateInvokeSkill(ctx: TemplateContext, args?: string[]): string {
65 const skillName = args?.[0];
66 if (!skillName || skillName === '') {
67 throw new Error('{{INVOKE_SKILL}} requires a skill name, e.g. {{INVOKE_SKILL:plan-ceo-review}}');
68 }
70 // Parse optional skip= parameter from args[1+]
71 const extraSkips = (args?.slice(1) || [])
72 .filter(a => a.startsWith('skip='))
73 .flatMap(a => a.slice(5).split(','))
74 .map(s => s.trim())
75 .filter(Boolean);
77 const DEFAULT_SKIPS = [
78 'Preamble (run first)',
79 'AskUserQuestion Format',
80 'Shared preamble (load first)',
81 'Search Before Building',
82 'Contributor Mode',
83 'Telemetry (run last)',
84 'Step 0: Detect platform and base branch',
85 'Review Readiness Dashboard',
86 'Plan File Review Report',
87 'Prerequisite Skill Offer',
88 'Plan Status Footer',
89 ];
91 const allSkips = [...DEFAULT_SKIPS, ...extraSkips];
93 return `Read the \`/${skillName}\` skill file at \`${ctx.paths.skillRoot}/${skillName}/SKILL.md\` using the Read tool.
95**If unreadable:** Skip with "Could not load /${skillName} — skipping." and continue.
97Follow its instructions from top to bottom, **skipping these sections when present** (already handled by the parent skill):
98${allSkips.map(s => `- ${s}`).join('\n')}
100Execute every other section at full depth. When the loaded skill's instructions are complete, continue with the next step below.`;
101}
103/** Autoplan reads methodology from this host's skill registry, not its runtime assets. */
104export function generateAutoplanReviewFile(ctx: TemplateContext, args?: string[]): string {
105 const skill = args?.[0];
106 if (!skill || !['plan-ceo-review', 'plan-design-review', 'plan-devex-review', 'plan-eng-review'].includes(skill)) {
107 throw new Error('AUTOPLAN_REVIEW_FILE requires an autoplan review skill');
108 }
109 const withSections = args?.[1] === 'with-sections';
110 if ((args?.length ?? 0) > 2 || (args?.[1] !== undefined && !withSections)) {
111 throw new Error('AUTOPLAN_REVIEW_FILE only accepts with-sections');
112 }
113 // Every host prepares an explicit bound artifact before create. Inline hosts
114 // supply one complete source file; Claude supplies main plus its carved section.
115 if (withSections) {
116 const phase = skill === 'plan-devex-review' ? 'dx' : skill.split('-')[1]!;
117 return `\`methodologyPath\` from \`bun "<SNAPSHOT_TOOL>" methodology ${phase} "<REVIEW_SKILL>" "<RESTORE_PATH>"\``;
118 }
119 if (ctx.host === 'claude') return `\`${ctx.paths.skillRoot}/${skill}/SKILL.md\``;
121 const host = getHostConfig(ctx.host);
122 const file = `gstack-${skill}/SKILL.md`;
123 const local = `${path.posix.dirname(host.localSkillRoot)}/${file}`;
124 const global = `~/${path.posix.dirname(host.globalRoot)}/${file}`;
125 // Resolve from the discovered entrypoint's directory: GSTACK_ROOT is an
126 // independently configurable runtime asset tree, not a skill registry.
127 // This also preserves custom CODEX_HOME installations without guessing HOME.
128 // Other hosts inline the review sections into this full registry file.
129 return `the sibling registry file \`../${file}\`, relative to the installed \`/autoplan\` SKILL.md directory (local: \`${local}\`; global: \`${global}\`${ctx.host === 'codex' ? ', or the corresponding skills directory under CODEX_HOME when configured' : ''})`;
130}
132/** Resolve once to a literal path; later phase commands run in fresh shells. */
133export function generateAutoplanSnapshotTool(ctx: TemplateContext): string {
134 return `\`\`\`bash
135${runtimeRootPrelude(ctx)}
136bun --no-env-file --config=/dev/null -e 'console.log(require("fs").realpathSync(process.argv[1]))' "${toShellPath(ctx.paths.binDir)}/gstack-autoplan-snapshot.ts"
137\`\`\``;
138}