From 242e918bcbd60002a614ebe4c1be650feaca2312 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 19:33:48 -0800 Subject: [PATCH] feat(hosts): /land-and-deploy and /modernize run only when typed on Claude Code and Cursor (C1) (#3808) * feat(hosts): manual-only /land-and-deploy and /modernize on Claude Code and Cursor (C1, GST-804) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(routing): host-neutral router wording, update repo CLAUDE.md and the onboarding fixture for the manual-only skills Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(gen): escape conditionalFields keys, anchor values, skip keys the template already sets Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(modernize): trim always-on description to stay under the 8000-token context budget Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CLAUDE.md | 2 +- SKILL.md | 4 +- SKILL.md.tmpl | 4 +- bin/gstack-skill-start | 2 +- changelog.d/capy-c1-manual-only-skills.md | 1 + docs/ADDING_A_HOST.md | 2 +- gstack/llms.txt | 2 +- hosts/claude.ts | 5 +- hosts/cursor.ts | 9 ++++ land-and-deploy/SKILL.md | 9 ++-- land-and-deploy/SKILL.md.tmpl | 9 ++-- modernize/SKILL.md | 3 +- modernize/SKILL.md.tmpl | 5 +- scripts/gen-skill-docs.ts | 28 +++++----- .../helpers/autoplan-preconfigured-fixture.ts | 2 +- test/host-config.test.ts | 53 +++++++++++++++++++ test/modernize-skill.test.ts | 2 +- 17 files changed, 104 insertions(+), 38 deletions(-) create mode 100644 changelog.d/capy-c1-manual-only-skills.md diff --git a/CLAUDE.md b/CLAUDE.md index bb2d196cd..37fb8db4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -554,7 +554,7 @@ Key routing rules: - QA/testing site behavior → invoke /qa or /qa-only - Code review/diff check → invoke /review - Visual polish → invoke /design-review -- Ship/deploy/PR → invoke /ship or /land-and-deploy +- Ship/deploy/PR → invoke /ship (merge + deploy: the user types /land-and-deploy) - Save progress → invoke /context-save - Resume context → invoke /context-restore diff --git a/SKILL.md b/SKILL.md index 59765b930..19892de70 100644 --- a/SKILL.md +++ b/SKILL.md @@ -210,8 +210,8 @@ quality gates that produce better results than answering inline. - User asks to audit the live developer experience, time-to-hello-world → invoke `/devex-review` - User asks to run the checks, make the tests pass, "verify my change", "check my work" while building → invoke `/verify` - User asks to ship, deploy, push, create a PR, "let's land this", "send it" → invoke `/ship` -- User asks to merge + deploy + verify as one flow → invoke `/land-and-deploy` -- User asks to rebase or revive an old PR, "modernize this PR", whether a stale PR is still worth landing → invoke `/modernize` +- User asks to merge + deploy + verify as one flow → tell them to type `/land-and-deploy`; never merge or deploy by hand +- User asks to rebase or revive an old PR, "modernize this PR", whether a stale PR is still worth landing → tell them to type `/modernize` - User asks to configure deployment for the project → invoke `/setup-deploy` - User asks to monitor prod after shipping, post-deploy checks → invoke `/canary` - User asks to update docs after shipping → invoke `/document-release` diff --git a/SKILL.md.tmpl b/SKILL.md.tmpl index 755fe0197..8e8a15302 100644 --- a/SKILL.md.tmpl +++ b/SKILL.md.tmpl @@ -86,8 +86,8 @@ quality gates that produce better results than answering inline. - User asks to audit the live developer experience, time-to-hello-world → invoke `/devex-review` - User asks to run the checks, make the tests pass, "verify my change", "check my work" while building → invoke `/verify` - User asks to ship, deploy, push, create a PR, "let's land this", "send it" → invoke `/ship` -- User asks to merge + deploy + verify as one flow → invoke `/land-and-deploy` -- User asks to rebase or revive an old PR, "modernize this PR", whether a stale PR is still worth landing → invoke `/modernize` +- User asks to merge + deploy + verify as one flow → tell them to type `/land-and-deploy`; never merge or deploy by hand +- User asks to rebase or revive an old PR, "modernize this PR", whether a stale PR is still worth landing → tell them to type `/modernize` - User asks to configure deployment for the project → invoke `/setup-deploy` - User asks to monitor prod after shipping, post-deploy checks → invoke `/canary` - User asks to update docs after shipping → invoke `/document-release` diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index 1ca710280..29dc7ce42 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -706,7 +706,7 @@ Key routing rules: - QA/testing site behavior → invoke /qa or /qa-only - Code review/diff check → invoke /review - Visual polish → invoke /design-review -- Ship/deploy/PR → invoke /ship or /land-and-deploy +- Ship/deploy/PR → invoke /ship (merge + deploy: the user types /land-and-deploy) - Save progress → invoke /context-save - Resume context → invoke /context-restore - Author a backlog-ready spec/issue → invoke /spec diff --git a/changelog.d/capy-c1-manual-only-skills.md b/changelog.d/capy-c1-manual-only-skills.md new file mode 100644 index 000000000..32f1a9ddd --- /dev/null +++ b/changelog.d/capy-c1-manual-only-skills.md @@ -0,0 +1 @@ +Claude Code and Cursor no longer start /land-and-deploy or /modernize on their own. Type `/land-and-deploy` or `/modernize` to run them; a request in plain words like "land it" now gets a pointer to the command instead of a mistaken merge or rewrite of an old branch. Both skills already stopped for your approval before merging or pushing, so this removes surprise rather than a missing safeguard. /ship, /careful, /freeze, /guard and /unfreeze still start from plain words like "ship it". gstack's router and the CLAUDE.md routing block now tell the model to point you to these two commands instead of invoking them. Under the hood, a new `manual_only: true` template flag maps to `disable-model-invocation: true` on the Claude and Cursor hosts, and host `conditionalFields` now work in denylist mode too. diff --git a/docs/ADDING_A_HOST.md b/docs/ADDING_A_HOST.md index f58fffe83..08c34acaf 100644 --- a/docs/ADDING_A_HOST.md +++ b/docs/ADDING_A_HOST.md @@ -508,7 +508,7 @@ Key fields: | `frontmatter.mode` | `allowlist` (keep only listed) or `denylist` (strip listed) | | `frontmatter.descriptionLimit` | Max chars, `null` for no limit | | `frontmatter.descriptionLimitBehavior` | `error` (fail build), `truncate`, `warn` | -| `frontmatter.conditionalFields` | Add fields based on template values (e.g., sensitive → disable-model-invocation) | +| `frontmatter.conditionalFields` | Add fields based on template values, in allowlist and denylist mode. Two template flags map to `disable-model-invocation: true`: `sensitive` (broad: /ship, /land-and-deploy, /modernize and the safety skills; Copilot and Factory) and `manual_only` (narrow: /land-and-deploy and /modernize only; Claude Code and Cursor, where "ship it" must still start /ship). A host whose docs honor `disable-model-invocation` picks one | | `frontmatter.renameFields` | Rename template fields (e.g., voice-triggers → triggers) | | `generation.generateMetadata` | Emit `agents/openai.yaml` beside each skill (Codex only today; the format is hardcoded in `gen-skill-docs.ts`). Its `default_prompt` comes from the template's `codex_default_prompt:` frontmatter key, else `Use for this task.` | | `pathRewrites` | Literal replaceAll on content. Order matters. Replaces the derived trio. | diff --git a/gstack/llms.txt b/gstack/llms.txt index 4078aaac3..4f7180eed 100644 --- a/gstack/llms.txt +++ b/gstack/llms.txt @@ -47,7 +47,7 @@ Conventions: - [/landing-report](landing-report/SKILL.md): Read-only queue dashboard for workspace-aware ship. - [/learn](learn/SKILL.md): Manage project learnings. - [/make-pdf](make-pdf/SKILL.md): Turn any markdown file into a publication-quality PDF. -- [/modernize](modernize/SKILL.md): Bring an aging pull request onto its current base and decide whether it should still exist: MODERNIZE, REWORK or CLOSE, with range-diff proof before any push. +- [/modernize](modernize/SKILL.md): Bring an aging PR onto its current base and decide: MODERNIZE, REWORK or CLOSE. - [/office-hours](office-hours/SKILL.md): YC Office Hours — two modes. - [/open-gstack-browser](open-gstack-browser/SKILL.md): Launch GStack Browser — AI-controlled Chromium with the sidebar extension baked in. - [/pair-agent](pair-agent/SKILL.md): Pair a remote AI agent with your browser. diff --git a/hosts/claude.ts b/hosts/claude.ts index 22c4d7189..8bbcfcea8 100644 --- a/hosts/claude.ts +++ b/hosts/claude.ts @@ -17,8 +17,11 @@ const claude = defineHost({ // allowed-tools/hooks; bin/gstack-brain-context-load reads gbrain: — which // is why gbrain and hooks are NOT stripped). Stripping them trims the // always-on frontmatter catalog every session loads. - stripFields: ['sensitive', 'voice-triggers', 'interactive', 'benefits-from', 'codex_default_prompt'], + stripFields: ['sensitive', 'manual_only', 'voice-triggers', 'interactive', 'benefits-from', 'codex_default_prompt'], descriptionLimit: null, + conditionalFields: [ + { if: { manual_only: true }, add: { 'disable-model-invocation': true } }, + ], }, generation: { diff --git a/hosts/cursor.ts b/hosts/cursor.ts index e0d38db8a..0383bdd3f 100644 --- a/hosts/cursor.ts +++ b/hosts/cursor.ts @@ -3,6 +3,15 @@ import { defineHost } from './define-host'; const cursor = defineHost({ name: 'cursor', displayName: 'Cursor', + + frontmatter: { + mode: 'allowlist', + keepFields: ['name', 'description'], + descriptionLimit: null, + conditionalFields: [ + { if: { manual_only: true }, add: { 'disable-model-invocation': true } }, + ], + }, }); export default cursor; diff --git a/land-and-deploy/SKILL.md b/land-and-deploy/SKILL.md index 57e64a21a..2243d5140 100644 --- a/land-and-deploy/SKILL.md +++ b/land-and-deploy/SKILL.md @@ -13,6 +13,7 @@ triggers: - merge and deploy - land the pr - ship to production +disable-model-invocation: true --- @@ -575,8 +576,8 @@ to check next. No deploy needed: merged, verification skipped for the stated rea Staging-only: production remains unverified, not necessarily undeployed. Rollback pending: identify who must resolve conflicts, merge the revert PR, or verify its deploy. REVERTED: cite rollback evidence; do not claim the original branch survived cleanup. -Offer `/canary ` for extended monitoring, `/benchmark ` when performance -matters, and `/document-release` when docs need updating. +Offer `/canary ` for extended monitoring, `/benchmark ` for performance, +and `/document-release` for docs. --- @@ -592,8 +593,8 @@ Recheck read-only evidence; never redo a merge/deploy because a section was miss ## Important Rules - Never force-push, bypass CI, replay a confirmed merge, or hide missing evidence. -- Auto-detect facts; ask when unknown or when an explicit approval gate applies. +- Auto-detect facts; ask when unknown or an explicit approval gate applies. - Step 3 is one `gstack-pr-wait` call; elsewhere poll at the stated interval (30 s for merge readback and deploy) with progress lines. - After merge failures, offer approved rollback when appropriate; never revert a rollback automatically. -- Verify once; `/canary` provides extended monitoring. Rechecks require the user's choice. +- Verify once; `/canary` does extended monitoring; rechecks need the user's choice. - Use `--delete-branch`; reconcile failed cleanup non-destructively with confirmation. diff --git a/land-and-deploy/SKILL.md.tmpl b/land-and-deploy/SKILL.md.tmpl index f620abfb1..e38c32dea 100644 --- a/land-and-deploy/SKILL.md.tmpl +++ b/land-and-deploy/SKILL.md.tmpl @@ -15,6 +15,7 @@ allowed-tools: - Glob - AskUserQuestion sensitive: true +manual_only: true triggers: - merge and deploy - land the pr @@ -422,8 +423,8 @@ to check next. No deploy needed: merged, verification skipped for the stated rea Staging-only: production remains unverified, not necessarily undeployed. Rollback pending: identify who must resolve conflicts, merge the revert PR, or verify its deploy. REVERTED: cite rollback evidence; do not claim the original branch survived cleanup. -Offer `/canary ` for extended monitoring, `/benchmark ` when performance -matters, and `/document-release` when docs need updating. +Offer `/canary ` for extended monitoring, `/benchmark ` for performance, +and `/document-release` for docs. --- @@ -439,8 +440,8 @@ Recheck read-only evidence; never redo a merge/deploy because a section was miss ## Important Rules - Never force-push, bypass CI, replay a confirmed merge, or hide missing evidence. -- Auto-detect facts; ask when unknown or when an explicit approval gate applies. +- Auto-detect facts; ask when unknown or an explicit approval gate applies. - Step 3 is one `gstack-pr-wait` call; elsewhere poll at the stated interval (30 s for merge readback and deploy) with progress lines. - After merge failures, offer approved rollback when appropriate; never revert a rollback automatically. -- Verify once; `/canary` provides extended monitoring. Rechecks require the user's choice. +- Verify once; `/canary` does extended monitoring; rechecks need the user's choice. - Use `--delete-branch`; reconcile failed cleanup non-destructively with confirmation. diff --git a/modernize/SKILL.md b/modernize/SKILL.md index 2b50f82ac..60fc559ce 100644 --- a/modernize/SKILL.md +++ b/modernize/SKILL.md @@ -2,7 +2,7 @@ name: modernize preamble-tier: 2 version: 0.1.0 -description: "Bring an aging pull request onto its current base and decide whether it should still exist: MODERNIZE, REWORK or CLOSE, with range-diff proof before any push. (gstack)" +description: "Bring an aging PR onto its current base and decide: MODERNIZE, REWORK or CLOSE. (gstack)" triggers: - modernize this pr - rebase this old pr @@ -14,6 +14,7 @@ allowed-tools: - Grep - Glob - AskUserQuestion +disable-model-invocation: true --- diff --git a/modernize/SKILL.md.tmpl b/modernize/SKILL.md.tmpl index 626c49bf8..93f2883e9 100644 --- a/modernize/SKILL.md.tmpl +++ b/modernize/SKILL.md.tmpl @@ -3,8 +3,8 @@ name: modernize preamble-tier: 2 version: 0.1.0 description: | - Bring an aging pull request onto its current base and decide whether it should - still exist: MODERNIZE, REWORK or CLOSE, with range-diff proof before any push. + Bring an aging PR onto its current base and decide: MODERNIZE, REWORK or CLOSE. + Proves the rebase with range-diff before any push. Use when asked to "modernize this PR", "rebase this old PR", "revive this branch", or "is this PR still worth landing". Never closes a PR on its own. (gstack) @@ -21,6 +21,7 @@ allowed-tools: - Glob - AskUserQuestion sensitive: true +manual_only: true --- # /modernize — Is this PR still worth landing? diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index a644f7337..773682613 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -486,13 +486,21 @@ policy: /** * Transform frontmatter for external hosts. - * Claude: strips `sensitive:` field (only Factory uses it). + * Claude: strips template-only fields, adds disable-model-invocation to manual_only skills. * Codex: keeps name + description only, enforces 1024-char limit. * Factory: keeps name + description + user-invocable, conditionally adds disable-model-invocation. */ function transformFrontmatter(content: string, host: Host): string { const hostConfig = getHostConfig(host); const fm = hostConfig.frontmatter; + const templateFrontmatter = content.startsWith('---\n') ? content.slice(4, content.indexOf('\n---', 4)) : ''; + const escape = (s: unknown) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const conditionalLines = (fm.conditionalFields || []) + .filter(rule => Object.entries(rule.if).every(([k, v]) => new RegExp(`^${escape(k)}:[ \\t]*${escape(v)}[ \\t]*$`, 'm').test(templateFrontmatter))) + .flatMap(rule => Object.entries(rule.add)) + .filter(([key]) => !new RegExp(`^${escape(key)}:`, 'm').test(templateFrontmatter)) + .map(([key, value]) => `${key}: ${value}\n`) + .join(''); if (fm.mode === 'denylist') { // Denylist mode: strip listed fields, keep everything else @@ -503,7 +511,7 @@ function transformFrontmatter(content: string, host: Host): string { content = content.replace(new RegExp(`^${field}:\\s*.*\\n`, 'm'), ''); } } - return content; + return conditionalLines ? content.replace('\n---\n', () => `\n${conditionalLines}---\n`) : content; } // Allowlist mode: reconstruct frontmatter with only allowed fields @@ -546,19 +554,7 @@ function transformFrontmatter(content: string, host: Host): string { } } - // Add conditional fields - if (fm.conditionalFields) { - for (const rule of fm.conditionalFields) { - const match = Object.entries(rule.if).every(([k, v]) => - new RegExp(`^${k}:\\s*${v}`, 'm').test(frontmatter) - ); - if (match) { - for (const [key, value] of Object.entries(rule.add)) { - newFm += `${key}: ${value}\n`; - } - } - } - } + newFm += conditionalLines; // Preserve additional keepFields beyond name and description if (fm.keepFields) { @@ -853,7 +849,7 @@ function processTemplate(tmplPath: string, host: Host, options: RenderOptions): // metadata gets the updated description with voice triggers included. const postProcessDescription = extractNameAndDescription(content).description; - // For Claude: strip sensitive: field (only Factory uses it) + // For Claude: strip template-only fields, add conditional fields // For external hosts: route output, transform frontmatter, rewrite paths let symlinkLoop = false; let metadata: { outputPath: string; content: string } | undefined; diff --git a/test/helpers/autoplan-preconfigured-fixture.ts b/test/helpers/autoplan-preconfigured-fixture.ts index a57b10059..6e2b4cb36 100644 --- a/test/helpers/autoplan-preconfigured-fixture.ts +++ b/test/helpers/autoplan-preconfigured-fixture.ts @@ -16,7 +16,7 @@ Key routing rules: - QA/testing site behavior → invoke /qa or /qa-only - Code review/diff check → invoke /review - Visual polish → invoke /design-review -- Ship/deploy/PR → invoke /ship or /land-and-deploy +- Ship/deploy/PR → invoke /ship (merge + deploy: the user types /land-and-deploy) - Save progress → invoke /context-save - Resume context → invoke /context-restore - Author a backlog-ready spec/issue → invoke /spec diff --git a/test/host-config.test.ts b/test/host-config.test.ts index e05ef8ed6..91cb5fd4d 100644 --- a/test/host-config.test.ts +++ b/test/host-config.test.ts @@ -772,6 +772,59 @@ describe('Copilot host render (#393)', () => { }); }); +describe('Manual-only skills on Claude Code and Cursor', () => { + const OUT = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cursor-')); + beforeAll(() => { + const r = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'cursor', '--out-dir', OUT], { cwd: ROOT, timeout: 120_000 }); + if (r.exitCode !== 0) throw new Error(r.stderr.toString()); + }); + afterAll(() => fs.rmSync(OUT, { recursive: true, force: true })); + const frontmatter = (file: string) => { + const md = fs.readFileSync(file, 'utf8'); + return md.slice(0, md.indexOf('\n---', 4)); + }; + const rendered: Record string> = { + claude: (skill) => frontmatter(path.join(ROOT, skill, 'SKILL.md')), + cursor: (skill) => frontmatter(path.join(OUT, '.cursor', 'skills', `gstack-${skill}`, 'SKILL.md')), + }; + const shipKeys: Record = { + claude: ['name', 'preamble-tier', 'version', 'description', 'allowed-tools', 'triggers'], + cursor: ['name', 'description'], + }; + + const manualOnly = fs.readdirSync(ROOT).filter(dir => /^manual_only: true$/m.test(fs.existsSync(path.join(ROOT, dir, 'SKILL.md.tmpl')) ? fs.readFileSync(path.join(ROOT, dir, 'SKILL.md.tmpl'), 'utf8') : '')); + + test('manual_only marks exactly /land-and-deploy and /modernize, and no router line invokes them', () => { + expect(manualOnly.sort()).toEqual(['land-and-deploy', 'modernize']); + const routers = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf8') + fs.readFileSync(path.join(ROOT, 'bin', 'gstack-skill-start'), 'utf8'); + for (const skill of manualOnly) { + expect(routers).not.toMatch(new RegExp(`invoke \`?/${skill}\\b`)); + } + }); + + for (const [host, read] of Object.entries(rendered)) { + test(`${host}: /land-and-deploy and /modernize run only when the user types their name`, () => { + for (const skill of manualOnly) { + expect(read(skill)).toMatch(/^disable-model-invocation: true$/m); + expect(read(skill)).not.toContain('manual_only'); + } + }); + + test(`${host}: /ship and the safety skills stay model-invocable`, () => { + expect([...read('ship').matchAll(/^([\w-]+):/gm)].map(m => m[1])).toEqual(shipKeys[host]); + for (const skill of ['careful', 'freeze', 'guard', 'unfreeze']) { + expect(read(skill)).not.toContain('disable-model-invocation'); + } + }); + } + + test('claude and cursor map manual_only, never sensitive, to disable-model-invocation', () => { + for (const host of [claude, cursor]) { + expect(host.frontmatter.conditionalFields).toEqual([{ if: { manual_only: true }, add: { 'disable-model-invocation': true } }]); + } + }); +}); + describe('Antigravity CLI host render', () => { const OUT = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-agy-')); beforeAll(() => { diff --git a/test/modernize-skill.test.ts b/test/modernize-skill.test.ts index 0bc531a59..bbc912abc 100644 --- a/test/modernize-skill.test.ts +++ b/test/modernize-skill.test.ts @@ -69,7 +69,7 @@ describe('/modernize skill', () => { expect(read('AGENTS.md')).toMatch(/^\| `\/modernize` \| /m); expect(read('docs/skills.md')).toMatch(/^\| \[`\/modernize`\]\(#modernize\) \| /m); expect(read('docs/skills.md')).toContain('## `/modernize`'); - expect(read('SKILL.md.tmpl')).toContain('invoke `/modernize`'); + expect(read('SKILL.md.tmpl')).toContain('type `/modernize`'); expect(read('gstack/llms.txt')).toContain('[/modernize](modernize/SKILL.md)'); expect(read('docs/PROJECT_STRUCTURE.md')).toMatch(/^├── modernize\//m); expect(read('lib/doctor-components.ts')).toMatch(/^ modernize: CLAUDE_ONLY,$/m);