diff --git a/.changeset/768-claude-permissions-prepopulate.md b/.changeset/768-claude-permissions-prepopulate.md new file mode 100644 index 000000000..9aab9eaac --- /dev/null +++ b/.changeset/768-claude-permissions-prepopulate.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 819 +--- +**Installer pre-populates `permissions.allow`/`deny` for Claude Code** — fresh Claude Code installs now receive GSD's known-safe tool-call patterns (`Bash(npx gsd-core *)`, `Read(.planning/*)`, `Write(.planning/*)`, `Read(STATE.md)`, `Write(STATE.md)`) in `settings.json` out of the box, eliminating first-run approval prompts. A `deny` block for credential files (`Read(.env)`, `Read(.env.*)`, `Read(.secrets)`) is also added for defense-in-depth. The merge is additive and idempotent; existing user-set entries are preserved. Uninstall removes only GSD-owned entries. (#768) diff --git a/.changeset/771-agent-named-colors.md b/.changeset/771-agent-named-colors.md new file mode 100644 index 000000000..6b8594ea5 --- /dev/null +++ b/.changeset/771-agent-named-colors.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 823 +--- +Agent `color:` frontmatter now uses Claude Code's documented named colors (`red`/`blue`/`green`/`yellow`/`purple`/`orange`/`pink`/`cyan`) instead of hex values or the undocumented `magenta`, so the intended per-agent TUI color differentiation renders reliably across the Claude Code runtime. Display-only metadata; no behavior change. (#771) \ No newline at end of file diff --git a/.changeset/772-codex-hook-events-commandwindows.md b/.changeset/772-codex-hook-events-commandwindows.md new file mode 100644 index 000000000..78a942559 --- /dev/null +++ b/.changeset/772-codex-hook-events-commandwindows.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 827 +--- +Codex installs now register three additional stable hook events (`SubagentStart`, `Stop`, `PostToolUse`) wired to `gsd-context-monitor.js`, matching the full event coverage available since Codex CLI stabilised these hooks. The `SessionStart` hook entry gains a `commandWindows` field on Windows installs so the `.cmd` shim is used for native execution (Git Bash/MSYS cannot POSIX-exec `node.exe` directly). Both new-event registration and uninstall paths handle the flat `{ "EventName": [...] }` and nested `{ "hooks": { "EventName": [...] } }` hooks.json shapes. `gsd-context-monitor.js` and its Windows `.cmd` sibling are added to the managed-hook allowlist so idempotent re-runs de-duplicate entries correctly. (#772) diff --git a/.changeset/774-codex-skill-metadata-service-tier.md b/.changeset/774-codex-skill-metadata-service-tier.md new file mode 100644 index 000000000..2f20d6218 --- /dev/null +++ b/.changeset/774-codex-skill-metadata-service-tier.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 828 +--- +Codex CLI installs now emit two enrichments per agent and skill. **Agent TOML enrichment:** light-tier agents (haiku-equivalent, `routingTier: "light"` in model-catalog.json) get `service_tier = "flex"` and `model_verbosity = "low"` appended to their agent TOML, telling the Codex scheduler to use the flex tier (lower cost, background processing) and suppress verbose token output. **Skill TUI chip:** each installed `gsd-*` skill directory now receives an `agents/openai.yaml` file with `interface.display_name` and `interface.short_description`, making the skill appear in the Codex `/skills` picker with a human-readable name and description drawn from the skill's existing short-description frontmatter. Both enrichments are additive and backward-compatible with Codex CLI ≥ 0.130.0. (#774) diff --git a/.changeset/778-cross-runtime-command-enrichment.md b/.changeset/778-cross-runtime-command-enrichment.md new file mode 100644 index 000000000..9ee123a24 --- /dev/null +++ b/.changeset/778-cross-runtime-command-enrichment.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 825 +--- +Cross-runtime command enrichment in the installer. Gemini CLI commands now use native `{{args}}` interpolation (translated from Claude's `$ARGUMENTS`) so typed arguments interpolate into the prompt body, and `/gsd:progress` injects live project state via a fixed, injection-safe `!{cat .planning/STATE.md 2>/dev/null}` shell block. Qwen Code skills now carry a numeric `priority` field so the most-used main-loop workflows (`new-project`, `plan-phase`, `execute-phase`, …) surface first in the `/skills` list. The OpenCode per-command `model`/`agent`/`subtask` enrichment was evaluated and intentionally not implemented — `model` would reintroduce the ProviderModelNotFoundError regression that the converter deliberately guards against for non-Anthropic providers (#1156), `subtask`/`agent` change execution semantics for GSD's interactive commands, and `variant` is not in the OpenCode command schema. (#778) diff --git a/.changeset/789-codebuddy-slash-commands.md b/.changeset/789-codebuddy-slash-commands.md new file mode 100644 index 000000000..244771897 --- /dev/null +++ b/.changeset/789-codebuddy-slash-commands.md @@ -0,0 +1,7 @@ +--- +type: Added +pr: 830 +--- +**CodeBuddy (Tencent) installs now emit `/gsd-*` slash commands.** A `--codebuddy` install writes `commands/gsd-.md` files to `~/.codebuddy/commands/` so GSD workflows are invokable from CodeBuddy's `/` menu (`/gsd-phase`, `/gsd-ship`, etc.), matching the integration depth of other fully-elevated runtimes (#789). The existing `skills/gsd-/SKILL.md` files are now emitted with `user-invocable: false` so they stay out of the `/` menu — the commands surface is the single `/` entry point (no duplicate entries) and skills remain available for model invocation. Subagents (`~/.codebuddy/agents/`) were already emitted and are unchanged. Uninstall removes the `gsd-*` command files while preserving user-owned commands. No `mcp.json` is written — gsd ships no MCP server and CodeBuddy's `mcp.json` only registers external MCP servers. + + diff --git a/.changeset/gemini-extension-package.md b/.changeset/gemini-extension-package.md new file mode 100644 index 000000000..5c527dc5a --- /dev/null +++ b/.changeset/gemini-extension-package.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 775 +--- +**Gemini CLI extension package** — gsd-core now ships a `gemini-extension.json` manifest (plus a `GEMINI.md` context payload) at the repository root, so Gemini CLI users can install, update, and remove GSD through Gemini's own extension lifecycle: `gemini extensions install https://github.com/open-gsd/gsd-core`, `gemini extensions update gsd-core`, `gemini extensions uninstall gsd-core`, and `gemini extensions link ` for local dev. The extension is discoverable in `gemini extensions list` and loads GSD's operating context into every session. Additive — the existing `npx gsd-core --gemini` installer (which provides the `/gsd:*` slash commands) is unchanged. (#775) diff --git a/.changeset/graceful-cranes-dart.md b/.changeset/graceful-cranes-dart.md new file mode 100644 index 000000000..279df7dab --- /dev/null +++ b/.changeset/graceful-cranes-dart.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 769 +--- +**`/gsd-plan-phase`, `/gsd-execute-phase`, `/gsd-autonomous` now run in an isolated forked context on Claude Code** — `context: fork` in skill frontmatter protects the main session's context budget. These three heavy skills also declare `effort: xhigh`; quick-status skills `/gsd-progress` and `/gsd-stats` declare `effort: low`. The installer preserves both fields when converting commands to Claude SKILL.md files. Runtimes that do not recognise these fields silently ignore them — no behaviour change on non-Claude runtimes. diff --git a/.changeset/mellow-yaks-bark.md b/.changeset/mellow-yaks-bark.md new file mode 100644 index 000000000..98227e60b --- /dev/null +++ b/.changeset/mellow-yaks-bark.md @@ -0,0 +1,6 @@ +--- +type: Changed +pr: 824 +--- + +Automated `codex exec` invocations in the review workflow now include `--ephemeral` (no session-state accumulation across automated/CI runs) and `--dangerously-bypass-hook-trust` (skip hook-trust prompts for hooks managed by gsd-core itself). These flags apply only to the non-interactive reviewer invocations in `gsd-core/workflows/review.md`. (#773) diff --git a/CONTEXT.md b/CONTEXT.md index 4f97160ed..ae909288b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -104,7 +104,7 @@ Module owning runtime identity normalization at runtime-selection seams. Canonic Module owning validation for Installer Migration Module records and planned actions. It enforces migration metadata, explicit install scopes, ownership evidence for destructive/config actions, and runtime contract citations for runtime config rewrites before a migration can enter planning or apply. ### Installer Module -Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Hermes uses nested `skills/gsd//` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module. +Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Claude-specific permission helpers: `mergeClaudePermissions(settings)` — non-destructively appends GSD-owned allow/deny entries (see `GSD_CLAUDE_ALLOW_PERMISSIONS`, `GSD_CLAUDE_DENY_PERMISSIONS` constants) to a Claude Code settings object; called from `finishInstall` for `runtime === 'claude'` only; uninstall removes exactly these entries (#768). Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Hermes uses nested `skills/gsd//` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module. ### Package Identity Module [Planned] Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is `package.json`; values are *derived*, not re-typed: `packageName` (`.name` → `@opengsd/get-shit-done-redux`), `binName` (`Object.keys(.bin)[0]` → `get-shit-done-redux`), `repoSlug` (parsed from `.repository.url` → `open-gsd/get-shit-done-redux`), plus derived `changelogRawUrl` and `manualInstallCommand({ scope, runtime })`. Generated `.cjs` per ADR-457 (generated-single-source); shipped under `gsd-core/bin/lib/`. Three consumer worlds: **Node** consumers `require()` it at runtime (worker, `check-latest-version.cjs`, `bin/install.js`); the **bash launcher** snippet receives the literal injected by `scripts/sync-runtime-launcher.cjs` at sync time; **prose/help** literals (`update.md`, installer help) carry a committed copy. A drift-guard lint (`scripts/lint-package-identity-drift.cjs`, sibling to `check:alias-drift`) fails CI on any raw package/repo literal outside `package.json`, the generated module, and the value-checked materialization sites — this is what keeps the seam real (`two adapters`, not one). Replaces the contradictory pair it consolidates: the runtime-broken `require('../package.json').name` in `hooks/gsd-check-update-worker.js` (#378, resolves to `undefined` post-install) and the hardcoded constant in `check-latest-version.cjs` (#2992). _Avoid_: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module. @@ -127,6 +127,9 @@ Module owning the explicit per-runtime config-mutation dispatch table for the in ### Claude Code Plugin Manifest Module Module owning the projection of gsd-core's artifact surfaces (`commands`, `agents`, hooks) onto the Claude Code plugin contract (`.claude-plugin/plugin.json` + `hooks/hooks.json`) — the plugin-contract sibling of the Runtime Artifact Layout Module (which projects the same surfaces onto filesystem placements). Defined mapping: `name`=`binName` (drives the `/gsd-core:` command namespace), `repository`/`homepage`=`repoUrl` (Package Identity Module), `version`/`description`/`license` from `package.json` (`version` is required for `claude plugin validate --strict`), `commands`=`./commands/gsd/`, agents via Claude Code's default `agents/` discovery (the explicit string form is schema-rejected), `hooks`=`./hooks/hooks.json`. The hook projection carries ONLY the always-on subset of the Installer Module's Claude `settings.json` wiring (check-update, context-monitor, prompt-guard, read-guard, worktree-path-guard, read-injection-scanner) via `${CLAUDE_PLUGIN_ROOT}`; config-gated opt-in hooks are excluded because a static manifest cannot honor per-project config gates, and plugin-shipped agents cannot carry hook frontmatter (so all plugin-path hook wiring lives in hooks.json). Additive — the file-copy path (Runtime Artifact Layout / Install Policy / Installer Modules) is unchanged. Conformance is validated by `claude plugin validate --strict` plus the in-repo drift-guard `tests/issue-766-plugin-manifest.test.cjs`. _Avoid_: "the plugin API", "the plugin file" (when you mean the seam). See ADR-766 and Runtime Artifact Layout Module. +### Gemini Extension Package +The repo-root `gemini-extension.json` + `GEMINI.md` pair that projects gsd-core onto the Gemini CLI extension contract, enabling one-step lifecycle management via `gemini extensions install ` / `update` / `remove` (and `gemini extensions link ` for dev). The Gemini-CLI sibling of the Claude Code Plugin Manifest Module — same additive idea, different runtime package format. Defined mapping: `name`=`binName` (`gsd-core`; lowercase-dashes per Gemini's extension naming rule), `version` tracks `package.json` (Gemini's `gemini extensions update` keys off the manifest `version` field), `description` (required by the manifest schema), `contextFileName`=`GEMINI.md` (the extension's context payload, loaded into every Gemini session). Intentionally minimal: no `mcpServers` (gsd-core ships no MCP server). Slash-command / agent / hook projection into the extension (which would require committing the Gemini-format TOML/agent conversions the Installer Module produces at `--gemini` install time) is deferred — the manual `npx gsd-core --gemini` path remains the way to install the `/gsd:*` commands, and is unchanged (additive, no breaking change). Conformance is guarded by the in-repo drift test `tests/issue-775-gemini-extension.test.cjs` (manifest validity, `version`↔`package.json` parity, `contextFileName` existence, `files[]` publication). _Avoid_: "the Gemini plugin" (Gemini calls them extensions, not plugins). See #775, ADR-766, Claude Code Plugin Manifest Module, and Runtime Artifact Layout Module. + ### Knowledge Graph Module Module owning the graphify integration: config gate (`isGraphifyEnabled`), disabled response (`disabledResponse`), subprocess helper (`execGraphify`, typed `GRAPHIFY_REASON` enum), presence detection (`checkGraphifyInstalled`), version checking (`checkGraphifyVersion`), query surface (`graphifyQuery` — BFS seed-expand + budget trim), status surface (`graphifyStatus` — node/edge counts, mtime staleness, commit-staleness tri-state via `built_at_commit`/`commits_behind`/`commit_stale`), diff surface (`graphifyDiff` — added/removed/changed nodes+edges), build pre-flight (`graphifyBuild`), snapshot management (`writeSnapshot`). Reads `.planning/config.json:graphify.enabled` as config gate; writes to `.planning/graphs/`. Auto-update hook (`hooks/gsd-graphify-update.sh`) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when `graphify.auto_update=true`. Status file `.planning/graphs/.last-build-status.json` carries `{ ts, status, exit_code, duration_ms, head_at_build, graphify_version }`. Graph IR uses `nodes[]`, `edges[]` (or `links[]` for graphify ≥0.7 compat), `hyperedges[]`, `built_at_commit`. `commit_stale` is tri-state: `false` (known fresh), `true` (stale), `null` (unknown — no git or pre-v0.7 graph). Source: `gsd-core/bin/lib/graphify.cjs`. Skill: `commands/gsd/graphify.md`. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 000000000..0d298557d --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,53 @@ +# GSD Core — Gemini CLI context + +This context is loaded by the **gsd-core Gemini CLI extension**. It gives Gemini +the operating context for [GSD Core](https://github.com/open-gsd/gsd-core), a +meta-prompting, context-engineering, and spec-driven development system for AI +coding agents. + +## What GSD is + +GSD turns a vague goal into shipped software through an explicit, +resumable workflow: **explore → plan → execute → verify → ship**. Work is +organised into milestones and phases under a `.planning/` directory, with each +phase carrying a SPEC, a PLAN, and verification criteria. The system favours +small, atomic, test-backed commits and keeps durable context in version-tracked +files rather than in the conversation. + +## The slash commands (installed separately) + +> **This extension ships only the context above — not the slash commands.** It +> loads gsd's operating context into your Gemini sessions and is managed through +> `gemini extensions list / update / uninstall`. To install the `/gsd:*` command +> set, agents, and hooks into `~/.gemini/`, run the dedicated installer: +> +> ```bash +> npx gsd-core --gemini --global +> ``` +> +> The two paths are complementary and the manual installer remains fully +> supported. The commands below are available only once that installer has run. + +If you have installed the gsd commands, the workflow is driven by these `/gsd:*` +slash commands (Gemini registers gsd's commands under the `gsd` namespace, so the +colon form is canonical): + +- `/gsd:new-project` — initialise a project and gather deep context. +- `/gsd:progress` — the unified situational command: check progress, advance the + workflow, or dispatch a freeform intent. +- `/gsd:plan-phase ` — produce a detailed phase plan with a verification loop. +- `/gsd:execute-phase ` — execute a phase's plans with wave-based parallelism. +- `/gsd:verify-work` — validate built features through conversational UAT. +- `/gsd:ship` — open a PR, run review, and prepare for merge. +- `/gsd:help` — list every available command. + +## Working with GSD + +- Treat `.planning/` as the source of truth for project state — read it before + acting, and keep it current as work progresses. +- Prefer the smallest change that satisfies the phase's verification criteria. +- Run the project's tests and linters before declaring a phase done. +- When unsure what to do next, and the gsd commands are installed, `/gsd:progress` + is the situational entry point. + +Learn more: diff --git a/agents/gsd-ai-researcher.md b/agents/gsd-ai-researcher.md index 341a5b153..3d1c58ea8 100644 --- a/agents/gsd-ai-researcher.md +++ b/agents/gsd-ai-researcher.md @@ -2,7 +2,7 @@ name: gsd-ai-researcher description: Researches a chosen AI framework's official docs to produce implementation-ready guidance — best practices, syntax, core patterns, and pitfalls distilled for the specific use case. Writes the Framework Quick Reference and Implementation Guidance sections of AI-SPEC.md. Spawned by /gsd:ai-integration-phase orchestrator. tools: Read, Write, Edit, Bash, Grep, Glob, WebFetch, WebSearch, mcp__context7__* -color: "#34D399" +color: green # hooks: # PostToolUse: # - matcher: "Write|Edit" diff --git a/agents/gsd-code-fixer.md b/agents/gsd-code-fixer.md index f7e04266b..841e8b435 100644 --- a/agents/gsd-code-fixer.md +++ b/agents/gsd-code-fixer.md @@ -2,7 +2,7 @@ name: gsd-code-fixer description: Applies fixes to code review findings from REVIEW.md. Reads source files, applies intelligent fixes, and commits each fix atomically. Spawned by /gsd:code-review --fix. tools: Read, Edit, Write, Bash, Grep, Glob -color: "#10B981" +color: green # hooks: # - before_write --- diff --git a/agents/gsd-code-reviewer.md b/agents/gsd-code-reviewer.md index 17a01abec..772c22aeb 100644 --- a/agents/gsd-code-reviewer.md +++ b/agents/gsd-code-reviewer.md @@ -2,7 +2,7 @@ name: gsd-code-reviewer description: Reviews source files for bugs, security issues, and code quality problems. Produces structured REVIEW.md with severity-classified findings. Spawned by /gsd:code-review. tools: Read, Write, Bash, Grep, Glob -color: "#F59E0B" +color: orange # hooks: # - before_write --- diff --git a/agents/gsd-domain-researcher.md b/agents/gsd-domain-researcher.md index 18ba806df..8b55f686d 100644 --- a/agents/gsd-domain-researcher.md +++ b/agents/gsd-domain-researcher.md @@ -2,7 +2,7 @@ name: gsd-domain-researcher description: Researches the business domain and real-world application context of the AI system being built. Surfaces domain expert evaluation criteria, industry-specific failure modes, regulatory context, and what "good" looks like for practitioners in this field — before the eval-planner turns it into measurable rubrics. Spawned by /gsd:ai-integration-phase orchestrator. tools: Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__* -color: "#A78BFA" +color: purple # hooks: # PostToolUse: # - matcher: "Write|Edit" diff --git a/agents/gsd-eval-auditor.md b/agents/gsd-eval-auditor.md index d23a6c838..d55503831 100644 --- a/agents/gsd-eval-auditor.md +++ b/agents/gsd-eval-auditor.md @@ -2,7 +2,7 @@ name: gsd-eval-auditor description: Retroactive audit of an implemented AI phase's evaluation coverage. Checks implementation against the AI-SPEC.md evaluation plan. Scores each eval dimension as COVERED/PARTIAL/MISSING. Produces a scored EVAL-REVIEW.md with findings, gaps, and remediation guidance. Spawned by /gsd:eval-review orchestrator. tools: Read, Write, Bash, Grep, Glob -color: "#EF4444" +color: red # hooks: # PostToolUse: # - matcher: "Write|Edit" diff --git a/agents/gsd-eval-planner.md b/agents/gsd-eval-planner.md index 25f61b4b6..5bad98c4a 100644 --- a/agents/gsd-eval-planner.md +++ b/agents/gsd-eval-planner.md @@ -2,7 +2,7 @@ name: gsd-eval-planner description: Designs a structured evaluation strategy for an AI phase. Identifies critical failure modes, selects eval dimensions with rubrics, recommends tooling, and specifies the reference dataset. Writes the Evaluation Strategy, Guardrails, and Production Monitoring sections of AI-SPEC.md. Spawned by /gsd:ai-integration-phase orchestrator. tools: Read, Write, Edit, Bash, Grep, Glob, AskUserQuestion -color: "#F59E0B" +color: orange # hooks: # PostToolUse: # - matcher: "Write|Edit" diff --git a/agents/gsd-framework-selector.md b/agents/gsd-framework-selector.md index c14a4fc02..b9c9d5d99 100644 --- a/agents/gsd-framework-selector.md +++ b/agents/gsd-framework-selector.md @@ -2,7 +2,7 @@ name: gsd-framework-selector description: Presents an interactive decision matrix to surface the right AI/LLM framework for the user's specific use case. Produces a scored recommendation with rationale. Spawned by /gsd:ai-integration-phase and /gsd-select-framework orchestrators. tools: Read, Bash, Grep, Glob, WebSearch, AskUserQuestion -color: "#38BDF8" +color: cyan --- diff --git a/agents/gsd-nyquist-auditor.md b/agents/gsd-nyquist-auditor.md index 86d5352a3..35279b10b 100644 --- a/agents/gsd-nyquist-auditor.md +++ b/agents/gsd-nyquist-auditor.md @@ -8,7 +8,7 @@ tools: - Bash - Glob - Grep -color: "#8B5CF6" +color: purple --- diff --git a/agents/gsd-pattern-mapper.md b/agents/gsd-pattern-mapper.md index 9b83144f2..c07288049 100644 --- a/agents/gsd-pattern-mapper.md +++ b/agents/gsd-pattern-mapper.md @@ -2,7 +2,7 @@ name: gsd-pattern-mapper description: Analyzes codebase for existing patterns and produces PATTERNS.md mapping new files to closest analogs. Read-only codebase analysis spawned by /gsd:plan-phase orchestrator before planning. tools: Read, Bash, Glob, Grep, Write -color: magenta +color: purple # hooks: # PostToolUse: # - matcher: "Write|Edit" diff --git a/agents/gsd-security-auditor.md b/agents/gsd-security-auditor.md index 31847360f..209ce4683 100644 --- a/agents/gsd-security-auditor.md +++ b/agents/gsd-security-auditor.md @@ -8,7 +8,7 @@ tools: - Bash - Glob - Grep -color: "#EF4444" +color: red --- diff --git a/agents/gsd-ui-auditor.md b/agents/gsd-ui-auditor.md index 2fd1b1153..3ed6cc9e6 100644 --- a/agents/gsd-ui-auditor.md +++ b/agents/gsd-ui-auditor.md @@ -2,7 +2,7 @@ name: gsd-ui-auditor description: Retroactive 6-pillar visual audit of implemented frontend code. Produces scored UI-REVIEW.md. Spawned by /gsd:ui-review orchestrator. tools: Read, Write, Bash, Grep, Glob -color: "#F472B6" +color: pink # hooks: # PostToolUse: # - matcher: "Write|Edit" diff --git a/agents/gsd-ui-checker.md b/agents/gsd-ui-checker.md index 2ed509e78..adb0e67a3 100644 --- a/agents/gsd-ui-checker.md +++ b/agents/gsd-ui-checker.md @@ -2,7 +2,7 @@ name: gsd-ui-checker description: Validates UI-SPEC.md design contracts against 6 quality dimensions. Produces BLOCK/FLAG/PASS verdicts. Spawned by /gsd:ui-phase orchestrator. tools: Read, Bash, Glob, Grep -color: "#22D3EE" +color: cyan --- diff --git a/agents/gsd-ui-researcher.md b/agents/gsd-ui-researcher.md index 3d6ae384d..63a7d6f84 100644 --- a/agents/gsd-ui-researcher.md +++ b/agents/gsd-ui-researcher.md @@ -2,7 +2,7 @@ name: gsd-ui-researcher description: Produces UI-SPEC.md design contract for frontend phases. Reads upstream artifacts, detects design system state, asks only unanswered questions. Spawned by /gsd:ui-phase orchestrator. tools: Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__*, mcp__tavily__*, mcp__ref__*, mcp__jina__* -color: "#E879F9" +color: purple # hooks: # PostToolUse: # - matcher: "Write|Edit" diff --git a/agents/gsd-user-profiler.md b/agents/gsd-user-profiler.md index 6e427fe94..82188804f 100644 --- a/agents/gsd-user-profiler.md +++ b/agents/gsd-user-profiler.md @@ -2,7 +2,7 @@ name: gsd-user-profiler description: Analyzes extracted session messages across 8 behavioral dimensions to produce a scored developer profile with confidence levels and evidence. Spawned by profile orchestration workflows. tools: Read -color: magenta +color: purple --- diff --git a/bin/install.js b/bin/install.js index d95c00812..442e07b2c 100755 --- a/bin/install.js +++ b/bin/install.js @@ -98,6 +98,69 @@ function isCodexHooksFeatureKey(key) { return CODEX_HOOKS_FEATURE_ALL_KEYS.includes(key); } +// #768 \u2014 Claude Code permissions.allow / permissions.deny entries. +// Pre-populated during Claude installs to eliminate first-run approval friction +// for gsd-core's own known-safe tool calls, and to add defense-in-depth deny +// entries for common credential files. +// +// Format: each string uses Claude Code's documented permission rule syntax \u2014 +// "Tool(pattern)" e.g. "Bash(npx gsd-core *)", "Read(.planning/*)" +// "Tool" (bare tool name, no pattern) +// +// Merge policy: additive, non-destructive \u2014 existing user entries are preserved; +// GSD entries are appended only when not already present (idempotent). +const GSD_CLAUDE_ALLOW_PERMISSIONS = Object.freeze([ + 'Bash(npx gsd-core *)', + 'Read(.planning/*)', + 'Write(.planning/*)', + 'Read(STATE.md)', + 'Write(STATE.md)', +]); +const GSD_CLAUDE_DENY_PERMISSIONS = Object.freeze([ + 'Read(.env)', + 'Read(.env.*)', + 'Read(.secrets)', +]); + +/** + * Merge GSD-owned permission entries into a Claude Code settings object. + * + * Additive and idempotent: existing allow/deny entries are preserved; GSD + * entries are appended only if not already present. No other permission sub-keys + * (ask, disableBypassPermissionsMode, etc.) are touched. + * + * Defensive: if settings is not a plain object, returns immediately without + * throwing. If permissions.allow / permissions.deny exist but are not arrays + * (malformed settings), they are replaced with valid arrays. + * + * @param {object} settings - The parsed settings.json object to mutate in-place. + */ +function mergeClaudePermissions(settings) { + if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) return; + + if (!settings.permissions || typeof settings.permissions !== 'object' || Array.isArray(settings.permissions)) { + settings.permissions = {}; + } + + if (!Array.isArray(settings.permissions.allow)) { + settings.permissions.allow = []; + } + if (!Array.isArray(settings.permissions.deny)) { + settings.permissions.deny = []; + } + + for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) { + if (!settings.permissions.allow.includes(entry)) { + settings.permissions.allow.push(entry); + } + } + for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) { + if (!settings.permissions.deny.includes(entry)) { + settings.permissions.deny.push(entry); + } + } +} + // Copilot instructions marker constants const GSD_COPILOT_INSTRUCTIONS_MARKER = ''; const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = ''; @@ -801,9 +864,31 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) { return { content: updated, changed }; } -function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) { +/** + * Generic reconcile helper: ensure hooks.json contains exactly one managed GSD + * hook entry for `eventName`, while preserving all user-owned entries. + * + * Supports both known hooks.json shapes: + * 1) { "": [...] } + * 2) { "hooks": { "": [...] } } + * + * @param {string} targetDir - Codex config dir (e.g. ~/.codex or /.codex). + * @param {string} eventName - Codex hook event name (e.g. 'SessionStart', 'Stop'). + * @param {{ managedCommand?: string|null, commandWindows?: string|null, matcher?: string|null, timeout?: number|null }} opts + * managedCommand: POSIX hook command string to register, or null to remove. + * commandWindows: Windows .cmd shim path to emit as `commandWindows` field + * (#772). When provided, Codex uses this path on Windows and `managedCommand` + * on POSIX without needing per-platform config regeneration. + * matcher: optional Codex MatcherGroup pattern (e.g. 'Bash|Edit|Write'). + * timeout: optional timeout in seconds. + * @returns {{ changed: boolean, wrote: boolean, path: string }} + */ +function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) { const hooksJsonPath = path.join(targetDir, 'hooks.json'); const managedCommand = typeof opts.managedCommand === 'string' ? opts.managedCommand : null; + const commandWindows = typeof opts.commandWindows === 'string' ? opts.commandWindows : null; + const matcher = typeof opts.matcher === 'string' ? opts.matcher : undefined; + const timeout = typeof opts.timeout === 'number' ? opts.timeout : undefined; let parsed = {}; let currentContent = null; if (fs.existsSync(hooksJsonPath)) { @@ -822,22 +907,22 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) { const usesNestedHooksObject = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks); const hookTable = usesNestedHooksObject ? parsed.hooks : parsed; - const sessionStart = Array.isArray(hookTable.SessionStart) ? hookTable.SessionStart : []; + const eventEntries = Array.isArray(hookTable[eventName]) ? hookTable[eventName] : []; let removedLegacy = false; - const sanitizedSessionStart = []; - for (const entry of sessionStart) { + const sanitizedEntries = []; + for (const entry of eventEntries) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; const originalHooks = Array.isArray(entry.hooks) ? entry.hooks : []; if (originalHooks.length === 0) { - sanitizedSessionStart.push(entry); + sanitizedEntries.push(entry); continue; } - const keptHooks = originalHooks.filter((hook) => { - const cmd = hook && typeof hook === 'object' ? hook.command : null; - const managed = isManagedHookCommand(cmd, { - surface: 'codex-hooks-json', - includeLegacyAliases: true, + const keptHooks = originalHooks.filter((hook) => { + const cmd = hook && typeof hook === 'object' ? hook.command : null; + const managed = isManagedHookCommand(cmd, { + surface: 'codex-hooks-json', + includeLegacyAliases: true, configDir: targetDir, }); if (managed) removedLegacy = true; @@ -845,24 +930,26 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) { }); if (keptHooks.length === 0) continue; const nextEntry = { ...entry, hooks: keptHooks }; - sanitizedSessionStart.push(nextEntry); + sanitizedEntries.push(nextEntry); } if (managedCommand) { - sanitizedSessionStart.push({ - hooks: [ - { - type: 'command', - command: managedCommand, - }, - ], - }); + const hookEntry = { type: 'command', command: managedCommand }; + // #772: emit commandWindows so Codex picks the .cmd shim on Windows and + // the POSIX command on other platforms — without requiring per-OS config + // regeneration. Sourced from HookHandlerConfig.command_windows field in + // codex-rs/config/src/hook_config.rs (alias: commandWindows). + if (commandWindows) hookEntry.commandWindows = commandWindows; + if (timeout !== undefined) hookEntry.timeout = timeout; + const newEntry = { hooks: [hookEntry] }; + if (matcher !== undefined) newEntry.matcher = matcher; + sanitizedEntries.push(newEntry); } - if (sanitizedSessionStart.length > 0) { - hookTable.SessionStart = sanitizedSessionStart; + if (sanitizedEntries.length > 0) { + hookTable[eventName] = sanitizedEntries; } else { - delete hookTable.SessionStart; + delete hookTable[eventName]; } if (usesNestedHooksObject) parsed.hooks = hookTable; @@ -876,6 +963,18 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) { return { changed: changed || removedLegacy, wrote: shouldWrite, path: hooksJsonPath }; } +/** + * Reconcile the GSD-managed SessionStart hook entry in hooks.json. + * Delegates to the generic reconcileCodexHooksJsonEvent helper. + * + * @param {string} targetDir + * @param {{ managedCommand?: string|null, commandWindows?: string|null }} opts + * @returns {{ changed: boolean, wrote: boolean, path: string }} + */ +function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) { + return reconcileCodexHooksJsonEvent(targetDir, 'SessionStart', opts); +} + /** * Build a typed IR for the Codex hook .cmd shim used on Windows (#3426). * @@ -957,8 +1056,14 @@ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) { * 2) { "hooks": { "SessionStart": [...] } } * * On Windows, writes a .cmd shim alongside the .js hook file and uses the - * .cmd path as the hook command to avoid the `bash.exe: cannot execute binary - * file` failure (#3426). + * .cmd shim path as the hook command to avoid the `bash.exe: cannot execute + * binary file` failure (#3426). + * + * #772: also emits `commandWindows` in the hook entry so that a + * cross-platform hooks.json works on both POSIX and Windows without + * requiring per-OS regeneration. Codex dispatches `commandWindows` on + * Windows and `command` on other platforms (HookHandlerConfig in + * codex-rs/config/src/hook_config.rs). * * @param {string} targetDir * @param {{ absoluteRunner: string|null, platform?: NodeJS.Platform }} opts @@ -970,7 +1075,18 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) { const hooksJsonPath = path.join(targetDir, 'hooks.json'); if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath }; - const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-check-update.js'); + // Normalize backslashes to forward slashes so isManagedHookCommand can + // match stored commands against configDir on Windows CI runners where + // path.resolve returns backslash paths but the stored command may use + // forward slashes (or vice versa). Forward-slash paths are always valid on + // Windows for both Node.js and Codex, so this normalization is safe for all + // platforms. (#772 — same fix applied to ensureCodexHooksJsonEvent.) + const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); + + // #772: compute the Windows .cmd shim path cross-platform so that + // `commandWindows` can be emitted in hooks.json regardless of the host OS. + // The .cmd path is always the .js script path with extension replaced. + const cmdShimPath = scriptPath.replace(/\.js$/, '.cmd'); let managedCommand; if (platform === 'win32') { @@ -1007,7 +1123,96 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) { } if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath }; - return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand }); + + // #772: emit commandWindows — the .cmd shim path — but ONLY on Windows where + // the shim was actually written. On POSIX, commandWindows is omitted to avoid + // pointing Windows Codex at a non-existent .cmd file (the shim is only present + // when install() ran natively on Windows and wrote it via buildCodexHookWindowsShimIR). + const commandWindows = platform === 'win32' + ? JSON.stringify(cmdShimPath.replace(/\\/g, '/')) + : undefined; + + return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand, commandWindows }); +} + +/** + * Ensure hooks.json contains exactly one managed GSD hook entry for the given + * Codex event, wired to gsd-context-monitor.js. Preserves user-owned entries. + * + * Used for the new Codex events added in #772: + * SubagentStart — inject context / GSD_AGENT_NAME awareness at subagent open + * Stop — post-session context headroom tracking + * PostToolUse — mirror the Claude Code PostToolUse context monitor + * + * All three events are routed through gsd-context-monitor.js — the same hook + * used for PostToolUse in the Claude Code baseline — so context-headroom + * warnings surface at these key Codex session lifecycle moments. + * + * On Windows (#3426): writes a gsd-context-monitor.cmd shim alongside the .js + * file and uses the .cmd path as the hook command — exactly the same fix as + * SessionStart uses for gsd-check-update — to avoid the bash.exe POSIX-exec + * failure when Codex's hook dispatcher tries to run node.exe through Git Bash. + * + * @param {string} targetDir + * @param {string} eventName - One of 'SubagentStart', 'Stop', 'PostToolUse'. + * @param {{ absoluteRunner: string|null, platform?: NodeJS.Platform }} opts + * @returns {{ changed: boolean, wrote: boolean, path: string }} + */ +function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) { + const platform = opts.platform || process.platform; + const absoluteRunner = opts.absoluteRunner || null; + const hooksJsonPath = path.join(targetDir, 'hooks.json'); + if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath }; + + // Normalize backslashes to forward slashes so that isManagedHookCommand can + // match the stored command against configDir on Windows. path.resolve on + // Windows returns backslash paths, but when platform is not 'win32' + // (e.g. platform: 'linux' in a test running on a Windows CI runner), + // projectManagedHookCommand does not normalize them — producing a mismatch + // between the stored command and the configDir-based hook-dir prefix used + // for deduplication. Forward-slash paths are always valid on Windows (Node.js + // and Codex both accept them), so normalizing here is safe for all platforms. + const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-context-monitor.js').replace(/\\/g, '/'); + + let managedCommand; + if (platform === 'win32') { + // #3426 fix pattern: on Windows, write a .cmd shim and use its path as the + // hook command. The same bash.exe POSIX-exec failure that affects + // gsd-check-update.js also affects gsd-context-monitor.js. + const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner); + if (!shimIR) return { changed: false, wrote: false, path: hooksJsonPath }; + try { + atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8'); + } catch (shimWriteErr) { + const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr); + console.warn( + ` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed for ${eventName}: ${reason}. ` + + `Fix the write error (permissions? disk full?) and re-run the installer.`, + ); + return { changed: false, wrote: false, path: hooksJsonPath }; + } + managedCommand = shimIR.hookCommand; + } else { + managedCommand = projectManagedHookCommand({ + absoluteRunner, + scriptPath, + runtime: 'codex', + platform, + }); + } + + if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath }; + return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand, timeout: 10 }); +} + +/** + * Remove a GSD-managed event entry from hooks.json. Called during uninstall. + * + * @param {string} targetDir + * @param {string} eventName + */ +function removeCodexHooksJsonEvent(targetDir, eventName) { + return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand: null }); } function removeCodexHooksJsonSessionStart(targetDir) { @@ -1989,6 +2194,40 @@ function skillFrontmatterName(skillDirName) { return skillDirName; } +/** + * Qwen Code skills accept an optional numeric `priority` frontmatter field. + * Per the Qwen skills spec (qwen-code/docs/users/features/skills.md, verified + * #778): HIGHER values sort EARLIER in the `/skills` TUI listing (omitted ≈ 0; + * negatives sort below unset). It affects ONLY the `/skills` list order — + * slash-command completion and the `/help` view stay alphabetical. + * + * We assign descending priorities to GSD's main-loop commands so the most-used + * workflow skills surface first; utility skills are deliberately left unset + * (default 0) and sort below. + * + * NOTE: the #778 issue body proposed the INVERSE numbering (plan-phase: 10, + * utilities: 90+). The verified spec shows that would BURY the core loop below + * utilities, so we implement the spec-correct direction (core = high) instead. + * Keyed by command stem (skill dir is `gsd-`). + */ +const QWEN_SKILL_PRIORITY = Object.freeze({ + 'new-project': 100, + 'discuss-phase': 95, + 'plan-phase': 90, + 'execute-phase': 85, + progress: 80, + 'verify-work': 75, + phase: 70, + review: 65, + ship: 60, + config: 55, + surface: 50, + 'resume-work': 45, + 'pause-work': 40, + help: 35, + update: 30, +}); + /** * Convert a Claude command (.md) to a Claude skill (SKILL.md). * Claude Code is the native format, so minimal conversion needed — @@ -2011,6 +2250,10 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c const description = extractFrontmatterField(frontmatter, 'description') || ''; const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint'); const agent = extractFrontmatterField(frontmatter, 'agent'); + // #769: preserve context: and effort: from source command files so they + // are emitted into the installed SKILL.md frontmatter unchanged. + const context = extractFrontmatterField(frontmatter, 'context'); + const effort = extractFrontmatterField(frontmatter, 'effort'); // Preserve allowed-tools as YAML multiline list (Claude native format) const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m); @@ -2028,8 +2271,26 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c // Track GSD's package version so Hermes' skill_view() reports a stable // identifier per install. if (runtime === 'hermes') fm += `version: ${yamlQuote(pkg.version)}\n`; + // #778 (b) — Qwen-only numeric priority for /skills ordering. Scoped to qwen + // so Claude/Hermes skill frontmatter is unchanged (they ignore the field, but + // we keep their output byte-stable). skillName is the `gsd-` dir name. + if (runtime === 'qwen') { + const stem = typeof skillName === 'string' && skillName.startsWith('gsd-') + ? skillName.slice(4) + : skillName; + const priority = Object.prototype.hasOwnProperty.call(QWEN_SKILL_PRIORITY, stem) + ? QWEN_SKILL_PRIORITY[stem] + : undefined; + if (typeof priority === 'number') fm += `priority: ${priority}\n`; + } if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`; if (agent) fm += `agent: ${agent}\n`; + // #769: emit context: and effort: when present so the runtime can honour + // them natively (context: fork = isolated subagent window; effort: = + // token-budget tier). Fields are Claude-specific; unknown frontmatter + // fields are silently ignored by other runtimes (backward-compatible). + if (context) fm += `context: ${context}\n`; + if (effort) fm += `effort: ${effort}\n`; if (toolsBlock) fm += toolsBlock; fm += '---'; @@ -2949,7 +3210,47 @@ function convertClaudeCommandToCodebuddySkill(content, skillName) { const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description; // #2876: quote so YAML flow indicators (`[BETA] …`) don't break // CodeBuddy's frontmatter parser. - return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n${body}`; + // + // #789: mark user-invocable:false so the skill is NOT shown in CodeBuddy's + // '/' menu (it defaults to true). The commands/ surface (#789) is the sole + // '/' entry point; skills remain model-invocable background knowledge, + // avoiding a duplicated /gsd-* entry per workflow. + return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\nuser-invocable: false\n---\n${body}`; +} + +/** + * Convert a Claude Code slash-command (.md) to a CodeBuddy slash-command (.md). + * + * CodeBuddy reads user-level slash commands from ~/.codebuddy/commands/.md + * (https://www.codebuddy.ai/docs/cli/slash-commands). The filename determines the + * command name (gsd-help.md → /gsd-help), so the Claude-specific `name: gsd:` + * frontmatter field is dropped. CodeBuddy command frontmatter supports + * `description` and `argument-hint`; both are preserved when present. The body is + * brand/path-converted via convertClaudeToCodebuddyMarkdown. + * + * @param {string} content raw Claude command markdown + * @param {string} commandName installed command name (e.g. 'gsd-help') + * @returns {string} + */ +function convertClaudeCommandToCodebuddyCommand(content, commandName) { + const converted = convertClaudeToCodebuddyMarkdown(content); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + let description = `Run GSD workflow ${commandName}.`; + let argumentHint = ''; + if (frontmatter) { + const maybeDescription = extractFrontmatterField(frontmatter, 'description'); + if (maybeDescription) description = maybeDescription; + const maybeArgHint = extractFrontmatterField(frontmatter, 'argument-hint'); + if (maybeArgHint) argumentHint = maybeArgHint; + } + description = toSingleLine(description); + const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description; + // #2876: quote values so YAML flow indicators (`[BETA] …`, `[name]`) don't + // break CodeBuddy's frontmatter parser. + const lines = ['---', `description: ${yamlQuote(shortDescription)}`]; + if (argumentHint) lines.push(`argument-hint: ${yamlQuote(toSingleLine(argumentHint))}`); + lines.push('---', body.trimStart()); + return lines.join('\n'); } function convertClaudeAgentToCodebuddyAgent(content) { @@ -3256,6 +3557,20 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null, const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex).value; lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`); + // #774 — Emit service_tier and model_verbosity for light-tier agents. + // Light-tier agents (routingTier: "light" in model-catalog.json) are haiku-equivalent + // and benefit from Codex's "flex" service tier (lower cost, background processing) + // and "low" verbosity (reduced token output). Both fields are validated against the + // Codex ConfigProfile schema (codex-rs/config/src/profile_toml.rs): + // service_tier: Option — "flex" | "fast" (legacy) + // model_verbosity: Option — "low" | "medium" | "high" + const { AGENT_DEFAULT_TIERS: _agentTiers } = _getGsdEffortCatalog(); + const _agentRoutingTier = _agentTiers?.[resolvedName] || _agentTiers?.[agentName]; + if (_agentRoutingTier === 'light') { + lines.push(`service_tier = "flex"`); + lines.push(`model_verbosity = "low"`); + } + // Agent prompts contain raw backslashes in regexes and shell snippets. // TOML literal multiline strings preserve them without escape parsing. lines.push(`developer_instructions = '''`); @@ -3265,6 +3580,115 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null, return lines.join('\n') + '\n'; } +/** + * Generate the agents/openai.yaml TUI chip metadata content for a Codex skill. + * + * This file is written alongside SKILL.md as /agents/openai.yaml. + * Codex loads it as a SkillMetadataFile (codex-rs/core-skills/src/loader.rs), + * making the skill discoverable in the /skills TUI popup with a display name + * and short description. If the file is absent, Codex silently skips it (fails open). + * + * Schema (interface section): + * display_name: short human-readable skill name (strip gsd- prefix) + * short_description: 1-2 sentence description for TUI chip, ≤180 chars + * + * @param {string} skillName - Full skill name e.g. "gsd-plan-phase" + * @param {string} shortDescription - Description text (already truncated by caller) + * @returns {string} YAML content for agents/openai.yaml + */ +function generateCodexSkillMetadataYaml(skillName, shortDescription) { + // Display name: strip "gsd-" prefix and convert hyphens to spaces for readability. + const displayName = skillName.replace(/^gsd-/, '').replace(/-/g, ' '); + // yamlQuote (= JSON.stringify) handles all YAML-unsafe chars: backslashes, + // quotes, newlines, control characters, and Unicode escapes. + return [ + 'interface:', + ` display_name: ${yamlQuote(displayName)}`, + ` short_description: ${yamlQuote(shortDescription)}`, + '', + ].join('\n'); +} + +/** + * Write agents/openai.yaml TUI chip metadata for each gsd-* skill directory. + * + * Called after layout-driven skill install for Codex. Iterates every gsd-* + * skill directory in skillsDir, reads the SKILL.md frontmatter to extract the + * short-description already emitted by convertClaudeCommandToCodexSkill, then + * writes /agents/openai.yaml using generateCodexSkillMetadataYaml. + * + * Fails open: individual skill directories that cannot be processed are silently + * skipped so a single malformed SKILL.md cannot block the whole install. + * + * User-owned skill directories (e.g. gsd-dev-preferences) are explicitly + * skipped so existing user-authored agents/openai.yaml files are never + * overwritten. These dirs are listed in the same USER_OWNED_SKILL_DIRS + * constant used by installOpencodeFamilySkills. + * + * The YAML-quoted description value is unescaped before embedding so that + * YAML escape sequences (e.g. \" in a double-quoted scalar) become the + * literal characters they represent rather than being double-escaped in the + * output. + * + * @param {string} skillsDir - Path to the skills/ directory (e.g. ~/.codex/skills) + */ +function writeCodexSkillMetadataFiles(skillsDir) { + if (!fs.existsSync(skillsDir)) return; + // Mirror the user-owned list from installOpencodeFamilySkills (#2973). + // We MUST skip these dirs — their contents are user-generated and must + // never be overwritten by GSD's install path. + const _userOwnedSkillDirs = new Set(['gsd-dev-preferences']); + for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('gsd-')) continue; + if (_userOwnedSkillDirs.has(entry.name)) continue; // preserve user content + const skillDir = path.join(skillsDir, entry.name); + const skillMdPath = path.join(skillDir, 'SKILL.md'); + try { + const content = fs.readFileSync(skillMdPath, 'utf8'); + const { frontmatter } = extractFrontmatterAndBody(content); + // Prefer the short-description field emitted by convertClaudeCommandToCodexSkill; + // fall back to description, then a synthetic label from the skill name. + let shortDesc = ''; + if (frontmatter) { + // SKILL.md uses YAML frontmatter with a nested metadata.short-description key. + // extractFrontmatterField handles only top-level keys; parse the metadata block + // by looking for " short-description:" directly. + const metaMatch = frontmatter.match(/^[ \t]*metadata\s*:\s*\n((?:[ \t]+.*\n?)*)/m); + if (metaMatch) { + const metaBlock = metaMatch[1]; + const sdMatch = metaBlock.match(/^[ \t]+short-description\s*:\s*(.+)$/m); + if (sdMatch) { + // Unescape YAML double-quoted scalar escapes before embedding. + // convertClaudeCommandToCodexSkill always emits a double-quoted + // value (via yamlQuote) so only double-quote unescaping is needed. + let raw = sdMatch[1].trim(); + if (raw.startsWith('"') && raw.endsWith('"')) { + // Strip outer double-quotes and decode \" → " and \\ → \ + raw = raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + } else { + // Single-quoted or unquoted: strip surrounding quotes/whitespace + raw = raw.replace(/^["']|["']$/g, ''); + } + shortDesc = raw; + } + } + if (!shortDesc) { + shortDesc = extractFrontmatterField(frontmatter, 'description') || ''; + } + } + if (!shortDesc) { + shortDesc = `Run GSD workflow ${entry.name}.`; + } + const yamlContent = generateCodexSkillMetadataYaml(entry.name, shortDesc); + const agentsSubdir = path.join(skillDir, 'agents'); + fs.mkdirSync(agentsSubdir, { recursive: true }); + fs.writeFileSync(path.join(agentsSubdir, 'openai.yaml'), yamlContent); + } catch (_err) { + // Fail open — missing or unreadable SKILL.md must not block the install. + } + } +} + /** * Generate the GSD config block for Codex config.toml. * @param {Array<{name: string, description: string}>} agents @@ -5904,7 +6328,7 @@ function convertSlashCommandsToGeminiMentions(content) { }); } -function convertClaudeToGeminiMarkdown(content, { isCommand = false } = {}) { +function convertClaudeToGeminiMarkdown(content, { isCommand = false, commandName = null } = {}) { // Apply Gemini-specific slash command namespacing let converted = convertSlashCommandsToGeminiMentions(content); // Gemini CLI does not expose Claude's AskUserQuestion tool. Convert body @@ -5916,8 +6340,9 @@ function convertClaudeToGeminiMarkdown(content, { isCommand = false } = {}) { converted = stripSubTags(converted); if (isCommand) { - // Convert to Gemini TOML format - converted = convertClaudeToGeminiToml(converted); + // Convert to Gemini TOML format (threads the command name so per-command + // enrichment — e.g. the #778 live-state injection — can target a command). + converted = convertClaudeToGeminiToml(converted, { commandName }); } return converted; @@ -6421,7 +6846,16 @@ function convertClaudeCommandToKiloSkill(content, skillName) { * @param {string} content - Markdown file content with YAML frontmatter * @returns {string} - TOML content */ -function convertClaudeToGeminiToml(content) { +function convertClaudeToGeminiToml(content, { commandName = null } = {}) { + // #778 (c) — Gemini {{args}} interpolation. Claude's $ARGUMENTS placeholder + // maps to Gemini's {{args}} so inline argument references interpolate into the + // command body instead of being emitted as a dead literal. Applied before + // frontmatter parsing so every return path benefits (a command's frontmatter + // never contains $ARGUMENTS, so this is body-only in practice). Gemini injects + // {{args}} as typed outside shell blocks; we never place it inside a !{...} + // block, so there is no shell-escaping/injection interaction. + content = content.replace(/\$ARGUMENTS\b/g, '{{args}}'); + // Check if content has frontmatter if (!content.startsWith('---')) { return `prompt = ${JSON.stringify(content)}\n`; @@ -6433,7 +6867,27 @@ function convertClaudeToGeminiToml(content) { } const frontmatter = content.substring(3, endIndex).trim(); - const body = content.substring(endIndex + 3).trim(); + let body = content.substring(endIndex + 3).trim(); + + // #778 (c) — Gemini !{...} dynamic-output injection for the situational + // `progress` command (GSD's status/dashboard surface). Inject the live + // .planning/STATE.md so the model sees current project state without relying + // on session memory. + // + // SECURITY: the shell command is a FIXED `cat` with NO interpolated user + // input — no {{args}} appears inside the block — so there is no + // shell-injection vector. Gemini still shows its standard per-invocation + // confirmation dialog (verified behavior). `2>/dev/null` keeps an + // uninitialized project (missing STATE.md) from injecting stderr noise. + // Braces inside the block are balanced (none present), per Gemini's parser + // requirement. The append happens AFTER the {{args}} mapping above so the + // injected block can never accidentally carry interpolated arguments. + if (commandName === 'progress') { + body += '\n\n## Live project state\n' + + 'Current contents of `.planning/STATE.md` ' + + '(empty if the project is not yet initialized):\n\n' + + '!{cat .planning/STATE.md 2>/dev/null}\n'; + } // Extract description from frontmatter let description = ''; @@ -6887,7 +7341,14 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) { content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix); content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix); content = content.replace(/\.\/\.claude\b/g, `./${dirName}`); + // The codebuddy converter rewrites `.claude/` → `.codebuddy/` at stage + // time, so `$HOME/.claude/...` arrives here as `$HOME/.codebuddy/...`. + // Normalize BOTH the `~/` and `$HOME/` forms (slash + bare) to the install + // target so `--config-dir`/local installs don't leak the default home. content = content.replace(/~\/\.codebuddy\//g, pathPrefix); + content = content.replace(/\$HOME\/\.codebuddy\//g, pathPrefix); + content = content.replace(/~\/\.codebuddy\b/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.codebuddy\b/g, normalizedPathPrefix); content = processAttribution(content, getCommitAttribution(runtime)); break; @@ -7494,8 +7955,11 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand : convertClaudeToOpencodeFrontmatter(content); fs.writeFileSync(destPath, content); } else if (isGemini) { - // Apply Gemini-specific Markdown transformations (slash commands, TOML) - const processed = convertClaudeToGeminiMarkdown(content, { isCommand }); + // Apply Gemini-specific Markdown transformations (slash commands, TOML). + // #778: thread the command name (file stem) so per-command TOML + // enrichment (live-state injection) can target a specific command. + const geminiCommandName = isCommand ? entry.name.replace(/\.md$/, '') : null; + const processed = convertClaudeToGeminiMarkdown(content, { isCommand, commandName: geminiCommandName }); const finalPath = isCommand ? destPath.replace(/\.md$/, '.toml') : destPath; fs.writeFileSync(finalPath, processed); } else if (isCodex) { @@ -7882,6 +8346,15 @@ function uninstall(isGlobal, runtime = 'claude') { removedCount++; console.log(` ${green}✓${reset} Removed managed Codex SessionStart hook from hooks.json`); } + + // #772: remove new Codex hook event registrations added by this enhancement. + for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { + const eventCleanup = removeCodexHooksJsonEvent(targetDir, eventName); + if (eventCleanup.changed) { + removedCount++; + console.log(` ${green}✓${reset} Removed managed Codex ${eventName} hook from hooks.json`); + } + } } // 1b. Non-layout Copilot side-effect: copilot-instructions.md cleanup @@ -8203,6 +8676,37 @@ function uninstall(isGlobal, runtime = 'claude') { delete settings.hooks; } + // #768 — Remove GSD-owned Claude permissions from settings.json. + // Applies only to Claude uninstalls. Filter only the exact GSD-owned entries + // to preserve any user-added allow/deny entries. + // Uses a local flag to avoid the shared `settingsModified` producing a false + // "Removed GSD permissions" message when only hooks/statusline changed. + if (runtime === 'claude' && settings.permissions) { + let permissionsModified = false; + if (Array.isArray(settings.permissions.allow)) { + const before = settings.permissions.allow.length; + settings.permissions.allow = settings.permissions.allow.filter( + (e) => !GSD_CLAUDE_ALLOW_PERMISSIONS.includes(e) + ); + if (settings.permissions.allow.length !== before) { + permissionsModified = true; + } + } + if (Array.isArray(settings.permissions.deny)) { + const before = settings.permissions.deny.length; + settings.permissions.deny = settings.permissions.deny.filter( + (e) => !GSD_CLAUDE_DENY_PERMISSIONS.includes(e) + ); + if (settings.permissions.deny.length !== before) { + permissionsModified = true; + } + } + if (permissionsModified) { + settingsModified = true; + console.log(` ${green}✓${reset} Removed GSD permissions from settings.json`); + } + } + if (settingsModified) { writeSettings(settingsPath, settings); removedCount++; @@ -9567,6 +10071,16 @@ function install(isGlobal, runtime = 'claude', options = {}) { const scope = isGlobal ? 'global' : 'local'; installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile); + // #774 — Codex only: write agents/openai.yaml TUI chip metadata alongside each + // installed skill so the /skills popup shows name + description for each gsd-* skill. + // The SkillMetadataFile is loaded by codex-rs/core-skills/src/loader.rs from + // /agents/openai.yaml; absence is silently tolerated (fails open). + // We parse the SKILL.md frontmatter to extract short-description already emitted + // by convertClaudeCommandToCodexSkill and use it as the TUI chip description. + if (isCodex) { + writeCodexSkillMetadataFiles(path.join(targetDir, 'skills')); + } + // Hermes only: write DESCRIPTION.md for the gsd/ category after layout install if (isHermes) { writeHermesCategoryDescription(path.join(targetDir, 'skills', 'gsd')); @@ -9651,6 +10165,22 @@ function install(isGlobal, runtime = 'claude', options = {}) { failures.push('commands/gsd-*'); } } + + // CodeBuddy only: also report the commands/ output (#789 — slash commands) + if (isCodebuddy) { + const commandsDir = path.join(targetDir, 'commands'); + if (fs.existsSync(commandsDir)) { + const cmdCount = fs.readdirSync(commandsDir) + .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; + if (cmdCount > 0) { + console.log(` ${green}✓${reset} Installed ${cmdCount} slash commands to commands/`); + } else { + failures.push('commands/gsd-*'); + } + } else { + failures.push('commands/gsd-*'); + } + } } } else if (isOpencode || isKilo) { // OpenCode/Kilo: flat structure in command/ directory @@ -10351,10 +10881,10 @@ function install(isGlobal, runtime = 'claude', options = {}) { } // Copy only the hook files that Codex actually registers via its hook configuration (#2153). - // Codex primarily needs gsd-check-update.js for the SessionStart update-check hook. + // #772: added gsd-context-monitor.js for the new SubagentStart/Stop/PostToolUse events. // We deliberately do *not* copy gsd-graphify-update.sh or hooks/lib/ for Codex // in this change (graphify auto-update support for Codex is out of scope for #3579). - const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js']; + const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js', 'gsd-context-monitor.js']; const codexHooksSrc = path.join(src, 'hooks', 'dist'); if (fs.existsSync(codexHooksSrc)) { const codexHooksDest = path.join(targetDir, 'hooks'); @@ -10484,6 +11014,39 @@ function install(isGlobal, runtime = 'claude', options = {}) { console.log(` ${green}✓${reset} Verified Codex hooks (SessionStart via hooks.json)`); } } + + // ── Codex extended hook events (#772) ──────────────────────────────── + // Codex CLI stabilised a full hook-event set in rust-v0.137.0. Register + // three new high-value lifecycle events — all routed through + // gsd-context-monitor.js so context-headroom warnings surface at: + // SubagentStart — subagent session open (environment / agent-name aware) + // Stop — model stop / session final-response moment + // PostToolUse — after each tool invocation (mirrors Claude baseline) + // + // Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless + // tool_name is Write|Edit (PreToolUse payload shape), so it would be a + // silent no-op for the UserPromptSubmit payload. Registration deferred + // to a follow-on issue. + // + // Guard: only register when the context-monitor file exists and the node + // runner is available — same guards as the SessionStart path above. + const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js'); + if (codexNodeRunner && fs.existsSync(contextMonitorFile)) { + for (const codexEvent of ['SubagentStart', 'Stop', 'PostToolUse']) { + const eventWrite = ensureCodexHooksJsonEvent(targetDir, codexEvent, { + absoluteRunner: codexNodeRunner, + platform: process.platform, + }); + if (eventWrite.wrote) { + console.log(` ${green}✓${reset} Configured Codex hooks (${codexEvent} via hooks.json)`); + } else if (eventWrite.changed) { + console.log(` ${green}✓${reset} Verified Codex hooks (${codexEvent} via hooks.json)`); + } + } + } else if (!codexNodeRunner) { + console.warn(` ${yellow}⚠${reset} Skipped Codex SubagentStart/Stop/PostToolUse hook registration — Node runner unavailable.`); + } + // ── end Codex extended hook events ──────────────────────────────────── } } catch (e) { // #2760 — schema-validation and write failures must be loud and fatal @@ -11259,6 +11822,14 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS } } + // #768 — Pre-populate permissions.allow/deny for Claude Code installs. + // Merges GSD-owned entries non-destructively (preserves existing user permissions). + // Scoped to Claude only: gemini/antigravity/qwen/hermes/codebuddy also write + // settings.json but use different runtimes and do not use these permission strings. + if (runtime === 'claude') { + mergeClaudePermissions(settings); + } + // Write settings when runtime supports settings.json. // #3002 CR: defense-in-depth — re-run validateHookFields right before // serialization. The push-site guards above already skip null-command @@ -12019,6 +12590,8 @@ module.exports = { convertClaudeToGeminiAgent, convertClaudeAgentToCodexAgent, generateCodexAgentToml, + generateCodexSkillMetadataYaml, + writeCodexSkillMetadataFiles, generateCodexConfigBlock, stripGsdFromCodexConfig, migrateCodexHooksMapFormat, @@ -12050,6 +12623,10 @@ module.exports = { convertClaudeCommandToKiloSkill, configureOpencodePermissions, neutralizeAgentReferences, + // #768 — Claude Code permissions pre-population + mergeClaudePermissions, + GSD_CLAUDE_ALLOW_PERMISSIONS, + GSD_CLAUDE_DENY_PERMISSIONS, GSD_CODEX_MARKER, CODEX_AGENT_SANDBOX, getDirName, @@ -12085,6 +12662,7 @@ module.exports = { convertClaudeAgentToTraeAgent, convertClaudeToCodebuddyMarkdown, convertClaudeCommandToCodebuddySkill, + convertClaudeCommandToCodebuddyCommand, convertClaudeAgentToCodebuddyAgent, convertClaudeToCliineMarkdown, convertClaudeCommandToClineSkill, @@ -12126,6 +12704,9 @@ module.exports = { rewriteLegacyCodexHookBlock, buildCodexHookWindowsShimIR, ensureCodexHooksJsonSessionStart, + ensureCodexHooksJsonEvent, + removeCodexHooksJsonEvent, + reconcileCodexHooksJsonEvent, readGsdCommandNames, installRuntimeArtifacts, installOpencodeFamilySkills, diff --git a/commands/gsd/autonomous.md b/commands/gsd/autonomous.md index 1fc44fbca..fce955925 100644 --- a/commands/gsd/autonomous.md +++ b/commands/gsd/autonomous.md @@ -2,6 +2,8 @@ name: gsd:autonomous description: Run all remaining phases autonomously — discuss→plan→execute per phase argument-hint: "[--from N] [--to N] [--only N] [--interactive]" +context: fork +effort: xhigh allowed-tools: - Read - Write diff --git a/commands/gsd/execute-phase.md b/commands/gsd/execute-phase.md index 93542a765..b7acb5885 100644 --- a/commands/gsd/execute-phase.md +++ b/commands/gsd/execute-phase.md @@ -2,6 +2,8 @@ name: gsd:execute-phase description: Execute all plans in a phase with wave-based parallelization argument-hint: " [--wave N] [--gaps-only] [--interactive] [--tdd]" +context: fork +effort: xhigh allowed-tools: - Read - Write diff --git a/commands/gsd/plan-phase.md b/commands/gsd/plan-phase.md index 4051a361f..47549dfbf 100644 --- a/commands/gsd/plan-phase.md +++ b/commands/gsd/plan-phase.md @@ -2,6 +2,8 @@ name: gsd:plan-phase description: Create detailed phase plan (PLAN.md) with verification loop argument-hint: "[phase] [--auto] [--research] [--skip-research] [--research-phase ] [--view] [--gaps] [--skip-verify] [--prd ] [--ingest ] [--ingest-format ] [--reviews] [--text] [--tdd] [--mvp]" +context: fork +effort: xhigh allowed-tools: - Read - Write diff --git a/commands/gsd/progress.md b/commands/gsd/progress.md index 89c274a01..d35473d2c 100644 --- a/commands/gsd/progress.md +++ b/commands/gsd/progress.md @@ -2,6 +2,7 @@ name: gsd:progress description: Check progress, advance workflow, or dispatch freeform intent — the unified GSD situational command argument-hint: "[--forensic | --next | --do \"task description\"]" +effort: low allowed-tools: - Read - Bash diff --git a/commands/gsd/stats.md b/commands/gsd/stats.md index ca62f6f81..ebdba3cca 100644 --- a/commands/gsd/stats.md +++ b/commands/gsd/stats.md @@ -1,6 +1,7 @@ --- name: gsd:stats description: Display project statistics — phases, plans, requirements, git metrics, and timeline +effort: low allowed-tools: - Read - Bash diff --git a/docs/AGENTS.md b/docs/AGENTS.md index ff048bda2..85d0fae12 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -42,6 +42,7 @@ GSD uses a multi-agent architecture where thin orchestrators (workflow files) sp | **Parallelism** | 4 instances (stack, features, architecture, pitfalls) | | **Tools** | Read, Write, Bash, Grep, Glob, WebSearch, WebFetch, mcp (context7) | | **Model (balanced)** | Sonnet | +| **Color** | Cyan | | **Produces** | `.planning/research/STACK.md`, `FEATURES.md`, `ARCHITECTURE.md`, `PITFALLS.md` | **Capabilities:** @@ -61,6 +62,7 @@ GSD uses a multi-agent architecture where thin orchestrators (workflow files) sp | **Parallelism** | 4 instances (same focus areas as project researcher) | | **Tools** | Read, Write, Bash, Grep, Glob, WebSearch, WebFetch, mcp (context7) | | **Model (balanced)** | Sonnet | +| **Color** | Cyan | | **Produces** | `{phase}-RESEARCH.md` | **Capabilities:** @@ -80,7 +82,7 @@ GSD uses a multi-agent architecture where thin orchestrators (workflow files) sp | **Parallelism** | Single instance | | **Tools** | Read, Write, Bash, Grep, Glob, WebSearch, WebFetch, mcp (context7) | | **Model (balanced)** | Sonnet | -| **Color** | `#E879F9` (fuchsia) | +| **Color** | Purple | | **Produces** | `{phase}-UI-SPEC.md` | **Capabilities:** @@ -268,7 +270,7 @@ GSD uses a multi-agent architecture where thin orchestrators (workflow files) sp | **Parallelism** | Single instance | | **Tools** | Read, Bash, Glob, Grep | | **Model (balanced)** | Sonnet | -| **Color** | `#22D3EE` (cyan) | +| **Color** | Cyan | | **Produces** | BLOCK/FLAG/PASS verdict | --- @@ -306,6 +308,7 @@ GSD uses a multi-agent architecture where thin orchestrators (workflow files) sp | **Parallelism** | Single instance | | **Tools** | Read, Write, Edit, Bash, Grep, Glob | | **Model (balanced)** | Sonnet | +| **Color** | Purple | | **Produces** | Test files, updated `VALIDATION.md` | **Key behaviors:** @@ -325,7 +328,7 @@ GSD uses a multi-agent architecture where thin orchestrators (workflow files) sp | **Parallelism** | Single instance | | **Tools** | Read, Write, Bash, Grep, Glob | | **Model (balanced)** | Sonnet | -| **Color** | `#F472B6` (pink) | +| **Color** | Pink | | **Produces** | `{phase}-UI-REVIEW.md` with scores | **6 Audit Pillars (scored 1-4):** @@ -401,7 +404,7 @@ runs its default whole-repo scan. | **Parallelism** | Single instance | | **Tools** | Read | | **Model (balanced)** | Sonnet | -| **Color** | Magenta | +| **Color** | Purple | | **Produces** | `USER-PROFILE.md`, `CLAUDE.md` profile section | **Behavioral Dimensions:** @@ -467,7 +470,7 @@ Communication style, decision patterns, debugging approach, UX preferences, vend | **Parallelism** | Single instance | | **Tools** | Read, Write, Edit, Bash, Glob, Grep | | **Model (balanced)** | Sonnet | -| **Color** | `#EF4444` (red) | +| **Color** | Red | | **Produces** | `{phase}-SECURITY.md` | **Key behaviors:** @@ -493,7 +496,7 @@ Twelve additional agents ship under `agents/gsd-*.md` and are used by specialty | **Parallelism** | Single instance | | **Tools** | Read, Bash, Glob, Grep, Write | | **Model (balanced)** | Sonnet | -| **Color** | Magenta | +| **Color** | Purple | | **Produces** | `PATTERNS.md` in the phase directory | **Key behaviors:** @@ -533,7 +536,7 @@ Twelve additional agents ship under `agents/gsd-*.md` and are used by specialty | **Parallelism** | Typically single instance per review scope | | **Tools** | Read, Write, Bash, Grep, Glob | | **Model (balanced)** | Sonnet | -| **Color** | `#F59E0B` (amber) | +| **Color** | Orange | | **Produces** | `REVIEW.md` in the phase directory | **Key behaviors:** @@ -553,7 +556,7 @@ Twelve additional agents ship under `agents/gsd-*.md` and are used by specialty | **Parallelism** | Single instance | | **Tools** | Read, Edit, Write, Bash, Grep, Glob | | **Model (balanced)** | Sonnet | -| **Color** | `#10B981` (emerald) | +| **Color** | Green | | **Produces** | `REVIEW-FIX.md`; one atomic git commit per applied fix | **Key behaviors:** @@ -573,7 +576,7 @@ Twelve additional agents ship under `agents/gsd-*.md` and are used by specialty | **Parallelism** | Single instance (sequential with domain-researcher / eval-planner) | | **Tools** | Read, Write, Bash, Grep, Glob, WebFetch, WebSearch, mcp (context7) | | **Model (balanced)** | Sonnet | -| **Color** | `#34D399` (green) | +| **Color** | Green | | **Produces** | Sections 3–4b of `AI-SPEC.md` (framework quick reference + implementation guidance) | **Key behaviors:** @@ -592,7 +595,7 @@ Twelve additional agents ship under `agents/gsd-*.md` and are used by specialty | **Parallelism** | Single instance | | **Tools** | Read, Write, Bash, Grep, Glob, WebSearch, WebFetch, mcp (context7) | | **Model (balanced)** | Sonnet | -| **Color** | `#A78BFA` (violet) | +| **Color** | Purple | | **Produces** | Section 1b of `AI-SPEC.md` | **Key behaviors:** @@ -611,7 +614,7 @@ Twelve additional agents ship under `agents/gsd-*.md` and are used by specialty | **Parallelism** | Single instance (sequential after domain-researcher) | | **Tools** | Read, Write, Bash, Grep, Glob, AskUserQuestion | | **Model (balanced)** | Sonnet | -| **Color** | `#F59E0B` (amber) | +| **Color** | Orange | | **Produces** | Sections 5–7 of `AI-SPEC.md` (Evaluation Strategy, Guardrails, Production Monitoring) | **Required reading:** `gsd-core/references/ai-evals.md` (evaluation framework). @@ -632,7 +635,7 @@ Twelve additional agents ship under `agents/gsd-*.md` and are used by specialty | **Parallelism** | Single instance | | **Tools** | Read, Write, Bash, Grep, Glob | | **Model (balanced)** | Sonnet | -| **Color** | `#EF4444` (red) | +| **Color** | Red | | **Produces** | `EVAL-REVIEW.md` with dimension scores, findings, and remediation guidance | **Required reading:** `gsd-core/references/ai-evals.md`. @@ -653,7 +656,7 @@ Twelve additional agents ship under `agents/gsd-*.md` and are used by specialty | **Parallelism** | Single instance (interactive) | | **Tools** | Read, Bash, Grep, Glob, WebSearch, AskUserQuestion | | **Model (balanced)** | Sonnet | -| **Color** | `#38BDF8` (sky blue) | +| **Color** | Cyan | | **Produces** | Scored ranked recommendation (structured return to orchestrator) | **Required reading:** `gsd-core/references/ai-frameworks.md` (decision matrix). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0e8b7aa33..e8ad9fc70 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -812,7 +812,7 @@ The migration-specific ownership and source snapshots live in | Trae | `~/.trae` | `./.trae` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks | | Qwen Code | `~/.qwen` | `./.qwen` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Common GSD settings and hook entries where supported | | Hermes Agent | `~/.hermes` | `./.hermes` | `skills/gsd/DESCRIPTION.md` plus `skills/gsd/gsd-*/SKILL.md` | `agents/gsd-*.md` | Common GSD settings and hook entries where supported | -| CodeBuddy | `~/.codebuddy` | `./.codebuddy` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Common GSD settings and hook entries where supported | +| CodeBuddy | `~/.codebuddy` | `./.codebuddy` | `skills/gsd-*/SKILL.md` (`user-invocable: false`) | `agents/gsd-*.md` | `/gsd-*` slash commands under `commands/`; common GSD settings and hook entries where supported | | Cline | `~/.cline` | project root | `.clinerules` | Rules only | No GSD hooks or statusline | ### Upstream Contract Sources diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 2fd855646..ffc8bf6d5 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -12,6 +12,14 @@ The hyphen and colon forms are *runtime-specific spellings of the same command*. Whichever runtime you're on, the installer writes the correct form into your runtime's command directory. +### Skill Runtime Behavior (Claude Code) + +Heavy workflow skills (`/gsd-plan-phase`, `/gsd-execute-phase`, `/gsd-autonomous`) carry `context: fork` in their frontmatter. On Claude Code, this runs each skill in an isolated subagent context window, protecting the main session's context budget. The skills also declare `effort: xhigh`, signalling maximum token budget to the runtime. + +Quick-status skills (`/gsd-progress`, `/gsd-stats`) declare `effort: low`, directing the runtime to use a minimal token budget for fast reads. + +These fields are Claude Code–specific frontmatter. On runtimes that do not recognise them (Gemini, Codex, Cursor, etc.) the fields are silently ignored — existing behaviour is unchanged. + --- ## Namespace Meta-Skills diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 839c29642..2ab41eb79 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -2290,7 +2290,7 @@ Test suite that scans all agent, workflow, and command files for embedded inject **Requirements:** - REQ-CLINE-02: Cline install MUST write `.clinerules` to `~/.cline/` (global) or `./.cline/` (local). No custom slash commands — rules-based integration only. Flag: `--cline`. -- REQ-CODEBUDDY-01: CodeBuddy install MUST deploy skills to `~/.codebuddy/skills/gsd-*/SKILL.md`. Flag: `--codebuddy`. +- REQ-CODEBUDDY-01: CodeBuddy install MUST deploy skills to `~/.codebuddy/skills/gsd-*/SKILL.md` (emitted `user-invocable: false`), `/gsd-*` slash commands to `~/.codebuddy/commands/gsd-*.md`, and subagents to `~/.codebuddy/agents/gsd-*.md`. The commands surface is the sole `/` menu entry point. No `mcp.json` is written (gsd ships no MCP server). Flag: `--codebuddy`. - REQ-QWEN-01: Qwen Code install MUST deploy skills to `~/.qwen/skills/gsd-*/SKILL.md`, following the open standard used by Claude Code 2.1.88+. `QWEN_CONFIG_DIR` env var overrides the default path. Flag: `--qwen`. **Runtime summary:** diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index e7af7be47..c4f114a76 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -688,6 +688,16 @@ To assign different models on a non-Claude runtime: } ``` +#### Codex skill picker and agent scheduling (#774) + +GSD enriches each Codex install with two additional artifacts: + +- **Skill TUI chip** — each installed `gsd-*` skill directory contains an `agents/openai.yaml` file that populates the Codex `/skills` picker with a human-readable display name and a short description, so you can browse and invoke GSD skills from the Codex TUI without typing the full skill name. + +- **Flex-tier scheduling** — light-tier agents (haiku-equivalent) emit `service_tier = "flex"` and `model_verbosity = "low"` in their agent TOML. The Codex scheduler routes these agents to the flex tier (lower cost, background processing) and suppresses verbose token output. + +Both enrichments are written automatically at install time and require no manual configuration. Requires Codex CLI ≥ 0.130.0. + #### Switching from Claude to Codex with one config change (#2517) ```json @@ -699,6 +709,15 @@ To assign different models on a non-Claude runtime: See [Runtime-Aware Profiles](CONFIGURATION.md#runtime-aware-profiles-2517). +#### Per-runtime command enrichment + +When generating artifacts, the installer adapts GSD commands to each runtime's native command schema: + +- **Gemini CLI** — generated TOML commands use Gemini's `{{args}}` placeholder (translated from Claude's `$ARGUMENTS`) so typed arguments interpolate into the prompt, and `/gsd:progress` injects live project state via a fixed `!{cat .planning/STATE.md 2>/dev/null}` shell block (no interpolated input, so no injection risk; Gemini shows its standard confirmation dialog). +- **Qwen Code** — main-loop skills carry Qwen's numeric `priority` field so the most-used workflows (e.g. `new-project`, `plan-phase`, `execute-phase`) sort first in the `/skills` list; utility skills are left unset. Higher values sort earlier; the field affects only the `/skills` list order. + +See [How to install GSD Core on your runtime](how-to/install-on-your-runtime.md) for the full per-runtime details. + ### Manual install / no-Node.js setup If you cannot run the GSD installer, you cannot use the source files in `agents/` directly — they are in Claude Code's native frontmatter format. For OpenCode, two transformations are required: @@ -727,12 +746,51 @@ npx @opengsd/gsd-core --cline --local # this project only npx @opengsd/gsd-core --codebuddy --global ``` +GSD installs four surfaces for CodeBuddy: `/gsd-*` slash commands in `~/.codebuddy/commands/`, subagents in `~/.codebuddy/agents/`, model-invocable skills in `~/.codebuddy/skills/`, and `settings.json` hooks. The skills are emitted with `user-invocable: false` so the slash commands are the single `/` menu surface (no duplicate entries). + ### Installing for Qwen Code ```bash npx @opengsd/gsd-core --qwen --global ``` +### Installing as a Gemini CLI extension (#775) + +GSD ships a `gemini-extension.json` extension manifest at the repository root, so +Gemini CLI users can install, update, and remove GSD through Gemini's own +extension lifecycle — and have it show up in `gemini extensions list`: + +```bash +# Install (Gemini clones the repo and copies the extension) +gemini extensions install https://github.com/open-gsd/gsd-core + +# Update to the latest released manifest version +gemini extensions update gsd-core + +# Remove +gemini extensions uninstall gsd-core +``` + +For local development against a checkout, symlink it instead of copying: + +```bash +gemini extensions link /path/to/gsd-core +``` + +**What the extension delivers today:** it loads GSD's operating context +(`GEMINI.md`) into every Gemini session in the project, and gives you the +discoverable install/update/remove lifecycle above. The `/gsd:*` slash commands, +agents, and hooks are still installed via the dedicated installer: + +```bash +npx @opengsd/gsd-core --gemini --global +``` + +The two paths are complementary and additive — installing the extension does not +change or replace the `npx gsd-core --gemini` install, and either can be used on +its own. (Slash-command/agent/hook projection into the extension package itself +is a planned follow-up.) + ### Installing for Prerelease Editions Set the runtime's `*_CONFIG_DIR` env var to the prerelease directory before running the installer: @@ -780,6 +838,35 @@ See [docs/manual-update.md](manual-update.md) for a step-by-step manual update p When a workflow fails in a non-obvious way, run `/gsd-forensics` to generate a diagnostic report covering git history anomalies, artifact integrity, and state inconsistencies. Output goes to `.planning/forensics/`. +### Pre-populated Permissions (Claude Code) + +Since v1.3.1, the installer pre-populates `~/.claude/settings.json` (or +`settings.local.json` for local installs) with the core permissions GSD needs: + +```json +{ + "permissions": { + "allow": [ + "Bash(npx gsd-core *)", + "Read(.planning/*)", + "Write(.planning/*)", + "Read(STATE.md)", + "Write(STATE.md)" + ], + "deny": [ + "Read(.env)", + "Read(.env.*)", + "Read(.secrets)" + ] + } +} +``` + +These entries eliminate first-run approval prompts for GSD's own tool calls. The +merge is non-destructive — your existing permissions are preserved and GSD entries +are only appended. Uninstalling GSD removes exactly these entries and preserves +any others. + ### Executor Subagent Gets "Permission denied" on Bash Commands Add the required patterns to `~/.claude/settings.json`. Core patterns needed for all stacks: diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index e82b342aa..b4b5370e8 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -96,6 +96,11 @@ npx @opengsd/gsd-core@latest --gemini --global Skills land in `~/.gemini/`. The installer rewrites all command bodies to Gemini's colon namespace (`/gsd:update`, `/gsd:config`, etc.). Restart Gemini CLI after install. +The installer also enriches the generated TOML commands with two native Gemini custom-command features: + +- **`{{args}}` interpolation** — every command that references arguments inline is emitted with Gemini's `{{args}}` placeholder (translated from Claude's `$ARGUMENTS`), so flags and free-text you type after the command name are interpolated into the prompt body rather than ignored. +- **`!{...}` live-state injection** — `/gsd:progress` injects the current contents of `.planning/STATE.md` via a fixed `!{cat .planning/STATE.md 2>/dev/null}` shell block, giving Gemini live project state without relying on session memory. The shell block contains no interpolated input, so there is no injection risk; Gemini still shows its standard confirmation dialog the first time the command runs in a session. + **Override the install directory:** ```bash @@ -104,6 +109,21 @@ GEMINI_CONFIG_DIR=~/.gemini-alt npx @opengsd/gsd-core@latest --gemini --global --- +### Gemini CLI — native extension install (#775) + +GSD also ships a `gemini-extension.json` extension manifest, so you can manage GSD through Gemini's own extension lifecycle and see it in `gemini extensions list`: + +```bash +gemini extensions install https://github.com/open-gsd/gsd-core # install +gemini extensions update gsd-core # update +gemini extensions uninstall gsd-core # remove +gemini extensions link /path/to/gsd-core # dev: symlink a checkout +``` + +The extension loads GSD's operating context (`GEMINI.md`) into every session and gives you the discoverable install/update/remove lifecycle. The `/gsd:*` slash commands, agents, and hooks are installed separately by `npx @opengsd/gsd-core --gemini --global` (above). The two paths are complementary and additive — neither replaces the other, and slash-command projection into the extension is a planned follow-up. + +--- + ### OpenCode ```bash @@ -146,6 +166,19 @@ Skills land in `~/.codex/skills/gsd-*/SKILL.md`. Agents are written with per-age **Minimum supported version:** Codex CLI 0.130.0. Earlier versions had additional skill-root scanning that can produce duplicate listings. +**Hook coverage** + +GSD registers the following Codex hook events automatically on install (requires Codex CLI 0.137.0+ for the stable hook-event schema): + +| Event | Hook | Purpose | +|---|---|---| +| `SessionStart` | `gsd-check-update.js` | Update check at session open; Windows installs also emit a `commandWindows` field pointing to the `.cmd` shim so Codex picks the correct executor on Windows without requiring per-OS config regeneration | +| `SubagentStart` | `gsd-context-monitor.js` | Inject context / GSD_AGENT_NAME awareness at subagent open | +| `Stop` | `gsd-context-monitor.js` | Context headroom tracking before model stop | +| `PostToolUse` | `gsd-context-monitor.js` | Mirror the context-monitor coverage available in Claude Code | + +All registered hooks are managed by GSD and are removed cleanly on `--uninstall`. + --- ### Kimi CLI @@ -303,7 +336,7 @@ directory is created for local scope. npx @opengsd/gsd-core@latest --codebuddy --global ``` -Skills land in `~/.codebuddy/skills/gsd-*/SKILL.md`. +GSD installs four surfaces. Slash command definitions land in `~/.codebuddy/commands/gsd-*.md` and appear as `/gsd-help`, `/gsd-phase`, `/gsd-ship`, etc. in the `/` menu. Subagents land in `~/.codebuddy/agents/gsd-*.md`. Skills land in `~/.codebuddy/skills/gsd-*/SKILL.md` — emitted with `user-invocable: false` so they stay out of the `/` menu (the commands surface is the sole `/` entry point) and remain available for model invocation. CodeBuddy hooks are written to `settings.json`. No `mcp.json` is written: GSD ships no MCP server. --- @@ -317,6 +350,8 @@ npx @opengsd/gsd-core@latest --qwen --global Skills land in `~/.qwen/skills/gsd-*/SKILL.md`. +GSD's main-loop skills are emitted with Qwen's optional numeric `priority` frontmatter field so the most-used workflows surface first in the `/skills` TUI list. Higher values sort earlier (per Qwen's skills spec), so core commands such as `/skills` for `new-project` (100), `plan-phase` (90), and `execute-phase` (85) appear above utility skills, which are left unset (default 0). This affects only the `/skills` list order — slash-command completion and `/help` remain alphabetical. + **Override the install directory:** ```bash diff --git a/gemini-extension.json b/gemini-extension.json new file mode 100644 index 000000000..31dbbdead --- /dev/null +++ b/gemini-extension.json @@ -0,0 +1,6 @@ +{ + "name": "gsd-core", + "version": "1.3.1-dev.0", + "description": "GSD Core — a meta-prompting, context engineering, and spec-driven development system for AI coding agents. Loads gsd's operating context into every Gemini CLI session.", + "contextFileName": "GEMINI.md" +} diff --git a/gsd-core/workflows/review.md b/gsd-core/workflows/review.md index 7c5ed8922..470fcd01b 100644 --- a/gsd-core/workflows/review.md +++ b/gsd-core/workflows/review.md @@ -248,9 +248,9 @@ fi **Codex:** ```bash if [ -n "$CODEX_MODEL" ] && [ "$CODEX_MODEL" != "null" ]; then - cat /tmp/gsd-review-prompt-{phase}.md | codex exec --model "$CODEX_MODEL" --skip-git-repo-check - 2>/dev/null > /tmp/gsd-review-codex-{phase}.md + cat /tmp/gsd-review-prompt-{phase}.md | codex exec --ephemeral --dangerously-bypass-hook-trust --model "$CODEX_MODEL" --skip-git-repo-check - 2>/dev/null > /tmp/gsd-review-codex-{phase}.md else - cat /tmp/gsd-review-prompt-{phase}.md | codex exec --skip-git-repo-check - 2>/dev/null > /tmp/gsd-review-codex-{phase}.md + cat /tmp/gsd-review-prompt-{phase}.md | codex exec --ephemeral --dangerously-bypass-hook-trust --skip-git-repo-check - 2>/dev/null > /tmp/gsd-review-codex-{phase}.md fi ``` diff --git a/package.json b/package.json index 01069328d..8eac5d16e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "assets", "agents", ".claude-plugin", + "gemini-extension.json", + "GEMINI.md", "hooks", "scripts" ], diff --git a/scripts/research-profiles.cjs b/scripts/research-profiles.cjs index 58b8ad2b4..01b0c0e7f 100644 --- a/scripts/research-profiles.cjs +++ b/scripts/research-profiles.cjs @@ -82,7 +82,7 @@ const PROFILES = [ name: 'gsd-ai-researcher', description: 'Researches a chosen AI framework\'s official docs to produce implementation-ready guidance — best practices, syntax, core patterns, and pitfalls distilled for the specific use case. Writes the Framework Quick Reference and Implementation Guidance sections of AI-SPEC.md. Spawned by /gsd:ai-integration-phase orchestrator.', - color: '"#34D399"', + color: 'green', tools: 'Read, Write, Edit, Bash, Grep, Glob, WebFetch, WebSearch, mcp__context7__*', requiredIncludes: [ @@ -99,7 +99,7 @@ const PROFILES = [ name: 'gsd-domain-researcher', description: 'Researches the business domain and real-world application context of the AI system being built. Surfaces domain expert evaluation criteria, industry-specific failure modes, regulatory context, and what "good" looks like for practitioners in this field — before the eval-planner turns it into measurable rubrics. Spawned by /gsd:ai-integration-phase orchestrator.', - color: '"#A78BFA"', + color: 'purple', tools: 'Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*', requiredIncludes: [ @@ -115,7 +115,7 @@ const PROFILES = [ name: 'gsd-ui-researcher', description: 'Produces UI-SPEC.md design contract for frontend phases. Reads upstream artifacts, detects design system state, asks only unanswered questions. Spawned by /gsd:ui-phase orchestrator.', - color: '"#E879F9"', + color: 'purple', tools: 'Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__*, mcp__tavily__*, mcp__ref__*, mcp__jina__*', requiredIncludes: [ diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index bd4d84bb9..b9a265378 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -391,7 +391,17 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: break; case 'codebuddy': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodebuddySkill', 'codebuddy', configDir)]; + // CodeBuddy (Tencent) reads two user-level surfaces (codebuddy.ai/docs/cli): + // 1. commands/gsd-.md — slash commands shown in the '/' menu (#789) + // 2. skills/gsd-/SKILL.md — model-invocable skills, emitted with + // user-invocable:false so they stay OUT of '/' (the commands surface is + // the sole '/' entry point) — avoids a duplicated /gsd-* per workflow. + // Subagents (~/.codebuddy/agents/) are already emitted by the generic agents + // block in bin/install.js; MCP is excluded (gsd ships no MCP server). + kinds = [ + convertedCommandsKind('commands', 'gsd-', 'convertClaudeCommandToCodebuddyCommand', configDir), + skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodebuddySkill', 'codebuddy', configDir), + ]; break; case 'cline': diff --git a/src/shell-command-projection.cts b/src/shell-command-projection.cts index ef2dad366..309872381 100644 --- a/src/shell-command-projection.cts +++ b/src/shell-command-projection.cts @@ -181,6 +181,10 @@ const MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE: Record> = { // reconcileCodexHooksJsonSessionStart can replace stale node-runner commands // with the .cmd shim on reinstall (and vice-versa on cross-platform moves). 'gsd-check-update.cmd', + // #772: context-monitor is now registered for Codex SubagentStart/Stop/PostToolUse. + 'gsd-context-monitor.js', + // #772: Windows .cmd shim for gsd-context-monitor — same #3426 pattern. + 'gsd-context-monitor.cmd', ]), }; diff --git a/tests/agent-frontmatter.test.cjs b/tests/agent-frontmatter.test.cjs index 5a2b8f4ed..f39ab5155 100644 --- a/tests/agent-frontmatter.test.cjs +++ b/tests/agent-frontmatter.test.cjs @@ -232,6 +232,29 @@ describe('AGENT: required frontmatter fields', () => { } }); +// ─── Color Value Validation ────────────────────────────────────────────────── + +const VALID_AGENT_COLORS = new Set(['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan']); + +describe('COLOR: color frontmatter must be a documented named color', () => { + for (const agent of ALL_AGENTS) { + test(`${agent} color: is a documented named color`, () => { + const content = fs.readFileSync(path.join(AGENTS_DIR, agent + '.md'), 'utf-8'); + const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + const frontmatter = fmMatch ? fmMatch[1] : ''; + const colorMatch = frontmatter.match(/^color:\s*(.+)$/m); + assert.ok(colorMatch, `${agent} missing color: field in frontmatter`); + const rawValue = colorMatch[1].trim(); + // Strip surrounding quotes (single or double) before validating + const colorValue = rawValue.replace(/^["']|["']$/g, ''); + assert.ok( + VALID_AGENT_COLORS.has(colorValue), + `${agent} has invalid color: "${colorValue}" — must be one of: ${[...VALID_AGENT_COLORS].join(', ')}` + ); + }); + } +}); + // ─── CLAUDE.md Compliance ─────────────────────────────────────────────────── describe('CLAUDEMD: CLAUDE.md compliance enforcement', () => { diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index 5ef505db8..3e1e955fe 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -20,6 +20,7 @@ const path = require('path'); const os = require('os'); const { execFileSync } = require('child_process'); const { cleanup } = require('./helpers.cjs'); +const jsYaml = require('js-yaml'); // #2153 follow-up: ensure hooks/dist/ exists before any install integration // test runs. The Codex install path copies hook files from hooks/dist/, which @@ -44,6 +45,8 @@ const { convertClaudeAgentToCodexAgent, convertClaudeCommandToCodexSkill, generateCodexAgentToml, + generateCodexSkillMetadataYaml, + writeCodexSkillMetadataFiles, generateCodexConfigBlock, stripGsdFromCodexConfig, migrateCodexHooksMapFormat, @@ -401,6 +404,74 @@ tools: Read, Grep, Glob assert.ok(instrIdx !== -1, 'developer_instructions present'); assert.ok(modelIdx < instrIdx, 'model field must appear before developer_instructions'); }); + + // ─── #774: service_tier / model_verbosity for light-tier agents ─────────────── + + test('emits service_tier="flex" and model_verbosity="low" for light-tier agents (#774)', () => { + // gsd-plan-checker has routingTier:"light" in model-catalog.json + const lightAgent = `--- +name: gsd-plan-checker +description: Checks plans quickly +tools: Read, Grep +--- + +You check plans.`; + const result = generateCodexAgentToml('gsd-plan-checker', lightAgent); + assert.ok(result.includes('service_tier = "flex"'), 'light-tier agent must have service_tier = "flex"'); + assert.ok(result.includes('model_verbosity = "low"'), 'light-tier agent must have model_verbosity = "low"'); + }); + + test('does not emit service_tier or model_verbosity for standard-tier agents (#774)', () => { + // gsd-executor has routingTier:"standard" in model-catalog.json + const result = generateCodexAgentToml('gsd-executor', sampleAgent); + assert.ok(!result.includes('service_tier'), 'standard-tier agent must not have service_tier'); + assert.ok(!result.includes('model_verbosity'), 'standard-tier agent must not have model_verbosity'); + }); + + test('does not emit service_tier or model_verbosity for heavy-tier agents (#774)', () => { + // gsd-planner has routingTier:"heavy" in model-catalog.json + const heavyAgent = `--- +name: gsd-planner +description: Creates plans +tools: Read, Write, Edit +--- + +You plan.`; + const result = generateCodexAgentToml('gsd-planner', heavyAgent); + assert.ok(!result.includes('service_tier'), 'heavy-tier agent must not have service_tier'); + assert.ok(!result.includes('model_verbosity'), 'heavy-tier agent must not have model_verbosity'); + }); + + test('service_tier and model_verbosity appear before developer_instructions (#774)', () => { + const lightAgent = `--- +name: gsd-plan-checker +description: Checks plans +--- + +You check plans.`; + const result = generateCodexAgentToml('gsd-plan-checker', lightAgent); + const stIdx = result.indexOf('service_tier = "flex"'); + const mvIdx = result.indexOf('model_verbosity = "low"'); + const instrIdx = result.indexOf("developer_instructions = '''"); + assert.ok(stIdx !== -1, 'service_tier present'); + assert.ok(mvIdx !== -1, 'model_verbosity present'); + assert.ok(instrIdx !== -1, 'developer_instructions present'); + assert.ok(stIdx < instrIdx, 'service_tier must appear before developer_instructions'); + assert.ok(mvIdx < instrIdx, 'model_verbosity must appear before developer_instructions'); + }); + + test('emitted TOML is parseable and contains correct field values for light-tier agents (#774)', () => { + const lightAgent = `--- +name: gsd-codebase-mapper +description: Maps the codebase +--- + +You map the codebase.`; + const toml = generateCodexAgentToml('gsd-codebase-mapper', lightAgent); + const parsed = parseTomlToObject(toml); + assert.strictEqual(parsed.service_tier, 'flex', 'service_tier must parse to "flex"'); + assert.strictEqual(parsed.model_verbosity, 'low', 'model_verbosity must parse to "low"'); + }); }); // ─── CODEX_AGENT_SANDBOX mapping ──────────────────────────────────────────────── @@ -2302,3 +2373,215 @@ describe('Codex uninstall symmetry for hook-enabled configs', () => { assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections'); }); }); + +// ─── #774: generateCodexSkillMetadataYaml ──────────────────────────────────────── + +describe('generateCodexSkillMetadataYaml', () => { + test('emits valid parseable YAML with interface section (#774)', () => { + const yaml = generateCodexSkillMetadataYaml('gsd-plan-phase', 'Plan and structure the next development phase.'); + // Must start with interface: and be parseable YAML + assert.ok(yaml.startsWith('interface:'), 'must start with interface: key'); + const parsed = jsYaml.load(yaml); + assert.ok(parsed && typeof parsed === 'object', 'must be parseable YAML object'); + assert.ok(parsed.interface, 'must have interface key'); + assert.ok('display_name' in parsed.interface, 'must include display_name'); + assert.ok('short_description' in parsed.interface, 'must include short_description'); + }); + + test('strips gsd- prefix from display_name and converts hyphens to spaces (#774)', () => { + const yaml = generateCodexSkillMetadataYaml('gsd-plan-phase', 'Plan and structure the next development phase.'); + const parsed = jsYaml.load(yaml); + assert.strictEqual(parsed.interface.display_name, 'plan phase', + 'display_name must be "plan phase" (gsd- stripped, hyphens→spaces)'); + }); + + test('embeds the short_description text as decoded string (#774)', () => { + const desc = 'Run GSD workflow gsd-test.'; + const yaml = generateCodexSkillMetadataYaml('gsd-test', desc); + const parsed = jsYaml.load(yaml); + assert.strictEqual(parsed.interface.short_description, desc, + 'short_description must round-trip through YAML correctly'); + }); + + test('escapes double-quotes in description so parsed value is correct (#774)', () => { + const desc = 'Run "special" workflow.'; + const yaml = generateCodexSkillMetadataYaml('gsd-test', desc); + const parsed = jsYaml.load(yaml); + assert.strictEqual(parsed.interface.short_description, desc, + 'double-quotes in description must round-trip correctly through YAML'); + }); + + test('escapes backslashes in description so parsed value is correct (#774)', () => { + const desc = 'Run C:\\path\\to workflow.'; + const yaml = generateCodexSkillMetadataYaml('gsd-test', desc); + const parsed = jsYaml.load(yaml); + assert.strictEqual(parsed.interface.short_description, desc, + 'backslashes in description must round-trip correctly through YAML'); + }); + + test('output ends with a newline (#774)', () => { + const yaml = generateCodexSkillMetadataYaml('gsd-test', 'Test skill.'); + assert.ok(yaml.endsWith('\n'), 'output must end with a newline'); + }); + + test('works for skill without gsd- prefix (#774)', () => { + const yaml = generateCodexSkillMetadataYaml('my-skill', 'A custom skill.'); + const parsed = jsYaml.load(yaml); + assert.strictEqual(parsed.interface.display_name, 'my skill', + 'display_name must convert hyphens to spaces even without gsd- prefix'); + }); +}); + +// ─── #774: writeCodexSkillMetadataFiles ───────────────────────────────────────── + +describe('writeCodexSkillMetadataFiles', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-skill-meta-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('writes agents/openai.yaml for each gsd-* skill directory (#774)', () => { + // Set up a synthetic skills directory with two gsd-* skill dirs + const skills = [ + { name: 'gsd-plan-phase', desc: 'Plan and structure the next development phase.' }, + { name: 'gsd-execute-phase', desc: 'Execute the current phase.' }, + ]; + for (const { name, desc } of skills) { + const skillDir = path.join(tmpDir, name); + fs.mkdirSync(skillDir, { recursive: true }); + const skillMd = `---\nname: ${name}\ndescription: "${desc}"\nmetadata:\n short-description: "${desc}"\n---\n\nBody text.\n`; + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillMd); + } + + writeCodexSkillMetadataFiles(tmpDir); + + for (const { name, desc } of skills) { + const yamlPath = path.join(tmpDir, name, 'agents', 'openai.yaml'); + assert.ok(fs.existsSync(yamlPath), `agents/openai.yaml must exist for ${name}`); + const content = fs.readFileSync(yamlPath, 'utf8'); + // Verify it's parseable YAML with the correct structure + const parsed = jsYaml.load(content); + assert.ok(parsed && parsed.interface, `${name}/agents/openai.yaml must parse to object with interface:`); + assert.ok('display_name' in parsed.interface, `${name}/agents/openai.yaml must have display_name`); + assert.strictEqual(parsed.interface.short_description, desc, + `${name}/agents/openai.yaml short_description must match source description`); + } + }); + + test('ignores non-gsd directories (#774)', () => { + // Non-gsd-* dir should not get agents/openai.yaml + const nonGsdDir = path.join(tmpDir, 'custom-skill'); + fs.mkdirSync(nonGsdDir, { recursive: true }); + fs.writeFileSync(path.join(nonGsdDir, 'SKILL.md'), '---\nname: custom\n---\nBody.\n'); + + writeCodexSkillMetadataFiles(tmpDir); + + assert.ok(!fs.existsSync(path.join(nonGsdDir, 'agents', 'openai.yaml')), + 'non-gsd-* dirs must not get agents/openai.yaml'); + }); + + test('does not overwrite user-owned gsd-dev-preferences/agents/openai.yaml (#774)', () => { + // gsd-dev-preferences is user-owned and must never be modified by GSD install + const userOwnedDir = path.join(tmpDir, 'gsd-dev-preferences'); + const agentsSubdir = path.join(userOwnedDir, 'agents'); + fs.mkdirSync(agentsSubdir, { recursive: true }); + fs.writeFileSync(path.join(userOwnedDir, 'SKILL.md'), '---\nname: gsd-dev-preferences\ndescription: "User pref"\n---\nBody.\n'); + const userYaml = 'interface:\n display_name: "my preferences"\n short_description: "User-authored"\n'; + fs.writeFileSync(path.join(agentsSubdir, 'openai.yaml'), userYaml); + + writeCodexSkillMetadataFiles(tmpDir); + + // User-authored file must remain unchanged + const after = fs.readFileSync(path.join(agentsSubdir, 'openai.yaml'), 'utf8'); + assert.strictEqual(after, userYaml, 'user-owned gsd-dev-preferences/agents/openai.yaml must not be overwritten'); + }); + + test('does not create agents/openai.yaml for gsd-dev-preferences if absent (#774)', () => { + // Even if gsd-dev-preferences exists without an openai.yaml, we must not create one + const userOwnedDir = path.join(tmpDir, 'gsd-dev-preferences'); + fs.mkdirSync(userOwnedDir, { recursive: true }); + fs.writeFileSync(path.join(userOwnedDir, 'SKILL.md'), '---\nname: gsd-dev-preferences\n---\nBody.\n'); + + writeCodexSkillMetadataFiles(tmpDir); + + assert.ok(!fs.existsSync(path.join(userOwnedDir, 'agents', 'openai.yaml')), + 'gsd-dev-preferences must not get agents/openai.yaml even if it was absent'); + }); + + test('is a no-op when skillsDir does not exist (#774)', () => { + // Should not throw when the directory doesn't exist + assert.doesNotThrow(() => { + writeCodexSkillMetadataFiles(path.join(tmpDir, 'nonexistent')); + }, 'must not throw when skillsDir does not exist'); + }); + + test('skips skill dirs with missing SKILL.md without throwing (#774)', () => { + // Create a gsd-* dir with no SKILL.md — should fail open + const emptySkillDir = path.join(tmpDir, 'gsd-empty'); + fs.mkdirSync(emptySkillDir, { recursive: true }); + + assert.doesNotThrow(() => { + writeCodexSkillMetadataFiles(tmpDir); + }, 'must not throw when SKILL.md is missing'); + }); + + test('display_name in agents/openai.yaml has gsd- prefix stripped (#774)', () => { + const skillDir = path.join(tmpDir, 'gsd-plan-phase'); + fs.mkdirSync(skillDir, { recursive: true }); + const skillMd = `---\nname: gsd-plan-phase\ndescription: "Plan the phase."\nmetadata:\n short-description: "Plan the phase."\n---\n\nBody.\n`; + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillMd); + + writeCodexSkillMetadataFiles(tmpDir); + + const yamlContent = fs.readFileSync(path.join(tmpDir, 'gsd-plan-phase', 'agents', 'openai.yaml'), 'utf8'); + const parsed = jsYaml.load(yamlContent); + assert.strictEqual(parsed.interface.display_name, 'plan phase', + 'display_name must have gsd- stripped and hyphens→spaces'); + }); + + test('correctly unescapes YAML-quoted short-description from SKILL.md (#774)', () => { + // convertClaudeCommandToCodexSkill emits double-quoted YAML for descriptions. + // Verify that escape sequences like \" in the YAML source round-trip to literal " in output. + const skillDir = path.join(tmpDir, 'gsd-test-esc'); + fs.mkdirSync(skillDir, { recursive: true }); + // SKILL.md has a YAML-escaped double-quote in short-description + const skillMd = '---\nname: gsd-test-esc\ndescription: "Normal"\nmetadata:\n short-description: "Run \\"special\\" workflow."\n---\n\nBody.\n'; + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillMd); + + writeCodexSkillMetadataFiles(tmpDir); + + const yamlContent = fs.readFileSync(path.join(tmpDir, 'gsd-test-esc', 'agents', 'openai.yaml'), 'utf8'); + const parsed = jsYaml.load(yamlContent); + assert.strictEqual(parsed.interface.short_description, 'Run "special" workflow.', + 'YAML-escaped quotes in SKILL.md must round-trip to literal quotes in agents/openai.yaml'); + }); + + test('Codex install emits agents/openai.yaml for each skill (#774)', () => { + // Integration test: run a full Codex install and verify agents/openai.yaml is written. + // Use runCodexInstall so CODEX_HOME is saved and restored correctly even if it was + // already set in the environment before this test ran. + const codexHome = path.join(tmpDir, 'codex-home'); + fs.mkdirSync(codexHome, { recursive: true }); + runCodexInstall(codexHome); + const skillsDir = path.join(codexHome, 'skills'); + // Assert that the install actually created a skills directory + assert.ok(fs.existsSync(skillsDir), 'Codex install must create a skills/ directory'); + const gsdSkillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); + // At least some skills should be present + assert.ok(gsdSkillDirs.length > 0, 'install must create at least one gsd-* skill directory'); + // Each skill directory must have agents/openai.yaml with valid YAML + for (const skillEntry of gsdSkillDirs) { + const yamlPath = path.join(skillsDir, skillEntry.name, 'agents', 'openai.yaml'); + assert.ok(fs.existsSync(yamlPath), `${skillEntry.name}/agents/openai.yaml must exist after install`); + const content = fs.readFileSync(yamlPath, 'utf8'); + const parsed = jsYaml.load(content); + assert.ok(parsed && parsed.interface, `${skillEntry.name}/agents/openai.yaml must parse to object with interface:`); + } + }); +}); diff --git a/tests/enh-769-context-fork-effort.install.test.cjs b/tests/enh-769-context-fork-effort.install.test.cjs new file mode 100644 index 000000000..125730723 --- /dev/null +++ b/tests/enh-769-context-fork-effort.install.test.cjs @@ -0,0 +1,317 @@ +// allow-test-rule: integration-test-input +// Exercises install() as a black-box by inspecting produced SKILL.md output +// in a temp dir. Source command .md files are inputs whose installed +// transformation is asserted — not inspected for string presence. + +/** + * #769 — context:fork + effort: frontmatter on heavy workflow skills. + * + * Verifies: + * 1. Source commands/gsd/autonomous.md has context: fork and effort: xhigh + * 2. Source commands/gsd/execute-phase.md has context: fork and effort: xhigh + * 3. Source commands/gsd/plan-phase.md has context: fork and effort: xhigh + * 4. Source commands/gsd/progress.md has effort: low + * 5. Source commands/gsd/stats.md has effort: low + * 6. Claude global install: SKILL.md for autonomous has context: fork and effort: xhigh + * 7. Claude global install: SKILL.md for execute-phase has context: fork and effort: xhigh + * 8. Claude global install: SKILL.md for plan-phase has context: fork and effort: xhigh + * 9. Claude global install: SKILL.md for progress has effort: low + * 10. Claude global install: SKILL.md for stats has effort: low + * 11. convertClaudeCommandToClaudeSkill preserves context: fork field + * 12. convertClaudeCommandToClaudeSkill preserves effort: field + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { install, convertClaudeCommandToClaudeSkill } = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const SOURCE_COMMANDS_DIR = path.join(REPO_ROOT, 'commands', 'gsd'); + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function makeTmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function readFrontmatter(mdPath) { + const content = fs.readFileSync(mdPath, 'utf8'); + if (!content.startsWith('---')) return ''; + const end = content.indexOf('---', 3); + if (end === -1) return ''; + return content.substring(3, end); +} + +/** + * Run a global install for Claude, redirecting its home dir to tmpHome. + * Returns the tmpHome for inspection. + */ +function runClaudeGlobalInstall(claudeHome) { + const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-769-home-')); + + const prevCwd = process.cwd(); + const prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK; + + process.env.CLAUDE_CONFIG_DIR = claudeHome; + process.env.HOME = isolatedHome; + process.env.USERPROFILE = isolatedHome; + process.env.GSD_SKIP_STALE_SDK_CHECK = '1'; + process.chdir(REPO_ROOT); + + try { + install(true, 'claude'); + } finally { + process.chdir(prevCwd); + if (prevClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK; + else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale; + cleanup(isolatedHome); + } + + return claudeHome; +} + +// ─── describe 1: Source command files have correct frontmatter ──────────────── + +describe('#769 source commands: heavy skills have context: fork and effort: xhigh', () => { + test('commands/gsd/autonomous.md has context: fork', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md')); + assert.match(fm, /^context:[ \t]*fork$/m, + `autonomous.md frontmatter must have context: fork\nActual:\n${fm}`); + }); + + test('commands/gsd/autonomous.md has effort: xhigh', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md')); + assert.match(fm, /^effort:[ \t]*xhigh$/m, + `autonomous.md frontmatter must have effort: xhigh\nActual:\n${fm}`); + }); + + test('commands/gsd/execute-phase.md has context: fork', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md')); + assert.match(fm, /^context:[ \t]*fork$/m, + `execute-phase.md frontmatter must have context: fork\nActual:\n${fm}`); + }); + + test('commands/gsd/execute-phase.md has effort: xhigh', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md')); + assert.match(fm, /^effort:[ \t]*xhigh$/m, + `execute-phase.md frontmatter must have effort: xhigh\nActual:\n${fm}`); + }); + + test('commands/gsd/plan-phase.md has context: fork', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md')); + assert.match(fm, /^context:[ \t]*fork$/m, + `plan-phase.md frontmatter must have context: fork\nActual:\n${fm}`); + }); + + test('commands/gsd/plan-phase.md has effort: xhigh', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md')); + assert.match(fm, /^effort:[ \t]*xhigh$/m, + `plan-phase.md frontmatter must have effort: xhigh\nActual:\n${fm}`); + }); +}); + +describe('#769 source commands: quick-status skills have effort: low', () => { + test('commands/gsd/progress.md has effort: low', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'progress.md')); + assert.match(fm, /^effort:[ \t]*low$/m, + `progress.md frontmatter must have effort: low\nActual:\n${fm}`); + }); + + test('commands/gsd/stats.md has effort: low', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'stats.md')); + assert.match(fm, /^effort:[ \t]*low$/m, + `stats.md frontmatter must have effort: low\nActual:\n${fm}`); + }); +}); + +// ─── describe 2: convertClaudeCommandToClaudeSkill preserves new fields ─────── + +describe('#769 convertClaudeCommandToClaudeSkill: preserves context and effort fields', () => { + test('preserves context: fork in emitted SKILL.md frontmatter', () => { + const input = [ + '---', + 'name: gsd:test-heavy', + 'description: Test heavy skill', + 'context: fork', + 'effort: xhigh', + 'allowed-tools:', + ' - Read', + ' - Bash', + '---', + '', + 'Heavy skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.match(fm, /^context:[ \t]*fork$/m, + `SKILL.md frontmatter must include context: fork\nActual frontmatter:\n${fm}`); + }); + + test('preserves effort: xhigh in emitted SKILL.md frontmatter', () => { + const input = [ + '---', + 'name: gsd:test-heavy', + 'description: Test heavy skill', + 'context: fork', + 'effort: xhigh', + 'allowed-tools:', + ' - Read', + ' - Bash', + '---', + '', + 'Heavy skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.match(fm, /^effort:[ \t]*xhigh$/m, + `SKILL.md frontmatter must include effort: xhigh\nActual frontmatter:\n${fm}`); + }); + + test('preserves effort: low in emitted SKILL.md frontmatter', () => { + const input = [ + '---', + 'name: gsd:test-light', + 'description: Test light skill', + 'effort: low', + 'allowed-tools:', + ' - Read', + '---', + '', + 'Light skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-light'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.match(fm, /^effort:[ \t]*low$/m, + `SKILL.md frontmatter must include effort: low\nActual frontmatter:\n${fm}`); + }); + + test('does NOT emit context: or effort: when absent from source', () => { + const input = [ + '---', + 'name: gsd:test-plain', + 'description: Plain skill without context or effort', + 'allowed-tools:', + ' - Read', + '---', + '', + 'Plain skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-plain'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.doesNotMatch(fm, /^context:/m, + `SKILL.md must not emit context: when absent from source\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:/m, + `SKILL.md must not emit effort: when absent from source\nActual:\n${fm}`); + }); +}); + +// ─── describe 3: Claude global install — SKILL.md files include new fields ──── + +describe('#769 Claude global install: SKILL.md files preserve context: fork and effort:', () => { + let tmpDir; + let claudeHome; + + beforeEach(() => { + tmpDir = makeTmpDir('gsd-769-claude-'); + claudeHome = path.join(tmpDir, 'claude-home'); + fs.mkdirSync(claudeHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-autonomous SKILL.md has context: fork after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = path.join(claudeHome, 'skills', 'gsd-autonomous', 'SKILL.md'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^context:[ \t]*fork$/m, + `gsd-autonomous SKILL.md must have context: fork\nActual:\n${fm}`); + }); + + test('gsd-autonomous SKILL.md has effort: xhigh after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = path.join(claudeHome, 'skills', 'gsd-autonomous', 'SKILL.md'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*xhigh$/m, + `gsd-autonomous SKILL.md must have effort: xhigh\nActual:\n${fm}`); + }); + + test('gsd-execute-phase SKILL.md has context: fork after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = path.join(claudeHome, 'skills', 'gsd-execute-phase', 'SKILL.md'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^context:[ \t]*fork$/m, + `gsd-execute-phase SKILL.md must have context: fork\nActual:\n${fm}`); + }); + + test('gsd-execute-phase SKILL.md has effort: xhigh after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = path.join(claudeHome, 'skills', 'gsd-execute-phase', 'SKILL.md'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*xhigh$/m, + `gsd-execute-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`); + }); + + test('gsd-plan-phase SKILL.md has context: fork after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = path.join(claudeHome, 'skills', 'gsd-plan-phase', 'SKILL.md'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^context:[ \t]*fork$/m, + `gsd-plan-phase SKILL.md must have context: fork\nActual:\n${fm}`); + }); + + test('gsd-plan-phase SKILL.md has effort: xhigh after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = path.join(claudeHome, 'skills', 'gsd-plan-phase', 'SKILL.md'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*xhigh$/m, + `gsd-plan-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`); + }); + + test('gsd-progress SKILL.md has effort: low after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = path.join(claudeHome, 'skills', 'gsd-progress', 'SKILL.md'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*low$/m, + `gsd-progress SKILL.md must have effort: low\nActual:\n${fm}`); + }); + + test('gsd-stats SKILL.md has effort: low after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = path.join(claudeHome, 'skills', 'gsd-stats', 'SKILL.md'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*low$/m, + `gsd-stats SKILL.md must have effort: low\nActual:\n${fm}`); + }); +}); diff --git a/tests/enh-772-codex-hook-events.test.cjs b/tests/enh-772-codex-hook-events.test.cjs new file mode 100644 index 000000000..528d1d6a1 --- /dev/null +++ b/tests/enh-772-codex-hook-events.test.cjs @@ -0,0 +1,411 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Enhancement #772: Adopt new stable Codex hook events + commandWindows for + * Windows parity. + * + * Codex CLI (rust-v0.137.0) stabilised the full hook-event set. This suite + * asserts that a Codex install: + * + * (a) Registers the 3 new high-value hook events in hooks.json: + * - SubagentStart — inject context / GSD_AGENT_NAME awareness at subagent open + * - Stop — post-session context headroom tracking + * - PostToolUse — mirror the Claude Code PostToolUse context monitor + * + * (b) Emits `commandWindows` in the SessionStart hooks.json entry so that + * Windows users get the .cmd shim path and non-Windows users get the POSIX + * node runner command. Both fields are present in the same entry; Codex picks + * the right one per its HookHandlerConfig schema + * (codex-rs/config/src/hook_config.rs: commandWindows / command_windows alias). + * + * Note: UserPromptSubmit is NOT wired (same rationale as Qwen #788 — the + * gsd-prompt-guard handler exits unless tool_name is Write|Edit, so it would be + * a silent no-op for the UserPromptSubmit payload shape). + * + * Test strategy: + * - Test new event registration via ensureCodexHooksJsonEvent() directly + * (mirrors the #3426 pattern of testing ensureCodexHooksJsonSessionStart + * directly with a stub hook file — avoids full install() migration dance). + * - Test commandWindows via ensureCodexHooksJsonSessionStart() directly. + * - IR-first discipline: assert on the structured result, not rendered text. + * + * Verified hook event schema: + * https://github.com/openai/codex/blob/main/codex-rs/protocol/src/protocol.rs + * https://github.com/openai/codex/blob/main/codex/codex-rs/config/src/hook_config.rs + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const INSTALL = require('../bin/install.js'); +const { + ensureCodexHooksJsonSessionStart, + ensureCodexHooksJsonEvent, + removeCodexHooksJsonEvent, + reconcileCodexHooksJsonEvent, +} = INSTALL; +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Extract all hook handler entries (full objects with type/command/etc.) for + * `eventName` from a hooks.json object (flat or nested-hooks shape). + */ +function hooksJsonHandlersForEvent(hooksJson, eventName) { + if (!hooksJson || typeof hooksJson !== 'object') return []; + const table = + hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks) + ? hooksJson.hooks + : hooksJson; + if (!Array.isArray(table[eventName])) return []; + return table[eventName].flatMap(entry => + Array.isArray(entry && entry.hooks) ? entry.hooks : [] + ); +} + +function readHooksJson(targetDir) { + const p = path.join(targetDir, 'hooks.json'); + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function stubHookFile(targetDir, hookName) { + const hooksDest = path.join(targetDir, 'hooks'); + fs.mkdirSync(hooksDest, { recursive: true }); + const dest = path.join(hooksDest, hookName); + if (!fs.existsSync(dest)) { + fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n'); + try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } + } +} + +// ─── Suite 1: ensureCodexHooksJsonEvent export surface ─────────────────────── + +describe('enh-772: export surface — new functions are exported', () => { + test('ensureCodexHooksJsonEvent is a function', () => { + assert.strictEqual(typeof ensureCodexHooksJsonEvent, 'function', + 'ensureCodexHooksJsonEvent must be exported from bin/install.js'); + }); + + test('removeCodexHooksJsonEvent is a function', () => { + assert.strictEqual(typeof removeCodexHooksJsonEvent, 'function', + 'removeCodexHooksJsonEvent must be exported from bin/install.js'); + }); + + test('reconcileCodexHooksJsonEvent is a function', () => { + assert.strictEqual(typeof reconcileCodexHooksJsonEvent, 'function', + 'reconcileCodexHooksJsonEvent must be exported from bin/install.js'); + }); +}); + +// ─── Suite 2: ensureCodexHooksJsonEvent registers new events ───────────────── + +describe('enh-772: ensureCodexHooksJsonEvent registers SubagentStart, Stop, PostToolUse', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-events-'); + stubHookFile(tmpDir, 'gsd-context-monitor.js'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { + test(`${eventName}: ensureCodexHooksJsonEvent writes hooks.json`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + const result = ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + assert.ok(result && result.path, `result must have path for ${eventName}`); + assert.ok(result.wrote || result.changed, + `ensureCodexHooksJsonEvent must write or change hooks.json for ${eventName}`); + assert.ok(fs.existsSync(path.join(tmpDir, 'hooks.json')), + `hooks.json must exist after registering ${eventName}`); + }); + + test(`${eventName}: hooks.json contains the event entry`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.ok(handlers.length > 0, + `Expected ${eventName} entry in hooks.json; got: ${JSON.stringify(hooksJson)}`); + }); + + test(`${eventName}: hook entry uses gsd-context-monitor`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.ok( + handlers.some(h => h.command && h.command.includes('gsd-context-monitor')), + `${eventName} hook must use gsd-context-monitor; got: ${JSON.stringify(handlers)}` + ); + }); + + test(`${eventName}: hook entry has type: 'command'`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor')); + assert.strictEqual(entry && entry.type, 'command', + `${eventName} hook entry must have type 'command'`); + }); + + test(`${eventName}: hook entry has timeout: 10`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor')); + assert.strictEqual(entry && entry.timeout, 10, + `${eventName} hook entry must have timeout 10`); + }); + } + + test('null absoluteRunner returns unchanged result without writing', () => { + const result = ensureCodexHooksJsonEvent(tmpDir, 'SubagentStart', { + absoluteRunner: null, + platform: 'linux', + }); + assert.strictEqual(result.changed, false, + 'null runner must return changed: false'); + assert.ok(!fs.existsSync(path.join(tmpDir, 'hooks.json')), + 'hooks.json must NOT be written when runner is null'); + }); +}); + +// ─── Suite 3: commandWindows parity in SessionStart ────────────────────────── + +describe('enh-772: commandWindows parity — ensureCodexHooksJsonSessionStart emits commandWindows', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-cmdwin-'); + stubHookFile(tmpDir, 'gsd-check-update.js'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // commandWindows is ONLY emitted on win32 platform (where the .cmd shim is also + // written). On POSIX platforms, commandWindows is omitted to avoid pointing Windows + // Codex at a non-existent .cmd file (the shim is only present after a native Windows + // install that runs buildCodexHookWindowsShimIR and atomicWriteFileSync). + + test('POSIX platform: commandWindows is NOT emitted (shim not written on POSIX)', () => { + const fakeRunner = '"/usr/local/bin/node"'; + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + assert.ok(result && result.wrote, 'must write hooks.json on linux'); + + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); + assert.ok(handlers.length > 0, `Expected SessionStart handlers; got: ${JSON.stringify(hooksJson)}`); + + const entry = handlers[0]; + assert.ok( + entry.commandWindows === undefined, + `commandWindows must NOT be emitted on POSIX (shim not written); got: ${JSON.stringify(entry)}` + ); + }); + + test('POSIX platform: command references gsd-check-update.js (not .cmd)', () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); + const entry = handlers[0]; + assert.ok( + entry.command && entry.command.includes('gsd-check-update'), + `POSIX command must reference gsd-check-update; got: ${entry.command}` + ); + assert.ok( + !entry.command.endsWith('.cmd') && !entry.command.endsWith('.cmd"'), + `POSIX command must not end with .cmd; got: ${entry.command}` + ); + }); + + test('null absoluteRunner: no commandWindows emitted, no write', () => { + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: null, + platform: 'linux', + }); + assert.strictEqual(result.changed, false, 'null runner must return changed: false'); + const hooksJson = readHooksJson(tmpDir); + if (hooksJson) { + const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); + for (const h of handlers) { + assert.ok(!h.commandWindows, + `commandWindows must not be present when runner is null; got: ${JSON.stringify(h)}`); + } + } + }); + + test('Windows platform: SessionStart hook is written with commandWindows pointing to .cmd shim', () => { + // On win32, both `command` and `commandWindows` use the .cmd shim path + // (because managedCommand = shimIR.hookCommand = .cmd path, and + // commandWindows = same .cmd path). This ensures Codex picks the .cmd + // on Windows regardless of which field it reads. + const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'win32', + }); + // The shim write and hooks.json write should succeed in the tmp dir. + if (result.wrote) { + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); + assert.ok(handlers.length > 0, + `SessionStart must be registered on Windows path; got: ${JSON.stringify(hooksJson)}`); + const entry = handlers[0]; + assert.ok(typeof entry.commandWindows === 'string', + `commandWindows must be present on Windows path; got: ${JSON.stringify(entry)}`); + // commandWindows should reference the .cmd shim + assert.ok( + entry.commandWindows.includes('gsd-check-update') && entry.commandWindows.includes('.cmd'), + `commandWindows must reference gsd-check-update.cmd on win32; got: ${entry.commandWindows}` + ); + } + }); +}); + +// ─── Suite 4: idempotency ──────────────────────────────────────────────────── + +describe('enh-772: ensureCodexHooksJsonEvent is idempotent', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-idem-'); + stubHookFile(tmpDir, 'gsd-context-monitor.js'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { + test(`${eventName}: calling twice does not duplicate hook entries`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + const opts = { absoluteRunner: fakeRunner, platform: 'linux' }; + + ensureCodexHooksJsonEvent(tmpDir, eventName, opts); + ensureCodexHooksJsonEvent(tmpDir, eventName, opts); + + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.strictEqual(handlers.length, 1, + `${eventName} should have exactly 1 hook handler after idempotent re-register; got ${handlers.length}: ${JSON.stringify(handlers)}`); + }); + } +}); + +// ─── Suite 5: removeCodexHooksJsonEvent ────────────────────────────────────── + +describe('enh-772: removeCodexHooksJsonEvent removes managed entries', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-remove-'); + stubHookFile(tmpDir, 'gsd-context-monitor.js'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { + test(`${eventName}: removeCodexHooksJsonEvent removes the managed entry`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + + // Verify it was registered + let hooksJson = readHooksJson(tmpDir); + let handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.ok(handlers.length > 0, `${eventName} must be registered before removal`); + + // Remove + const result = removeCodexHooksJsonEvent(tmpDir, eventName); + assert.ok(result.changed || result.wrote, + `removeCodexHooksJsonEvent must change hooks.json for ${eventName}`); + + hooksJson = readHooksJson(tmpDir); + if (hooksJson) { + handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.strictEqual(handlers.length, 0, + `After removal, ${eventName} should have 0 handlers; got: ${JSON.stringify(handlers)}`); + } + }); + } +}); + +// ─── Suite 6: reconcileCodexHooksJsonEvent preserves user entries ───────────── + +describe('enh-772: reconcileCodexHooksJsonEvent preserves user-owned entries', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-preserve-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('user-owned SubagentStart entry is preserved when GSD entry is registered', () => { + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + const userEntry = { + hooks: [{ type: 'command', command: 'my-custom-hook.sh' }] + }; + fs.writeFileSync(hooksJsonPath, JSON.stringify({ + SubagentStart: [userEntry] + }, null, 2) + '\n'); + + reconcileCodexHooksJsonEvent(tmpDir, 'SubagentStart', { + managedCommand: '"/usr/local/bin/node" "/home/me/.codex/hooks/gsd-context-monitor.js"', + }); + + const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + const table = hooksJson.hooks || hooksJson; + const entries = Array.isArray(table.SubagentStart) ? table.SubagentStart : []; + // Should have 2 entries: user entry + GSD entry + assert.ok(entries.length >= 2, + `User entry must be preserved; got entries: ${JSON.stringify(entries)}`); + // User entry must still be present + const userEntryStillPresent = entries.some(e => + Array.isArray(e.hooks) && e.hooks.some(h => h.command === 'my-custom-hook.sh') + ); + assert.ok(userEntryStillPresent, + `User entry must survive GSD registration; entries: ${JSON.stringify(entries)}`); + }); +}); diff --git a/tests/enh-773-codex-exec-automation-flags.test.cjs b/tests/enh-773-codex-exec-automation-flags.test.cjs new file mode 100644 index 000000000..c3697de74 --- /dev/null +++ b/tests/enh-773-codex-exec-automation-flags.test.cjs @@ -0,0 +1,68 @@ +'use strict'; + +// allow-test-rule: source-text-is-the-product +// Workflow markdown is runtime contract; these assertions verify that +// automated codex exec invocations carry the correct automation flags. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +describe('enh-773: automated codex exec invocations include --ephemeral and --dangerously-bypass-hook-trust', () => { + const workflow = fs.readFileSync( + path.join(process.cwd(), 'gsd-core', 'workflows', 'review.md'), + 'utf8' + ); + + // Extract all codex exec invocation lines from code fences + const codexExecLines = workflow + .split('\n') + .filter((line) => line.includes('codex exec')); + + test('review.md contains at least one codex exec invocation', () => { + assert.ok( + codexExecLines.length > 0, + 'review.md must contain at least one codex exec invocation' + ); + }); + + test('every codex exec invocation includes --ephemeral', () => { + for (const line of codexExecLines) { + assert.ok( + line.includes('--ephemeral'), + `codex exec invocation is missing --ephemeral:\n ${line.trim()}` + ); + } + }); + + test('every codex exec invocation includes --dangerously-bypass-hook-trust', () => { + for (const line of codexExecLines) { + assert.ok( + line.includes('--dangerously-bypass-hook-trust'), + `codex exec invocation is missing --dangerously-bypass-hook-trust:\n ${line.trim()}` + ); + } + }); + + test('--ephemeral appears before the prompt argument (flag ordering)', () => { + for (const line of codexExecLines) { + const ephemeralPos = line.indexOf('--ephemeral'); + const promptPos = line.indexOf(' - '); + if (promptPos === -1) continue; // no stdin prompt arg on this line + assert.ok( + ephemeralPos < promptPos, + `--ephemeral must appear before the stdin prompt argument:\n ${line.trim()}` + ); + } + }); + + test('--skip-git-repo-check is preserved alongside automation flags', () => { + for (const line of codexExecLines) { + assert.ok( + line.includes('--skip-git-repo-check'), + `codex exec invocation lost --skip-git-repo-check:\n ${line.trim()}` + ); + } + }); +}); diff --git a/tests/enh-778-cross-runtime-command-enrichment.test.cjs b/tests/enh-778-cross-runtime-command-enrichment.test.cjs new file mode 100644 index 000000000..4c724efe7 --- /dev/null +++ b/tests/enh-778-cross-runtime-command-enrichment.test.cjs @@ -0,0 +1,227 @@ +// allow-test-rule: source-text-is-the-product +// Reads .md/SKILL.md/.toml product files whose deployed text IS what the +// runtime loads — testing text content tests the deployed contract. + +/** + * GSD Tools Tests — #778 cross-runtime command enrichment. + * + * Two independently-verified, additive sub-features: + * (b) Qwen Code skills: numeric `priority` field (higher sorts earlier in the + * /skills TUI listing per the Qwen skills spec). Scoped to runtime='qwen'. + * (c) Gemini custom-command TOML: $ARGUMENTS → {{args}} interpolation, and a + * fixed `!{cat .planning/STATE.md}` live-state injection on the + * situational `progress` command (injection-safe — no interpolated input). + * + * The OpenCode sub-feature (per-command model/agent/subtask/variant) is + * intentionally NOT implemented — see PR description: `model` reintroduces the + * #1156 ProviderModelNotFoundError regression for non-Anthropic OpenCode users, + * `subtask`/`agent` change execution semantics for GSD's interactive commands, + * and `variant` is not in the OpenCode command schema. + * + * Uses node:test and node:assert (NOT Jest). + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { + convertClaudeCommandToClaudeSkill, + convertClaudeToGeminiMarkdown, + install, +} = require('../bin/install.js'); + +// ─── (b) Qwen Code: priority ordering ─────────────────────────────────────── + +describe('#778 (b) Qwen skills priority', () => { + const mk = (name, desc, body) => + ['---', `name: gsd:${name}`, `description: ${desc}`, '---', '', body].join('\n'); + + test('emits numeric priority for a core-loop command (runtime=qwen)', () => { + const result = convertClaudeCommandToClaudeSkill( + mk('plan-phase', 'Plan a phase', 'Body.'), + 'gsd-plan-phase', + 'qwen', + [] + ); + const m = result.match(/^priority:\s*(\d+)\s*$/m); + assert.ok(m, 'priority field present for gsd-plan-phase'); + assert.equal(Number(m[1]) > 0, true, 'priority is a positive number'); + }); + + test('core loop ranks higher than mid-tier (higher = earlier per spec)', () => { + const np = convertClaudeCommandToClaudeSkill( + mk('new-project', 'Start a project', 'Body.'), 'gsd-new-project', 'qwen', [] + ).match(/^priority:\s*(\d+)/m); + const help = convertClaudeCommandToClaudeSkill( + mk('help', 'Help', 'Body.'), 'gsd-help', 'qwen', [] + ).match(/^priority:\s*(\d+)/m); + assert.ok(np && help, 'both core and mid-tier get a priority'); + assert.ok( + Number(np[1]) > Number(help[1]), + 'new-project (core) sorts earlier than help (utility) — higher value' + ); + }); + + test('utility command NOT in the priority map gets no priority field', () => { + const result = convertClaudeCommandToClaudeSkill( + mk('stats', 'Show stats', 'Body.'), 'gsd-stats', 'qwen', [] + ); + assert.ok(!/^priority:/m.test(result), 'no priority emitted for unmapped utility'); + }); + + test('does NOT emit priority for non-qwen runtimes (scoped to qwen)', () => { + for (const rt of [null, 'claude', 'hermes']) { + const result = convertClaudeCommandToClaudeSkill( + mk('plan-phase', 'Plan a phase', 'Body.'), 'gsd-plan-phase', rt, [] + ); + assert.ok(!/^priority:/m.test(result), `no priority for runtime=${rt}`); + } + }); +}); + +// ─── (c) Gemini: {{args}} interpolation ───────────────────────────────────── + +describe('#778 (c) Gemini {{args}} interpolation', () => { + const cmd = (body) => + ['---', 'name: gsd:demo', 'description: Demo', '---', '', body].join('\n'); + + test('maps $ARGUMENTS to {{args}} in the TOML prompt', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('Operate on $ARGUMENTS now.'), + { isCommand: true, commandName: 'demo' } + ); + assert.ok(out.includes('{{args}}'), '{{args}} present'); + assert.ok(!out.includes('$ARGUMENTS'), 'literal $ARGUMENTS removed'); + assert.ok(out.startsWith('description =') || out.includes('prompt ='), 'TOML shape'); + }); + + test('command without $ARGUMENTS gets no injected {{args}}', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('No arguments referenced here.'), + { isCommand: true, commandName: 'demo' } + ); + assert.ok(!out.includes('{{args}}'), 'no spurious {{args}}'); + }); + + test('non-command Gemini content is not TOML-converted and keeps $ARGUMENTS', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('Reference $ARGUMENTS.'), + { isCommand: false } + ); + // isCommand:false keeps markdown — no TOML wrap and no {{args}} mapping + // (the $ARGUMENTS→{{args}} translation is scoped to the TOML command path). + assert.ok(!out.startsWith('prompt ='), 'not wrapped as TOML prompt'); + assert.ok(out.includes('$ARGUMENTS'), '$ARGUMENTS left intact for non-command content'); + assert.ok(!out.includes('{{args}}'), 'no {{args}} injected outside the command path'); + }); +}); + +// ─── (c) Gemini: end-to-end install wiring ────────────────────────────────── +// Proves the install path derives the per-command name from the file stem so a +// regression in the call-site wiring (not just the converter) is caught. + +describe('#778 (c) Gemini install wiring (end-to-end)', () => { + let tmpDir; + let tmpHome; + let prevCwd; + let prevHome; + let prevUserprofile; + + beforeEach(() => { + tmpDir = createTempDir('gsd-enh778-gem-'); + tmpHome = createTempDir('gsd-enh778-home-'); + prevCwd = process.cwd(); + prevHome = process.env.HOME; + prevUserprofile = process.env.USERPROFILE; + process.chdir(tmpDir); + // Isolate HOME so a real ~/.gemini/commands/gsd/ doesn't trigger the #3037 + // local-install conflict-skip path. + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + }); + + afterEach(() => { + process.chdir(prevCwd); + if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; + if (prevUserprofile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserprofile; + cleanup(tmpDir); + cleanup(tmpHome); + }); + + test('installed progress.toml carries the !{} block; arg-bearing commands get {{args}}', () => { + const oldLog = console.log; + console.log = () => {}; + try { + install(false, 'gemini'); + } finally { + console.log = oldLog; + } + + const commandsDir = path.join(tmpDir, '.gemini', 'commands', 'gsd'); + const progressToml = path.join(commandsDir, 'progress.toml'); + assert.ok(fs.existsSync(progressToml), 'progress.toml installed'); + const progress = fs.readFileSync(progressToml, 'utf8'); + // Proves commandName was derived as 'progress' from the file stem. + assert.ok( + progress.includes('!{cat .planning/STATE.md 2>/dev/null}'), + 'progress.toml has the live-state shell block' + ); + + // A non-situational command must NOT receive the shell block. + const helpToml = path.join(commandsDir, 'help.toml'); + if (fs.existsSync(helpToml)) { + assert.ok(!fs.readFileSync(helpToml, 'utf8').includes('!{'), 'help.toml has no shell block'); + } + + // At least one installed command must use {{args}} and none may retain a + // literal $ARGUMENTS (every command body's $ARGUMENTS is translated). + const tomls = fs.readdirSync(commandsDir).filter((f) => f.endsWith('.toml')); + const withArgs = tomls.filter((f) => + fs.readFileSync(path.join(commandsDir, f), 'utf8').includes('{{args}}')); + const withLiteral = tomls.filter((f) => + fs.readFileSync(path.join(commandsDir, f), 'utf8').includes('$ARGUMENTS')); + assert.ok(withArgs.length > 0, 'at least one installed command interpolates {{args}}'); + assert.equal(withLiteral.length, 0, 'no installed command retains literal $ARGUMENTS'); + }); +}); + +// ─── (c) Gemini: !{...} live-state injection (progress) ───────────────────── + +describe('#778 (c) Gemini !{} live-state injection', () => { + const cmd = (name) => + ['---', `name: gsd:${name}`, `description: ${name}`, '---', '', 'Workflow body.'].join('\n'); + + test('progress command injects a fixed !{cat .planning/STATE.md} block', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('progress'), + { isCommand: true, commandName: 'progress' } + ); + assert.ok(out.includes('!{cat .planning/STATE.md'), 'STATE.md injection present'); + }); + + test('non-progress commands get no !{} shell block', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('help'), + { isCommand: true, commandName: 'help' } + ); + assert.ok(!out.includes('!{'), 'no shell block for non-situational command'); + }); + + test('SECURITY: the !{} block interpolates NO user input ({{args}})', () => { + const out = convertClaudeToGeminiMarkdown( + ['---', 'name: gsd:progress', 'description: progress', '---', '', + 'Body uses $ARGUMENTS too.'].join('\n'), + { isCommand: true, commandName: 'progress' } + ); + const blocks = out.match(/!\{([^}]*)\}/g) || []; + assert.equal(blocks.length, 1, 'exactly one shell block'); + assert.ok(!/\{\{args\}\}/.test(blocks[0]), 'no {{args}} inside the shell block'); + assert.ok(/^!\{cat \.planning\/STATE\.md/.test(blocks[0]), 'fixed cat command only'); + }); +}); diff --git a/tests/enh-789-codebuddy-commands.test.cjs b/tests/enh-789-codebuddy-commands.test.cjs new file mode 100644 index 000000000..56ca77e69 --- /dev/null +++ b/tests/enh-789-codebuddy-commands.test.cjs @@ -0,0 +1,259 @@ +// allow-test-rule: source-text-is-the-product +// Workflow .md / command .md / SKILL.md files — their text IS what the runtime +// loads. Testing emitted text tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Regression guard — enh(#789): elevate CodeBuddy slash-command surface. + * + * CodeBuddy (Tencent, @tencent-ai/codebuddy-code) reads user-level surfaces + * (https://www.codebuddy.ai/docs/cli/slash-commands, /skills): + * - commands/gsd-.md — slash commands shown in the '/' menu + * - skills/gsd-/SKILL.md — model-invocable skills + * + * Before #789 gsd emitted only skills/. Because CodeBuddy skills default to + * user-invocable:true (appear in '/'), emitting a commands/ surface AND leaving + * skills user-invocable would duplicate every /gsd-* entry. #789 therefore: + * 1. emits commands/gsd-.md (the '/' surface, peer-consistent with + * Cursor #785 and Augment #790), + * 2. marks skills user-invocable:false so they become model-invocable + * background knowledge and the commands/ surface is the sole '/' surface. + * + * Subagents are already emitted via the generic agents block + convertClaude + * AgentToCodebuddyAgent (~/.codebuddy/agents/), so #789 adds no agents change. + * + * mcp.json is intentionally NOT written: gsd ships no MCP server, and CodeBuddy's + * mcp.json holds an `mcpServers` map of *external* servers to connect to — + * there is nothing for gsd to register. Same exclusion as #784/#785/#790. + */ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { + installRuntimeArtifacts, + uninstallRuntimeArtifacts, + convertClaudeCommandToCodebuddyCommand, + convertClaudeCommandToCodebuddySkill, +} = require('../bin/install.js'); +const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); +const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); +const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); +const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); + +// ─── Layout contract ───────────────────────────────────────────────────────── + +describe('enh-789 — codebuddy layout has commands + skills kinds', () => { + test('resolveRuntimeArtifactLayout codebuddy returns 2 kinds', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); + assert.strictEqual(layout.kinds.length, 2, 'codebuddy must have exactly 2 artifact kinds'); + const kindNames = layout.kinds.map(k => k.kind).sort(); + assert.deepStrictEqual(kindNames, ['commands', 'skills']); + }); + + test('codebuddy commands kind targets commands/ with gsd- prefix', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'must have commands kind'); + assert.strictEqual(commandsKind.destSubpath, 'commands'); + assert.strictEqual(commandsKind.prefix, 'gsd-'); + assert.strictEqual(typeof commandsKind.stage, 'function'); + }); + + test('codebuddy skills kind targets skills/ with gsd- prefix', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'must have skills kind'); + assert.strictEqual(skillsKind.destSubpath, 'skills'); + assert.strictEqual(skillsKind.prefix, 'gsd-'); + }); +}); + +// ─── Command converter contract ────────────────────────────────────────────── + +describe('enh-789 — convertClaudeCommandToCodebuddyCommand', () => { + const SRC = [ + '---', + 'name: gsd:new-project', + 'description: Initialize a project', + 'argument-hint: "[name]"', + 'allowed-tools:', + ' - Read', + '---', + '', + 'Use .claude/skills/ and run /gsd:help. Claude Code reads CLAUDE.md.', + '', + ].join('\n'); + + test('emits a description-only frontmatter (no Claude-specific name: gsd:)', () => { + const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); + assert.ok(out.startsWith('---\n'), 'must begin with frontmatter'); + assert.ok(/^description:/m.test(out), 'must carry a description field'); + assert.ok(!out.includes('name: gsd:new-project'), 'must drop Claude colon-form name field'); + }); + + test('preserves a present argument-hint (CodeBuddy supports it)', () => { + const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); + assert.ok(/^argument-hint:\s*["']?\[name\]["']?\s*$/m.test(out), + `argument-hint must be carried through when present in source. Got:\n${out}`); + }); + + test('converts body Claude-isms to CodeBuddy equivalents', () => { + const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); + assert.ok(out.includes('.codebuddy/skills/'), out); + assert.ok(out.includes('/gsd-help'), out); + assert.ok(out.includes('CODEBUDDY.md'), out); + assert.ok(!/\bClaude Code\b/.test(out), 'must rebrand "Claude Code"'); + }); +}); + +describe('enh-789 — skills marked user-invocable:false', () => { + test('convertClaudeCommandToCodebuddySkill emits user-invocable: false', () => { + const src = [ + '---', + 'name: gsd:help', + 'description: Show help', + '---', + '', + '# body', + '', + ].join('\n'); + const out = convertClaudeCommandToCodebuddySkill(src, 'gsd-help'); + assert.ok(/^user-invocable:\s*false\s*$/m.test(out), + `SKILL.md frontmatter must hide skill from '/' menu (user-invocable: false). Got:\n${out}`); + }); +}); + +// ─── Install contract ──────────────────────────────────────────────────────── + +describe('enh-789 — installRuntimeArtifacts codebuddy emits commands and skills', () => { + test('global codebuddy install: commands/gsd-help.md and skills/gsd-help/SKILL.md exist', (t) => { + const configDir = createTempDir('gsd-enh789-codebuddy-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const commandsDir = path.join(configDir, 'commands'); + assert.ok(fs.existsSync(commandsDir), 'commands/ dir must exist'); + const cmdFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok(cmdFiles.length > 0, 'at least one gsd-*.md command file must be installed'); + assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'commands/gsd-help.md must exist'); + + const skillsDir = path.join(configDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist'); + assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), 'skills/gsd-help/SKILL.md must exist'); + }); + + test('installed commands/gsd-help.md is CodeBuddy-compatible (no raw ~/.claude/, rebranded)', (t) => { + const configDir = createTempDir('gsd-enh789-content-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const helpCmd = path.join(configDir, 'commands', 'gsd-help.md'); + const content = fs.readFileSync(helpCmd, 'utf8'); + assert.ok(!content.includes('~/.claude/'), 'commands must not contain raw ~/.claude/ refs'); + assert.ok(content.startsWith('---'), 'commands must carry frontmatter'); + }); + + test('installed skills/gsd-help/SKILL.md is hidden from the / menu', (t) => { + const configDir = createTempDir('gsd-enh789-skillhide-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const skill = fs.readFileSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'), 'utf8'); + assert.ok(/^user-invocable:\s*false\s*$/m.test(skill), + 'installed SKILL.md must set user-invocable: false'); + }); + + test('command count matches skill count (profile parity)', (t) => { + const configDir = createTempDir('gsd-enh789-parity-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const cmdCount = fs.readdirSync(path.join(configDir, 'commands')) + .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; + const skillCount = fs.readdirSync(path.join(configDir, 'skills'), { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; + assert.strictEqual(cmdCount, skillCount, 'command count must equal skill count for same profile'); + }); + + test('full profile install: no $HOME/.codebuddy or ~/.codebuddy leak in any command', (t) => { + // The codebuddy converter rewrites `.claude/` → `.codebuddy/`, so source + // refs like `@$HOME/.claude/gsd-core/...` (e.g. plan-review-convergence.md) + // must be normalized to the install target — not left as $HOME/.codebuddy. + const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + const configDir = createTempDir('gsd-enh789-noleak-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL); + + const commandsDir = path.join(configDir, 'commands'); + for (const f of fs.readdirSync(commandsDir).filter(n => n.endsWith('.md'))) { + const content = fs.readFileSync(path.join(commandsDir, f), 'utf8'); + assert.ok(!content.includes('$HOME/.codebuddy'), `${f} must not leak $HOME/.codebuddy`); + assert.ok(!content.includes('~/.codebuddy'), `${f} must not leak ~/.codebuddy`); + assert.ok(!content.includes('.claude/'), `${f} must not retain raw .claude/ refs`); + } + }); + + test('full profile install does NOT mutate source commands/gsd/ files', (t) => { + const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + assert.strictEqual(RESOLVED_FULL.skills, '*', 'full profile must have skills === "*"'); + + const configDir = createTempDir('gsd-enh789-full-'); + t.after(() => cleanup(configDir)); + + const srcHelpPath = path.join(REAL_COMMANDS_DIR, 'help.md'); + const before = fs.readFileSync(srcHelpPath, 'utf8'); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL); + + const after = fs.readFileSync(srcHelpPath, 'utf8'); + assert.strictEqual(before, after, 'source commands/gsd/help.md must not be mutated by the install'); + }); +}); + +// ─── Uninstall contract ────────────────────────────────────────────────────── + +describe('enh-789 — uninstallRuntimeArtifacts removes codebuddy commands', () => { + test('uninstall removes gsd-* commands but preserves user commands', (t) => { + const configDir = createTempDir('gsd-enh789-uninstall-'); + t.after(() => cleanup(configDir)); + + const commandsDir = path.join(configDir, 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), '# help\n'); + fs.writeFileSync(path.join(commandsDir, 'user-custom.md'), '# user\n'); + + uninstallRuntimeArtifacts('codebuddy', configDir, 'global'); + + assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'gsd-help.md must be removed'); + assert.ok(fs.existsSync(path.join(commandsDir, 'user-custom.md')), 'user-custom.md must be preserved'); + }); +}); + +// ─── mcp.json exclusion ────────────────────────────────────────────────────── + +describe('enh-789 — mcp.json excluded (gsd ships no MCP server)', () => { + test('codebuddy install does not write mcp.json / .mcp.json', (t) => { + const configDir = createTempDir('gsd-enh789-mcp-excluded-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + assert.ok(!fs.existsSync(path.join(configDir, 'mcp.json')), 'must not write mcp.json'); + assert.ok(!fs.existsSync(path.join(configDir, '.mcp.json')), 'must not write .mcp.json'); + }); +}); diff --git a/tests/install-regressions.test.cjs b/tests/install-regressions.test.cjs index 6d6239fdd..f7fb9213f 100644 --- a/tests/install-regressions.test.cjs +++ b/tests/install-regressions.test.cjs @@ -37,7 +37,7 @@ try { else process.env.GSD_TEST_MODE = savedTestMode; } -const { installRuntimeArtifacts, uninstallRuntimeArtifacts } = installExports || {}; +const { installRuntimeArtifacts, uninstallRuntimeArtifacts, mergeClaudePermissions, GSD_CLAUDE_ALLOW_PERMISSIONS, GSD_CLAUDE_DENY_PERMISSIONS } = installExports || {}; const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); @@ -304,3 +304,298 @@ describe('U3 (#2973): uninstallRuntimeArtifacts hermes migrates dev-preferences assert.strictEqual(fs.readFileSync(skillFile, 'utf8'), '# my hermes prefs\n'); }); }); + +// ─── #768 — mergeClaudePermissions: pre-populate permissions.allow/deny ────── + +describe('mergeClaudePermissions (#768): exports and permission constants', () => { + test('mergeClaudePermissions is exported', () => { + assert.strictEqual(typeof mergeClaudePermissions, 'function', + 'mergeClaudePermissions must be exported from bin/install.js'); + }); + + test('GSD_CLAUDE_ALLOW_PERMISSIONS is a non-empty array of strings', () => { + assert.ok(Array.isArray(GSD_CLAUDE_ALLOW_PERMISSIONS), + 'GSD_CLAUDE_ALLOW_PERMISSIONS must be an array'); + assert.ok(GSD_CLAUDE_ALLOW_PERMISSIONS.length > 0, + 'GSD_CLAUDE_ALLOW_PERMISSIONS must not be empty'); + for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) { + assert.strictEqual(typeof entry, 'string', `allow entry must be a string, got: ${JSON.stringify(entry)}`); + } + }); + + test('GSD_CLAUDE_DENY_PERMISSIONS is a non-empty array of strings', () => { + assert.ok(Array.isArray(GSD_CLAUDE_DENY_PERMISSIONS), + 'GSD_CLAUDE_DENY_PERMISSIONS must be an array'); + assert.ok(GSD_CLAUDE_DENY_PERMISSIONS.length > 0, + 'GSD_CLAUDE_DENY_PERMISSIONS must not be empty'); + for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) { + assert.strictEqual(typeof entry, 'string', `deny entry must be a string, got: ${JSON.stringify(entry)}`); + } + }); +}); + +describe('mergeClaudePermissions (#768): fresh settings object', () => { + test('populates permissions.allow and permissions.deny on empty settings', () => { + const settings = {}; + mergeClaudePermissions(settings); + assert.ok(Array.isArray(settings.permissions?.allow), 'permissions.allow must be an array'); + assert.ok(Array.isArray(settings.permissions?.deny), 'permissions.deny must be an array'); + for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) { + assert.ok(settings.permissions.allow.includes(entry), + `permissions.allow must contain "${entry}"`); + } + for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) { + assert.ok(settings.permissions.deny.includes(entry), + `permissions.deny must contain "${entry}"`); + } + }); + + test('includes Bash(npx gsd-core *) in allow', () => { + const settings = {}; + mergeClaudePermissions(settings); + assert.ok(settings.permissions.allow.includes('Bash(npx gsd-core *)'), + 'permissions.allow must contain Bash(npx gsd-core *)'); + }); + + test('includes planning path entries in allow', () => { + const settings = {}; + mergeClaudePermissions(settings); + assert.ok(settings.permissions.allow.includes('Read(.planning/*)'), + 'permissions.allow must contain Read(.planning/*)'); + assert.ok(settings.permissions.allow.includes('Write(.planning/*)'), + 'permissions.allow must contain Write(.planning/*)'); + }); + + test('includes STATE.md entries in allow', () => { + const settings = {}; + mergeClaudePermissions(settings); + assert.ok(settings.permissions.allow.includes('Read(STATE.md)'), + 'permissions.allow must contain Read(STATE.md)'); + assert.ok(settings.permissions.allow.includes('Write(STATE.md)'), + 'permissions.allow must contain Write(STATE.md)'); + }); + + test('includes .env denial entries in deny', () => { + const settings = {}; + mergeClaudePermissions(settings); + assert.ok(settings.permissions.deny.includes('Read(.env)'), + 'permissions.deny must contain Read(.env)'); + assert.ok(settings.permissions.deny.includes('Read(.env.*)'), + 'permissions.deny must contain Read(.env.*)'); + assert.ok(settings.permissions.deny.includes('Read(.secrets)'), + 'permissions.deny must contain Read(.secrets)'); + }); +}); + +describe('mergeClaudePermissions (#768): non-destructive merge', () => { + test('appends to existing allow/deny arrays without overwriting user entries', () => { + const settings = { + permissions: { + allow: ['Bash(git *)'], + deny: ['WebSearch'], + }, + }; + mergeClaudePermissions(settings); + // User entries must be preserved + assert.ok(settings.permissions.allow.includes('Bash(git *)'), + 'existing allow entries must be preserved'); + assert.ok(settings.permissions.deny.includes('WebSearch'), + 'existing deny entries must be preserved'); + // GSD entries must be added + assert.ok(settings.permissions.allow.includes('Bash(npx gsd-core *)'), + 'GSD allow entry must be added'); + assert.ok(settings.permissions.deny.includes('Read(.env)'), + 'GSD deny entry must be added'); + }); + + test('does not duplicate entries on repeated calls (idempotent)', () => { + const settings = {}; + mergeClaudePermissions(settings); + mergeClaudePermissions(settings); + for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) { + const count = settings.permissions.allow.filter((e) => e === entry).length; + assert.strictEqual(count, 1, `allow entry "${entry}" must appear exactly once after two merges`); + } + for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) { + const count = settings.permissions.deny.filter((e) => e === entry).length; + assert.strictEqual(count, 1, `deny entry "${entry}" must appear exactly once after two merges`); + } + }); + + test('preserves other permission sub-keys (ask, disableBypassPermissionsMode)', () => { + const settings = { + permissions: { + ask: ['Bash'], + disableBypassPermissionsMode: 'disable', + allow: [], + deny: [], + }, + }; + mergeClaudePermissions(settings); + assert.deepStrictEqual(settings.permissions.ask, ['Bash'], + 'permissions.ask must be preserved'); + assert.strictEqual(settings.permissions.disableBypassPermissionsMode, 'disable', + 'permissions.disableBypassPermissionsMode must be preserved'); + }); + + test('handles permissions with non-array allow/deny gracefully (replaces with array)', () => { + // If allow/deny exist but are not arrays (malformed settings), must not crash + // and must result in valid arrays. + const settings = { permissions: { allow: null, deny: null } }; + mergeClaudePermissions(settings); + assert.ok(Array.isArray(settings.permissions.allow)); + assert.ok(Array.isArray(settings.permissions.deny)); + assert.ok(settings.permissions.allow.includes('Bash(npx gsd-core *)')); + }); + + test('handles settings that are not plain objects (returns unchanged)', () => { + // Guard: if settings is not a plain object, do nothing + const badInputs = [null, undefined, [], 'string', 42]; + for (const bad of badInputs) { + // Must not throw + assert.doesNotThrow(() => mergeClaudePermissions(bad), + `mergeClaudePermissions must not throw on: ${JSON.stringify(bad)}`); + } + }); +}); + +describe('mergeClaudePermissions (#768): end-to-end install writes permissions to settings.json', () => { + test('--claude --global install writes GSD allow/deny entries to settings.json', (t) => { + const root = createTempDir('gsd-claude-perm-install-'); + t.after(() => cleanup(root)); + + const result = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root], + { encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } }, + ); + + assert.strictEqual(result.status, 0, + `installer exited ${result.status}\n${result.stdout}\n${result.stderr}`); + + const settingsPath = path.join(root, 'settings.json'); + assert.ok(fs.existsSync(settingsPath), 'settings.json must exist after claude install'); + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.ok(Array.isArray(settings.permissions?.allow), + 'settings.json must have permissions.allow array'); + assert.ok(Array.isArray(settings.permissions?.deny), + 'settings.json must have permissions.deny array'); + + assert.ok(settings.permissions.allow.includes('Bash(npx gsd-core *)'), + 'settings.json permissions.allow must include Bash(npx gsd-core *)'); + assert.ok(settings.permissions.allow.includes('Read(.planning/*)'), + 'settings.json permissions.allow must include Read(.planning/*)'); + assert.ok(settings.permissions.deny.includes('Read(.env)'), + 'settings.json permissions.deny must include Read(.env)'); + }); + + test('non-claude runtime (gemini) does NOT write GSD allow/deny permissions to settings.json', (t) => { + const root = createTempDir('gsd-gemini-perm-install-'); + t.after(() => cleanup(root)); + + const result = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--gemini', '--global', '--config-dir', root], + { encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } }, + ); + + assert.strictEqual(result.status, 0, + `installer exited ${result.status}\n${result.stdout}\n${result.stderr}`); + + const settingsPath = path.join(root, 'settings.json'); + // If settings.json doesn't exist, permissions are definitely not written — pass. + if (fs.existsSync(settingsPath)) { + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + const allow = settings.permissions?.allow ?? []; + assert.ok(!allow.includes('Bash(npx gsd-core *)'), + 'Gemini settings.json must NOT include Bash(npx gsd-core *) in permissions.allow'); + } + }); + + test('--claude --global reinstall is idempotent (no duplicate permission entries)', (t) => { + const root = createTempDir('gsd-claude-perm-idempotent-'); + t.after(() => cleanup(root)); + + const spawnOpts = { + encoding: 'utf8', + env: { ...process.env, HOME: root, USERPROFILE: root }, + }; + const args = [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root]; + + // First install + const r1 = spawnSync(process.execPath, args, spawnOpts); + assert.strictEqual(r1.status, 0, `first install failed: ${r1.stderr}`); + + // Second install (reinstall) + const r2 = spawnSync(process.execPath, args, spawnOpts); + assert.strictEqual(r2.status, 0, `reinstall failed: ${r2.stderr}`); + + const settings = JSON.parse(fs.readFileSync(path.join(root, 'settings.json'), 'utf8')); + for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) { + const count = (settings.permissions?.allow ?? []).filter((e) => e === entry).length; + assert.strictEqual(count, 1, + `allow entry "${entry}" must appear exactly once after two installs`); + } + for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) { + const count = (settings.permissions?.deny ?? []).filter((e) => e === entry).length; + assert.strictEqual(count, 1, + `deny entry "${entry}" must appear exactly once after two installs`); + } + }); + + test('--claude --global uninstall removes GSD permission entries from settings.json', (t) => { + const root = createTempDir('gsd-claude-perm-uninstall-'); + t.after(() => cleanup(root)); + + const spawnOpts = { + encoding: 'utf8', + env: { ...process.env, HOME: root, USERPROFILE: root }, + }; + + // Install first + const r1 = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root], + spawnOpts, + ); + assert.strictEqual(r1.status, 0, `install failed: ${r1.stderr}`); + + // Verify permissions were written + const settingsPath = path.join(root, 'settings.json'); + const afterInstall = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.ok((afterInstall.permissions?.allow ?? []).includes('Bash(npx gsd-core *)'), + 'permissions.allow must contain GSD entry after install'); + + // Now add a user permission to make sure we don't nuke it + afterInstall.permissions.allow.push('Bash(git *)'); + afterInstall.permissions.deny.push('WebSearch'); + fs.writeFileSync(settingsPath, JSON.stringify(afterInstall, null, 2) + '\n'); + + // Uninstall + const r2 = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root, '--uninstall'], + spawnOpts, + ); + assert.strictEqual(r2.status, 0, `uninstall failed: ${r2.stderr}`); + + const afterUninstall = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + const allow = afterUninstall.permissions?.allow ?? []; + const deny = afterUninstall.permissions?.deny ?? []; + + // GSD entries must be removed + assert.ok(!allow.includes('Bash(npx gsd-core *)'), + 'GSD Bash allow entry must be removed by uninstall'); + assert.ok(!allow.includes('Read(.planning/*)'), + 'GSD Read(.planning/*) allow entry must be removed by uninstall'); + assert.ok(!deny.includes('Read(.env)'), + 'GSD Read(.env) deny entry must be removed by uninstall'); + + // User entries must survive + assert.ok(allow.includes('Bash(git *)'), + 'user Bash(git *) allow entry must survive uninstall'); + assert.ok(deny.includes('WebSearch'), + 'user WebSearch deny entry must survive uninstall'); + }); +}); diff --git a/tests/issue-775-gemini-extension.test.cjs b/tests/issue-775-gemini-extension.test.cjs new file mode 100644 index 000000000..33d5caf38 --- /dev/null +++ b/tests/issue-775-gemini-extension.test.cjs @@ -0,0 +1,119 @@ +'use strict'; + +/** + * Regression tests for issue #775: additive Gemini CLI extension manifest. + * + * Asserts structural and semantic correctness of the Gemini Extension Package: + * gemini-extension.json — extension manifest (consumed by + * `gemini extensions install `) + * GEMINI.md — the extension's context-file payload, referenced + * by the manifest's `contextFileName` field. + * + * This mirrors tests/issue-766-plugin-manifest.test.cjs (the parallel Claude + * Code plugin manifest) — the Gemini extension is the same artifact-surface + * projection onto Gemini CLI's package contract. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs')); +const pkg = require(path.join(ROOT, 'package.json')); + +const MANIFEST_PATH = path.join(ROOT, 'gemini-extension.json'); + +// ─── Section A: gemini-extension.json ──────────────────────────────────────── +describe('A: gemini-extension.json', () => { + + let manifest; + + test('exists and is valid JSON', () => { + assert.ok(fs.existsSync(MANIFEST_PATH), 'gemini-extension.json must exist at repo root'); + const raw = fs.readFileSync(MANIFEST_PATH, 'utf-8'); + manifest = JSON.parse(raw); // throws on invalid JSON + assert.ok(typeof manifest === 'object' && manifest !== null, 'manifest must be a JSON object'); + }); + + test('name equals identity.binName ("gsd-core")', (t) => { + if (!manifest) { t.skip('manifest could not be parsed'); return; } + assert.equal(manifest.name, identity.binName, `name should be "${identity.binName}"`); + }); + + test('name is lowercase/dashes only (Gemini extension naming rule)', (t) => { + if (!manifest) { t.skip('manifest could not be parsed'); return; } + // Gemini reference: "lowercase or numbers and use dashes instead of + // underscores or spaces". + assert.match( + manifest.name, + /^[a-z0-9]+(?:-[a-z0-9]+)*$/, + 'name must be lowercase letters/numbers with dashes (no underscores, spaces, or uppercase)' + ); + }); + + test('version is a non-empty string matching package.json version', (t) => { + if (!manifest) { t.skip('manifest could not be parsed'); return; } + assert.equal( + manifest.version, + pkg.version, + `gemini-extension.json version (${manifest.version}) must match package.json version (${pkg.version}). ` + + `When bumping the package version, update gemini-extension.json \`version\` to match — ` + + `Gemini CLI's \`gemini extensions update\` keys off the manifest version field. (#775)` + ); + }); + + test('description is a non-empty string (required by Gemini manifest schema)', (t) => { + if (!manifest) { t.skip('manifest could not be parsed'); return; } + assert.ok( + typeof manifest.description === 'string' && manifest.description.trim().length > 0, + 'description must be a non-empty string' + ); + }); + + test('contextFileName points to an existing repo-root file', (t) => { + if (!manifest) { t.skip('manifest could not be parsed'); return; } + assert.equal(manifest.contextFileName, 'GEMINI.md', 'contextFileName must be "GEMINI.md"'); + const ctx = path.join(ROOT, manifest.contextFileName); + assert.ok(fs.existsSync(ctx), `context file must exist on disk: ${ctx}`); + const body = fs.readFileSync(ctx, 'utf-8'); + assert.ok(body.trim().length > 0, 'context file must be non-empty'); + }); + + test('only declares schema-known top-level keys', (t) => { + if (!manifest) { t.skip('manifest could not be parsed'); return; } + // Per Gemini extension reference. We intentionally ship the minimal + // context-loading subset; this guards against typos / unknown keys that + // would fail `gemini extensions install` manifest validation. + const ALLOWED = new Set([ + 'name', 'version', 'description', 'contextFileName', + 'mcpServers', 'excludeTools', 'migratedTo', 'plan', 'settings', 'themes', + ]); + for (const key of Object.keys(manifest)) { + assert.ok(ALLOWED.has(key), `Unknown top-level manifest key "${key}" is not in the Gemini extension schema`); + } + }); + + test('does not declare mcpServers (gsd-core ships no MCP server)', (t) => { + if (!manifest) { t.skip('manifest could not be parsed'); return; } + assert.ok( + !Object.prototype.hasOwnProperty.call(manifest, 'mcpServers'), + 'manifest must NOT declare mcpServers — gsd-core has no MCP server' + ); + }); +}); + +// ─── Section B: package publication ────────────────────────────────────────── +describe('B: package.json publication', () => { + + test('files[] includes gemini-extension.json and GEMINI.md', () => { + const files = pkg.files || []; + for (const required of ['gemini-extension.json', 'GEMINI.md']) { + assert.ok( + files.includes(required), + `package.json "files" must include "${required}" so it ships to npm consumers` + ); + } + }); +}); diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index 2d1a18482..3843d3814 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -226,15 +226,21 @@ describe('resolveRuntimeArtifactLayout — hermes', () => { }); describe('resolveRuntimeArtifactLayout — codebuddy', () => { - test('returns correct layout for codebuddy', () => { + test('returns correct layout for codebuddy (commands + skills — #789)', () => { const layout = resolveRuntimeArtifactLayout('codebuddy', FAKE_DIR); assert.strictEqual(layout.runtime, 'codebuddy'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 1); - assert.strictEqual(layout.kinds[0].kind, 'skills'); - assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds.length, 2); + // commands kind first + assert.strictEqual(layout.kinds[0].kind, 'commands'); + assert.strictEqual(layout.kinds[0].destSubpath, 'commands'); assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + // skills kind second + assert.strictEqual(layout.kinds[1].kind, 'skills'); + assert.strictEqual(layout.kinds[1].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[1].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[1].stage, 'function'); }); });