mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-12 05:00:36 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fe56159c4 | ||
|
|
347c9ee178 | ||
|
|
8d7ac40b3a |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@fission-ai/openspec": minor
|
||||
---
|
||||
|
||||
Add Grok Build, xAI's `grok` CLI, as a supported tool. `openspec init --tools grok` installs skills to `.grok/skills/openspec-*/SKILL.md` and commands to `.grok/commands/opsx-<id>.md`, invoked as `/opsx-propose`.
|
||||
@@ -32,6 +32,7 @@ The id goes to `openspec init --tools <id>` to skip the picker ([CLI](cli.md)).
|
||||
| ForgeCode | `forgecode` | `.forge/skills/` | `/openspec-apply-change` | none | none |
|
||||
| Gemini CLI | `gemini` | `.gemini/skills/` | `/openspec-apply-change` | `.gemini/commands/opsx/` | `/opsx:apply` |
|
||||
| GitHub Copilot | `github-copilot` | `.github/skills/` | `/openspec-apply-change` | `.github/prompts/` | `/opsx-apply` |
|
||||
| Grok Build | `grok` | `.grok/skills/` | `/openspec-apply-change` | `.grok/commands/` | `/opsx-apply` |
|
||||
| Hermes Agent | `hermes` | `.hermes/skills/` | `/openspec-apply-change` | none | none |
|
||||
| iFlow | `iflow` | `.iflow/skills/` | `/openspec-apply-change` | `.iflow/commands/` | `/opsx-apply` |
|
||||
| Junie | `junie` | `.junie/skills/` | `/openspec-apply-change` | `.junie/commands/` | `/opsx-apply` |
|
||||
|
||||
+1
-1
@@ -114,7 +114,7 @@ field so OpenSpec never overwrites project-specific guidance.
|
||||
|
||||
The welcome animation is also skipped when the `OPENSPEC_NO_ANIMATION` environment variable is set (any value, including empty), when `NO_COLOR` is set to a non-empty value, or when the OS reduced-motion preference is enabled (macOS Reduce Motion, GNOME animations disabled).
|
||||
|
||||
**Supported tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `command-code`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `codeassistant`, `qoder`, `qwen`, `rovodev`, `roocode`, `trae`, `zed`, `zcode`, `agents`
|
||||
**Supported tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `command-code`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `grok`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `codeassistant`, `qoder`, `qwen`, `rovodev`, `roocode`, `trae`, `zed`, `zcode`, `agents`
|
||||
|
||||
> This list mirrors `AI_TOOLS` in `src/core/config.ts`. See [Supported Tools](supported-tools.md) for each tool's skill and command paths.
|
||||
|
||||
|
||||
+1
-1
@@ -672,7 +672,7 @@ Different AI tools use slightly different command syntax. Use the format that ma
|
||||
| Your tool's command file | Syntax example | Example tools |
|
||||
|--------------------------|----------------|---------------|
|
||||
| `.../commands/opsx/<id>.*` | `/opsx:propose`, `/opsx:apply` | Claude Code, Gemini CLI, Crush |
|
||||
| `.../opsx-<id>.*` | `/opsx-propose`, `/opsx-apply` | Cursor, Devin Desktop, Copilot (IDE), Trae, Oh My Pi |
|
||||
| `.../opsx-<id>.*` | `/opsx-propose`, `/opsx-apply` | Cursor, Devin Desktop, Copilot (IDE), Grok Build, Trae, Oh My Pi |
|
||||
| none — skills only | `/openspec-propose`, `/openspec-apply-change` | CodeArts, ForgeCode, Hermes, MiniMax Code, Mistral Vibe, Zed Agent, shared `.agents` |
|
||||
| none — Kimi Code | `/skill:openspec-propose` | Kimi Code |
|
||||
| none — Codex CLI | `$openspec-propose` | Codex |
|
||||
|
||||
@@ -76,7 +76,7 @@ The intent is identical everywhere. The spelling follows the file your tool load
|
||||
| Your tool's command file | How you type it | Example tools |
|
||||
|--------------------------|-----------------|---------------|
|
||||
| `.../commands/opsx/<id>.*` | `/opsx:propose` | Claude Code, Gemini CLI, Crush |
|
||||
| `.../opsx-<id>.*` | `/opsx-propose` | Cursor, GitHub Copilot (IDE), Devin Desktop, Trae, Oh My Pi |
|
||||
| `.../opsx-<id>.*` | `/opsx-propose` | Cursor, GitHub Copilot (IDE), Devin Desktop, Grok Build, Trae, Oh My Pi |
|
||||
| `.amazonq/prompts/opsx-<id>.md` | `@opsx-propose` | Amazon Q Developer |
|
||||
| none — skills only | `/openspec-propose` | CodeArts, ForgeCode, Hermes, Mistral Vibe, Zed Agent, shared `.agents` |
|
||||
| none — Kimi Code | `/skill:openspec-propose` | Kimi Code |
|
||||
|
||||
@@ -83,6 +83,7 @@ to read the hint.
|
||||
| Factory Droid (`factory`) | `.factory/skills/openspec-*/SKILL.md` | `.factory/commands/opsx-<id>.md` |
|
||||
| Gemini CLI (`gemini`) | `.gemini/skills/openspec-*/SKILL.md` | `.gemini/commands/opsx/<id>.toml` |
|
||||
| GitHub Copilot (`github-copilot`) | `.github/skills/openspec-*/SKILL.md` | `.github/prompts/opsx-<id>.prompt.md`\*\* |
|
||||
| [Grok Build](https://docs.x.ai/build/overview) (`grok`) | `.grok/skills/openspec-*/SKILL.md` | `.grok/commands/opsx-<id>.md`\*\*\*\*\* |
|
||||
| Hermes Agent (`hermes`) | `.hermes/skills/openspec-*/SKILL.md`\*\*\* | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |
|
||||
| iFlow (`iflow`) | `.iflow/skills/openspec-*/SKILL.md` | `.iflow/commands/opsx-<id>.md` |
|
||||
| Junie (`junie`) | `.junie/skills/openspec-*/SKILL.md` | `.junie/commands/opsx-<id>.md` |
|
||||
@@ -111,6 +112,12 @@ to read the hint.
|
||||
|
||||
\*\*\*\* Windsurf was [rebranded to Devin Desktop](https://docs.devin.ai/desktop/devin-desktop-faq) on June 2, 2026, and its config directory moved: `.devin/` is the preferred read + write location, `.windsurf/` a legacy read-only fallback. OpenSpec follows the rename — the tool id is `devin`, and `--tools windsurf` still resolves to it so existing setup scripts keep working. A project still holding OpenSpec files in `.windsurf/` is offered the move on the next `openspec update`; declining leaves them in place, and files you wrote yourself are never touched. Workflows are invoked by filename, so `.devin/workflows/opsx-apply.md` is `/opsx-apply`. The [Devin Local agent does not support workflows](https://docs.devin.ai/desktop/devin-local) — only skills, and it does not read `.windsurf/` at all — so whenever OpenSpec writes Devin skills it keeps their bodies, and the getting-started hint, on `/openspec-*` skill invocations, which work on both agents. Under commands-only delivery no skills are written and both fall back to `/opsx-*`.
|
||||
|
||||
\*\*\*\*\* Grok Build is xAI's `grok` CLI. It has no separate command subsystem: the loader that discovers `.grok/skills/<name>/SKILL.md` also scans `.grok/commands/` and registers each Markdown file there as a slash command. That scan is flat — a nested `commands/opsx/<id>.md` is skipped rather than namespaced — so OpenSpec writes `.grok/commands/opsx-<id>.md`, and the filename is the command: `/opsx-propose`. Because that directory is shared with your own command files, avoid naming one of yours `opsx-<workflow>.md`: OpenSpec owns those names and will overwrite or remove them. Under skills-only delivery no command files are written, and the skills are invoked by name instead, as `/openspec-propose`.
|
||||
|
||||
Grok also scans `.agents/`, `.claude/`, and `.cursor/` for skills and commands, so a project configured for Grok alongside one of those tools can offer Grok the same workflow from two directories. The copies differ only in the frontmatter each tool needs, so either one runs the same workflow.
|
||||
|
||||
[Skills](https://docs.x.ai/build/features/skills-plugins-marketplaces) are documented upstream. The `commands/` directory is read by the shipping CLI but is not yet in the published docs, so a pinned test guards its shape.
|
||||
|
||||
SourceCraft Code Assistant support targets its VS Code extension. Its [custom commands](https://sourcecraft.dev/portal/docs/en/code-assistant/operations/agent/slash-commands) and [skills](https://sourcecraft.dev/portal/docs/ru/code-assistant/operations/agent/skills) are available only in VS Code. This integration does not configure SourceCraft web or JetBrains.
|
||||
|
||||
With skills-only delivery, ask Code Assistant to use the `openspec-propose` skill with your idea. Skills activate through request matching; OpenSpec does not generate `/openspec-*` commands for this tool.
|
||||
@@ -219,7 +226,7 @@ openspec init --tools none
|
||||
openspec init --profile core
|
||||
```
|
||||
|
||||
**Available tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `command-code`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `codeassistant`, `trae`, `zed`, `zcode`, `agents`
|
||||
**Available tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `command-code`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `grok`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `codeassistant`, `qoder`, `qwen`, `rovodev`, `roocode`, `trae`, `zed`, `zcode`, `agents`
|
||||
|
||||
## Workflow-Dependent Installation
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-11
|
||||
@@ -1,288 +0,0 @@
|
||||
## Context
|
||||
|
||||
See proposal.md — Why. Three facts about the existing code shape every decision
|
||||
below.
|
||||
|
||||
- **Workflow ids are a wide seam, and part of it is compile-enforced.**
|
||||
`WORKFLOW_TO_SKILL_DIR` in `src/core/profile-sync-drift.ts:28` is typed
|
||||
`Record<WorkflowId, string>`, so adding an id to `ALL_WORKFLOWS` without adding
|
||||
it there fails `tsc`. Four other maps carry the same information without that
|
||||
protection: the duplicate `WORKFLOW_TO_SKILL_DIR` in `src/core/init.ts:111`
|
||||
(typed `Record<string, string>`), `COMMAND_TO_SKILL_NAME` in
|
||||
`src/utils/command-references.ts:53`, `OPENSPEC_SKILL_NAMES` in
|
||||
`src/core/config.ts:3`, and `COMMAND_IDS` in
|
||||
`src/core/shared/tool-detection.ts:41`. Only the first is protected; the other
|
||||
four drift silently, because the tests around them assert fixed counts rather
|
||||
than derive from `ALL_WORKFLOWS`.
|
||||
- **`feedback` is the only workflow module with no command template.** Every
|
||||
other `src/core/templates/workflows/*.ts` exports a `getOpsx*CommandTemplate`
|
||||
beside its skill template. `getCommandTemplates()` and `COMMAND_IDS` assume the
|
||||
pair, and `profile-sync-drift` reads a missing command file under
|
||||
`delivery: 'both'` as drift to repair on every run.
|
||||
- **The core profile is the default, and the only profile most users ever have.**
|
||||
`getProfileWorkflows` (`src/core/profiles.ts:45`) returns `CORE_WORKFLOWS`
|
||||
unconditionally for any non-`custom` profile and ignores `customWorkflows`
|
||||
entirely for `core`; `DEFAULT_CONFIG.profile` is `'core'`
|
||||
(`src/core/global-config.ts:45`). An id added to `ALL_WORKFLOWS` alone reaches a
|
||||
user only if they open `openspec config profile` and tick it, which also flips
|
||||
them to `custom`.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- A user who says "file that as an issue" gets a well-scoped issue, without
|
||||
reading anything first.
|
||||
- The issue arrives needing less work from a maintainer than it would have. That
|
||||
is the point: refinement at filing time is labour not spent in triage later.
|
||||
- A skill-filed issue and a form-filed issue carry the same fields, so triage
|
||||
reads one shape.
|
||||
- `openspec feedback` with no flags behaves exactly as it does today.
|
||||
|
||||
**Non-Goals**
|
||||
|
||||
- CI enforcement that every PR links an issue. #1834 rules it out, and a gate
|
||||
punishes the people the easy path has not reached yet.
|
||||
- Sending anything anywhere without an explicit confirmation. Feedback submission
|
||||
stays a deliberate act, independent of telemetry
|
||||
(`cli-feedback` — "Feedback always works").
|
||||
- Reading the issue forms at runtime. The CLI never fetches or parses
|
||||
`.github/`; see "Coupling to the forms" below.
|
||||
- Refinement in the CLI. Deciding which question matters next, given what the
|
||||
user just said, is the whole value and it needs a model. A fixed question list
|
||||
in `feedback.ts` would ask a crash and a papercut the same five things and
|
||||
could not tell a vague answer from a usable one. The CLI keeps the
|
||||
deterministic half — validating `--type`, assembling the body, matching the
|
||||
form's fields and labels, building the fallback URL — and the skill keeps the
|
||||
judgement.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Keep the workflow id `feedback`
|
||||
|
||||
#1834 leaves this open: keep `feedback`, or rename to `report`?
|
||||
|
||||
Keep `feedback`. It is already the CLI verb (`openspec feedback`) and already the
|
||||
capability name (`openspec/specs/cli-feedback/`). Renaming would split the skill from the command it tells the agent
|
||||
to run, for a word that is no clearer at the point of use — a user asking to
|
||||
"report a bug" and a user asking to "send feedback" both want the same skill, and
|
||||
the skill's description is what an agent matches on, not its id.
|
||||
|
||||
`report` would also be the first workflow id that names a document rather than an
|
||||
act, against `propose` / `apply` / `archive` / `verify`.
|
||||
|
||||
### Make it a core workflow, not an optional one
|
||||
|
||||
This is the one decision here that changes the default install for every user, and
|
||||
it is the decision most worth arguing with.
|
||||
|
||||
The case for optional: the six core workflows are the steps of one loop — propose,
|
||||
explore, apply, update, sync, archive. `feedback` is not a step in that loop. Every
|
||||
core workflow costs a file in every configured tool's skill directory for every
|
||||
user, forever, and a skill about the tool itself is a weaker claim on that space
|
||||
than a skill about the user's work.
|
||||
|
||||
The case for core, which we take: the problem #1834 describes is not that the
|
||||
feedback skill is hard to find, it is that nobody has it. Shipping it as opt-in
|
||||
reproduces the current outcome at a smaller scale — the users who would tick a box
|
||||
in `openspec config profile` are the users already able to write a good issue by
|
||||
hand. The whole value is in reaching the user who has not read anything, and only
|
||||
the default install reaches them.
|
||||
|
||||
The cost is bounded and reversible: one skill, one command file, no change to any
|
||||
other workflow, and moving it out of `CORE_WORKFLOWS` later is a one-line change
|
||||
with the same update path in reverse.
|
||||
|
||||
Consequence to handle rather than assume: existing projects on a `custom` profile
|
||||
must not silently gain a workflow they did not choose.
|
||||
`displayMissingCoreWorkflowsNote` (`src/core/update.ts:689`, called at `:656`)
|
||||
already handles this: it returns early unless the profile is `custom`, diffs
|
||||
`CORE_WORKFLOWS` against the user's workflows, points at `openspec config
|
||||
profile`, and mutates nothing. This change relies on that path rather than adding
|
||||
one.
|
||||
|
||||
### `--type` defaults to `feedback`, and each type carries the form's labels
|
||||
|
||||
| `--type` | Title | Body sections | Labels |
|
||||
|---|---|---|---|
|
||||
| `feedback` (default) | `Feedback: <msg>` | Summary, Details | `feedback` |
|
||||
| `bug` | `<msg>` | What happened, Expected, Repro, Version, Agent, Environment | `bug`, `needs-triage` |
|
||||
| `feature` | `<msg>` | Problem, Who it affects, What you tried, Proposal | `enhancement`, `needs-triage` |
|
||||
|
||||
Four things follow from this table.
|
||||
|
||||
The default is `feedback` because the bare command must not change behavior for
|
||||
anyone typing it today. `--type bug` and `--type feature` drop the `Feedback: `
|
||||
prefix, because a title reading `Feedback: archive drops the main spec` misfiles
|
||||
a bug report.
|
||||
|
||||
The default keeps asking for the `feedback` label, and the fix for that label not
|
||||
existing is to create it, not to stop asking. The alternative — dropping the label
|
||||
request for the default type — buys one saved round trip and costs the ability to
|
||||
find general feedback in the issue list at all, which is the thing the label is
|
||||
for. `bug` and `enhancement` already exist. The missing-label retry stays for all
|
||||
three, because a label that exists today can be renamed tomorrow and a renamed
|
||||
label must not cost a user their report.
|
||||
|
||||
The labels per type are the forms' labels, `needs-triage` included. A report
|
||||
filed by the CLI and a report filed through the form have to land in the same
|
||||
triage queue, or the CLI path quietly skips it.
|
||||
|
||||
The fields per type are the issue forms' fields, in the forms' order. That is the
|
||||
point: the same report, whoever files it and however.
|
||||
|
||||
### The manual fallback targets the form, and degrades to today's URL
|
||||
|
||||
`generateManualSubmissionUrl()` gains `template=bug_report.yml` (or
|
||||
`feature_request.yml`) and one query parameter per form field id — GitHub's own
|
||||
prefill mechanism, which needs no API and no auth.
|
||||
|
||||
Two failure modes are handled rather than hoped away.
|
||||
|
||||
**Length.** A prefilled URL carries the whole body twice-encoded, and a long
|
||||
report can exceed what GitHub and some browsers accept. The builder caps the
|
||||
encoded URL; over the cap it returns the blank-issue URL the command builds today,
|
||||
with the body intact. A user who lands on a blank form with their text still in it
|
||||
has lost formatting, not their report. `--type feedback` uses that blank-issue URL
|
||||
unconditionally, since it has no form to target.
|
||||
|
||||
**Coupling to the forms.** The field ids (`what_happened`, `expected`, `repro`,
|
||||
`version`, `agent`, `environment`; `problem`, `who`, `tried`, `proposal`) become an
|
||||
interface the CLI depends on and `.github/` owns. The CLI does not read `.github/`
|
||||
— it cannot, since it runs in the user's project. It hard-codes the ids, and a
|
||||
test asserts that the ids it hard-codes are exactly the ids present in
|
||||
`.github/ISSUE_TEMPLATE/*.yml`, so renaming a field in a form fails CI in this
|
||||
repository rather than silently emptying a field for users. If an id ever does go
|
||||
stale in a released version, GitHub ignores unknown prefill parameters: the user
|
||||
gets the right form with one field blank, not an error.
|
||||
|
||||
### What the skill asks, and what it must never ask
|
||||
|
||||
The current template's flow is "review the conversation, draft, show it." #1834
|
||||
sharpens that to "draft, not interrogate," and taken literally that forbids the
|
||||
thing most worth doing: helping the user work out what they are actually asking
|
||||
for. A vague issue filed instantly is not cheaper than a good one filed a minute
|
||||
later — it is the same work, moved to a maintainer and made harder by the missing
|
||||
context. So the skill refines first, and the contract is about *which* things it
|
||||
may put to the user.
|
||||
|
||||
The split is facts versus judgements.
|
||||
|
||||
**Facts are the skill's job.** The OpenSpec version, the platform, the failing
|
||||
command and its output, the agent and model, what the user was doing — all
|
||||
observable. Asking for any of them is the interrogation failure mode, and it is
|
||||
the one users actually resent. This is the half of #1834's "draft, not
|
||||
interrogate" that survives intact, and it is stated as a prohibition in the spec
|
||||
because it is the one place a hard line is right.
|
||||
|
||||
**Judgements are the user's.** What the report is really asking for, who it
|
||||
affects, what it deliberately excludes, whether the reported problem is the
|
||||
problem or a symptom of one. These cannot be inferred, and they are exactly the
|
||||
questions whose absence costs a maintainer a round trip later. Scope is the
|
||||
highest-value one: *what should this not do* settles more downstream argument than
|
||||
any reproduction detail.
|
||||
|
||||
Two mechanics are worth borrowing rather than inventing, from Matt Pocock's
|
||||
`grilling` skill, which does this well:
|
||||
|
||||
- **Each question carries a recommended answer.** Agreement then costs a word, and
|
||||
a user who disagrees is arguing with a concrete proposal instead of composing
|
||||
one. This is also how OpenSpec's own `explore` already works — its shared
|
||||
`PLANNING_GUIDANCE` says to "state your preferred option and why it fits."
|
||||
- **Dependency ordering.** Never ask a question whose answer depends on one still
|
||||
open; settle the blocking decision, then the details it unlocks. `explore`
|
||||
already says "Follow dependencies"; grilling makes it the whole selection rule.
|
||||
|
||||
Grilling asks the entire unblocked frontier in a numbered round; `explore` asks
|
||||
one focused question at a time. The spec deliberately picks neither. Cadence is
|
||||
the kind of thing that should improve without a spec change, and the right answer
|
||||
probably differs between a bug report and a feature idea. What the spec fixes is
|
||||
the ordering rule and the recommendation, which are the parts that make the
|
||||
conversation short.
|
||||
|
||||
**Termination is by exhaustion, not by count.** Grilling refuses a question cap on
|
||||
purpose, and it is right to: some reports need one question and some need ten.
|
||||
What the spec requires instead is that the skill stop when nothing material is
|
||||
unsettled — and, just as importantly, that it be able to ask *nothing at all*. A
|
||||
skill that manufactures three questions for a typo report teaches users to stop
|
||||
invoking it, which costs more than any single badly-scoped issue.
|
||||
|
||||
### One file, no reference layer
|
||||
|
||||
Current skill-authoring practice — Anthropic's `skill-creator`, and Pocock's
|
||||
skills, whose SKILL.md files run 7 to 140 lines — is progressive disclosure: a
|
||||
short SKILL.md that points at sibling reference files loaded only when needed.
|
||||
|
||||
**OpenSpec cannot do that today.** `generateSkillContent`
|
||||
(`src/core/shared/skill-generation.ts:132`) returns a single string, and every
|
||||
adapter writes exactly one `SKILL.md` per skill directory. There is no mechanism
|
||||
for a sibling file — adding one would touch the generator, every adapter's file
|
||||
layout, `tool-detection.ts`'s existence checks, the `skills/` mirror, and the
|
||||
parity hashes. That is its own proposal, and this change should not smuggle it in.
|
||||
|
||||
So the whole skill has to fit one file, which makes leanness a constraint rather
|
||||
than a preference. Two consequences for how it is written, both drawn from the
|
||||
same current practice:
|
||||
|
||||
- **Explain why instead of stacking capitalised MUSTs.** The existing template has
|
||||
seven in a row under "Guardrails." `skill-creator` calls that a yellow flag and
|
||||
asks for the reasoning instead, so the model can generalise to the case nobody
|
||||
wrote down. The genuine invariants — show the draft, get confirmation, anonymise
|
||||
— stay imperative; the rest becomes explanation.
|
||||
- **Phrase instructions positively.** Pocock's `writing-for-agents` puts it well:
|
||||
steering by prohibition drags the forbidden behaviour into context and makes it
|
||||
*more* available. "Fill the version in yourself" beats "DO NOT ask for the
|
||||
version."
|
||||
|
||||
### The skill's own `name` has to change, even though the workflow id does not
|
||||
|
||||
The workflow id and the skill's frontmatter `name` are different things, and
|
||||
`feedback` is currently wrong in the second. `dirName` is a free literal per entry
|
||||
in `getSkillTemplates()` (`src/core/shared/skill-generation.ts:61`), but the
|
||||
SKILL.md frontmatter is written from `template.name`
|
||||
(`src/core/shared/skill-generation.ts:142`), and every registered template sets
|
||||
`name` equal to its dirName — `openspec-verify-change`, `openspec-onboard`, and so
|
||||
on. `src/core/templates/workflows/feedback.ts:11` sets `name: 'feedback'`, written
|
||||
when nothing deployed it.
|
||||
|
||||
Registering it as-is would commit `skills/openspec-feedback/SKILL.md` whose
|
||||
frontmatter says `feedback` — the only skill where the two disagree, and a
|
||||
disagreement with both `OPENSPEC_SKILL_NAMES` and the `/openspec-feedback`
|
||||
reference in `COMMAND_TO_SKILL_NAME`. The template's `name` becomes
|
||||
`openspec-feedback`; the workflow id stays `feedback`, exactly as `verify` maps to
|
||||
`openspec-verify-change`.
|
||||
|
||||
### The command template must take `$ARGUMENTS`
|
||||
|
||||
`test/core/command-generation/adapters.test.ts:158` asserts that `onboard` is the
|
||||
only command with no `$ARGUMENTS` placeholder, and the file describes that
|
||||
assertion as a tripwire for exactly this situation. `/opsx-feedback <what went
|
||||
wrong>` should accept the message anyway — an agent invoking it already has the
|
||||
sentence.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **A seventh core skill for every user.** Accepted above, and cheap to reverse.
|
||||
- **Five parallel id maps, three unprotected.** This change adds a sixth entry to
|
||||
each. It does not consolidate them — that is a refactor with its own blast
|
||||
radius and belongs in its own proposal — but the tasks below touch all of them
|
||||
in one pass. Be clear about what actually catches an omission: only `tsc`, via
|
||||
the `Record<WorkflowId, string>` at `profile-sync-drift.ts:28`. The other guards
|
||||
are fixed-count assertions such as `tool-detection.test.ts:33`, which fail when
|
||||
you *add* an entry and stay silent when you skip one, and
|
||||
`command-references.test.ts:92`, whose list is hand-written and does not derive
|
||||
from `ALL_WORKFLOWS` — it omits `propose`, which is in the map. Omitting
|
||||
`feedback` from `COMMAND_TO_SKILL_NAME` fails nothing at all.
|
||||
- **The skill tells an agent to run a command that posts to GitHub.** Mitigated by
|
||||
contract rather than convention: the amended skill requirement in `cli-feedback`
|
||||
keeps showing the complete draft and receiving explicit confirmation a MUST.
|
||||
- **Anonymization is instruction, not enforcement.** An agent can still include a
|
||||
path it should have redacted. This is the same bound the existing spec accepts;
|
||||
making it mechanical would mean parsing the body the agent wrote, which is a
|
||||
different change.
|
||||
|
||||
## Migration
|
||||
|
||||
None for data. `openspec update` installs the new skill and command on the next
|
||||
run; `openspec init` installs them for new projects. Projects on a `custom`
|
||||
profile keep their selection and are nudged, not modified.
|
||||
@@ -1,116 +0,0 @@
|
||||
## Why
|
||||
|
||||
`CONTRIBUTING.md` asks every change to start with an issue. Filing one is now the
|
||||
path of least resistance for someone who opens this repository on GitHub —
|
||||
[#1847](https://github.com/Fission-AI/OpenSpec/pull/1847) added the issue forms and
|
||||
the PR template. It is still not the path of least resistance for the person who
|
||||
hits the bug: a user in their own project, with `openspec` installed and an agent
|
||||
open, who says "file that as an issue."
|
||||
|
||||
Two things stand between them and a well-formed issue.
|
||||
|
||||
**The skill that was supposed to do this has never existed for anyone.**
|
||||
`openspec/specs/cli-feedback/spec.md` specifies a `/feedback` skill that gathers
|
||||
context, drafts, anonymizes, and submits. The template is written and exported
|
||||
(`src/core/templates/workflows/feedback.ts`, re-exported at
|
||||
`src/core/templates/skill-templates.ts:21`), but `feedback` is not in
|
||||
`ALL_WORKFLOWS` (`src/core/profiles.ts:19`), so it is in no skill registry, no
|
||||
tool's skill directory, and no user's project. The requirement is unimplemented,
|
||||
and the parity suite records that deliberately: `skill-templates-parity.test.ts:83`
|
||||
excludes it from the generated-skill list because it "is covered in function
|
||||
payload parity" — the payload of a template nothing deploys.
|
||||
|
||||
**What the CLI emits does not match what the forms ask for.**
|
||||
`openspec feedback "..."` posts a free-form `## Summary` / `## Details` body under
|
||||
a `Feedback: ` title, with no expected-vs-actual, no repro, and no agent or model —
|
||||
the four things triage asks for every time. It requests the `feedback` label, which
|
||||
this repository does not define, so every submission falls back to unlabeled and
|
||||
prints a note saying so. And the manual URL in `generateManualSubmissionUrl()`
|
||||
(`src/commands/feedback.ts:127`) passes bare `title`/`body`, which now lands on the
|
||||
*blank* issue form — so every user without `gh` bypasses the forms #1847 just added.
|
||||
|
||||
The result is that the two people most able to write a good bug report — the user
|
||||
who just hit it, and the agent that watched it happen — are the two we equip least.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Register `feedback` as a workflow.** It joins `ALL_WORKFLOWS` and
|
||||
`CORE_WORKFLOWS`, so `openspec init` and `openspec update` install it by default
|
||||
alongside the six workflows already there. A skill nobody opts into does not
|
||||
change the outcome for anyone; see design.md for why this is worth a seventh
|
||||
core workflow and what it costs.
|
||||
- **Author the missing command template.** `feedback` is the only workflow whose
|
||||
module exports a skill template and no `getOpsx*CommandTemplate`. Without one it
|
||||
is a skills-only workflow that `profile-sync-drift` reports as drift forever
|
||||
under `delivery: 'both'`.
|
||||
- **Rewrite the skill to refine the report, then draft it.** Two different jobs
|
||||
that the current template conflates. Facts are the skill's to find — the
|
||||
conversation, the failing command, the version, the platform — and it should
|
||||
never ask for one of those. Judgements are the user's, and those are worth
|
||||
asking about: what the report is really asking for, who it affects, what it
|
||||
deliberately leaves out. A report that arrives already scoped is the labour this
|
||||
saves later, so the skill converges on one with the user before it drafts
|
||||
anything, then shows one complete draft and submits on a single confirmation.
|
||||
Its fields are the form's fields, so a skill-filed issue and a form-filed issue
|
||||
read the same.
|
||||
- **`openspec feedback --type bug|feature|feedback`.** Each type emits the body
|
||||
the matching form asks for and requests the labels that form applies — `bug,
|
||||
needs-triage`, `enhancement, needs-triage`, or `feedback` — so a CLI-filed
|
||||
report lands in the same triage queue as a form-filed one. `feedback` stays the default, so the bare command a
|
||||
user types today behaves exactly as it does today, including the retry that
|
||||
rescues a submission when a label is missing.
|
||||
- **Define the `feedback` label in this repository.** It is the one the command
|
||||
has always asked for and the one this repository has never had, which is why
|
||||
every submission today is created unlabeled and apologizes for it. This is a
|
||||
repository setting, not code — no released version changes.
|
||||
- **Prefill the form in the manual fallback.** The URL gains
|
||||
`?template=bug_report.yml` and the form's own field ids, so a user without `gh`
|
||||
lands on the filled-in form rather than a blank box. The URL is capped, and falls
|
||||
back to the blank-issue URL it builds today when prefill would exceed the cap.
|
||||
- **Not breaking.** `openspec feedback "msg"` with no flags keeps its title,
|
||||
its body shape, and its exit codes. No existing workflow id, skill directory, or
|
||||
command name changes.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None. The skill contract already lives in `cli-feedback` as the "Feedback skill
|
||||
for agents" requirement; it is amended there rather than moved to an
|
||||
`opsx-feedback-skill` capability, because moving it changes nothing for a reader
|
||||
and costs a removal plus a new file.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `cli-feedback`: `--type` and its effect on title, body, and label; the manual fallback URL
|
||||
targeting the issue form with prefilled fields; the feedback skill requirement
|
||||
amended to refine before drafting — facts found rather than asked for, the
|
||||
user's judgements put to them with a recommendation — and stated as installed
|
||||
rather than merely specified.
|
||||
|
||||
## Depends on
|
||||
|
||||
[#1847](https://github.com/Fission-AI/OpenSpec/pull/1847), unmerged. It adds
|
||||
`.github/ISSUE_TEMPLATE/bug_report.yml` and `feature_request.yml`; every field id
|
||||
this change prefills and the test that pins those ids read those files, so the
|
||||
form-prefill work (tasks 5 and 6.11) cannot start until it lands. Everything else
|
||||
here — registering the workflow, the command template, the skill rewrite, `--type`
|
||||
— is independent of it.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Affected behavior**: `openspec feedback` (new flag, new bodies, new fallback
|
||||
URL); `openspec init` and `openspec update` for every user, which now install a
|
||||
seventh core skill and command; `openspec config profile`, whose picker gains a
|
||||
row.
|
||||
- **Existing projects**: `openspec update` adds the skill on the next run. Projects
|
||||
on a `custom` profile are nudged about the newly-core workflow rather than having
|
||||
it added silently, via the path `src/core/update.ts:689` already takes.
|
||||
- **Unaffected**: every other workflow, the schema system, and
|
||||
`config-schema.ts`, which validates `workflows` as `z.array(z.string())` with no
|
||||
enum.
|
||||
- **Docs**: `docs/commands.md`, `docs/workflows.md`, `docs/supported-tools.md`,
|
||||
`docs/glossary.md`, and `README.md` list workflows by name and gain a row.
|
||||
- **Repository, not product**: the `bug_report.yml` / `feature_request.yml` field
|
||||
ids become an interface the CLI depends on. design.md states how that coupling
|
||||
fails safe.
|
||||
@@ -1,245 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Feedback command
|
||||
|
||||
The system SHALL provide an `openspec feedback` command that creates a GitHub Issue in the openspec repository using the `gh` CLI. The system SHALL use `execFileSync` with argument arrays to prevent shell injection vulnerabilities.
|
||||
|
||||
The command SHALL accept a `--type` option with the values `bug`, `feature`, and `feedback`, defaulting to `feedback`. The type SHALL determine the issue title, the body sections, and the label requested:
|
||||
|
||||
| Type | Title | Body sections | Labels |
|
||||
|---|---|---|---|
|
||||
| `feedback` | `Feedback: <message>` | Summary, Details | `feedback` |
|
||||
| `bug` | `<message>` | What happened, Expected, Repro, Version, Agent, Environment | `bug`, `needs-triage` |
|
||||
| `feature` | `<message>` | Problem, Who it affects, What you tried, Proposal | `enhancement`, `needs-triage` |
|
||||
|
||||
The `bug` and `feature` body sections SHALL match the fields and order of the repository's issue forms, and the labels requested SHALL be the labels those forms apply, so that a report filed by the command and a report filed through the form read the same and reach the same triage queue. The system SHALL reject an unrecognized `--type` value with a message naming the accepted values, and SHALL NOT submit anything.
|
||||
|
||||
#### Scenario: Simple feedback submission
|
||||
|
||||
- **WHEN** user executes `openspec feedback "Great tool!"`
|
||||
- **THEN** the system executes `gh issue create` with title "Feedback: Great tool!"
|
||||
- **AND** the issue body includes "Great tool!" under a Summary heading
|
||||
- **AND** the issue is created in the openspec repository
|
||||
- **AND** the issue has the `feedback` label
|
||||
- **AND** the system displays the created issue URL
|
||||
|
||||
#### Scenario: Bug report submission
|
||||
|
||||
- **WHEN** user executes `openspec feedback "archive drops the main spec" --type bug`
|
||||
- **THEN** the issue title is "archive drops the main spec" with no "Feedback: " prefix
|
||||
- **AND** the issue body carries the bug form's sections in the form's order
|
||||
- **AND** the system requests the `bug` and `needs-triage` labels
|
||||
- **AND** the system displays the created issue URL
|
||||
|
||||
#### Scenario: Feature request submission
|
||||
|
||||
- **WHEN** user executes `openspec feedback "let me unarchive a change" --type feature`
|
||||
- **THEN** the issue title is "let me unarchive a change" with no "Feedback: " prefix
|
||||
- **AND** the issue body carries the feature form's sections in the form's order
|
||||
- **AND** the system requests the `enhancement` and `needs-triage` labels
|
||||
|
||||
#### Scenario: Unrecognized type
|
||||
|
||||
- **WHEN** user executes `openspec feedback "message" --type question`
|
||||
- **THEN** the system reports that `question` is not an accepted type
|
||||
- **AND** names `bug`, `feature`, and `feedback` as the accepted values
|
||||
- **AND** exits with a non-zero code without contacting GitHub
|
||||
|
||||
#### Scenario: Repository does not define the feedback label
|
||||
|
||||
- **WHEN** user executes `openspec feedback "Great tool!"`
|
||||
- **AND** the repository does not define the `feedback` label, so `gh` refuses to create the issue
|
||||
- **THEN** the system retries `gh issue create` without the label
|
||||
- **AND** the issue is created in the openspec repository without the `feedback` label
|
||||
- **AND** the system displays the created issue URL
|
||||
- **AND** the system notes that the label was not applied
|
||||
|
||||
#### Scenario: Repository does not define a type's label
|
||||
|
||||
- **WHEN** user executes `openspec feedback "message" --type bug`
|
||||
- **AND** the repository does not define one of the labels, so `gh` refuses to create the issue
|
||||
- **THEN** the system retries `gh issue create` without labels, as it does for the `feedback` label
|
||||
- **AND** the issue is created in the openspec repository without labels
|
||||
- **AND** the system notes that the labels were not applied
|
||||
|
||||
#### Scenario: Safe command execution
|
||||
|
||||
- **WHEN** submitting feedback via `gh` CLI
|
||||
- **THEN** the system uses `execFileSync` with separate arguments array
|
||||
- **AND** user input is NOT passed through a shell
|
||||
- **AND** shell metacharacters (quotes, backticks, $(), etc.) are treated as literal text
|
||||
|
||||
#### Scenario: Feedback with body
|
||||
|
||||
- **WHEN** user executes `openspec feedback "Title here" --body "Detailed description..."`
|
||||
- **THEN** the system creates a GitHub Issue with the specified title
|
||||
- **AND** the issue body contains the message under a Summary heading
|
||||
- **AND** the issue body contains the detailed description under a Details heading
|
||||
- **AND** the issue body includes metadata (OpenSpec version, platform, timestamp)
|
||||
|
||||
#### Scenario: Long or multiline feedback message
|
||||
|
||||
- **WHEN** user executes `openspec feedback` with a long or multiline message
|
||||
- **THEN** the issue title is a single whitespace-normalized line of at most 72 characters
|
||||
- **AND** an ellipsis indicates when the title was shortened
|
||||
- **AND** the complete message is preserved in the issue body
|
||||
|
||||
### Requirement: GitHub CLI dependency
|
||||
|
||||
The system SHALL use `gh` CLI for automatic feedback submission when available, and provide a manual submission fallback when `gh` is not installed or not authenticated. The system SHALL use platform-appropriate commands to detect `gh` CLI availability.
|
||||
|
||||
The pre-filled URL offered by the manual fallback SHALL target the repository's issue form matching the `--type`, prefilling each of the form's fields by its field id, so that a user without `gh` reaches the same form a user on GitHub reaches. The system SHALL fall back to the blank-issue URL, carrying the complete body, when no form matches the type or when the prefilled URL would exceed the length the system accepts. The system SHALL NOT read the issue forms at runtime.
|
||||
|
||||
#### Scenario: Missing gh CLI with fallback
|
||||
|
||||
- **WHEN** user runs `openspec feedback "message"`
|
||||
- **AND** `gh` CLI is not installed (not found in PATH)
|
||||
- **THEN** the system displays warning: "GitHub CLI not found. Manual submission required."
|
||||
- **AND** outputs structured feedback content with delimiters:
|
||||
- "--- FORMATTED FEEDBACK ---"
|
||||
- Title line
|
||||
- Labels line
|
||||
- Body content with metadata
|
||||
- "--- END FEEDBACK ---"
|
||||
- **AND** displays pre-filled GitHub issue URL for manual submission
|
||||
- **AND** exits with zero code (successful fallback)
|
||||
|
||||
#### Scenario: Fallback URL targets the bug form
|
||||
|
||||
- **WHEN** the manual fallback is reached for `--type bug`
|
||||
- **THEN** the pre-filled URL selects the repository's bug report form
|
||||
- **AND** carries one query parameter per form field id, holding that field's drafted content
|
||||
- **AND** a field the system cannot fill is omitted rather than sent empty
|
||||
|
||||
#### Scenario: Fallback URL too long for the form
|
||||
|
||||
- **WHEN** the manual fallback is reached for `--type bug`
|
||||
- **AND** the prefilled URL would exceed the length the system accepts
|
||||
- **THEN** the system displays the blank-issue URL instead
|
||||
- **AND** the complete body is still carried in that URL
|
||||
- **AND** the complete body is still printed between the feedback delimiters
|
||||
|
||||
#### Scenario: Fallback URL for general feedback
|
||||
|
||||
- **WHEN** the manual fallback is reached for the default `feedback` type
|
||||
- **THEN** the system displays the blank-issue URL, because no issue form matches general feedback
|
||||
- **AND** the URL carries the complete body, as it does today
|
||||
|
||||
#### Scenario: Cross-platform gh CLI detection on Unix
|
||||
|
||||
- **WHEN** system is running on macOS or Linux (platform is 'darwin' or 'linux')
|
||||
- **AND** checking if `gh` CLI is installed
|
||||
- **THEN** the system executes `which gh` command
|
||||
|
||||
#### Scenario: Cross-platform gh CLI detection on Windows
|
||||
|
||||
- **WHEN** system is running on Windows (platform is 'win32')
|
||||
- **AND** checking if `gh` CLI is installed
|
||||
- **THEN** the system executes `where gh` command
|
||||
|
||||
#### Scenario: Unauthenticated gh CLI with fallback
|
||||
|
||||
- **WHEN** user runs `openspec feedback "message"`
|
||||
- **AND** `gh` CLI is installed but not authenticated
|
||||
- **THEN** the system displays warning: "GitHub authentication required. Manual submission required."
|
||||
- **AND** outputs structured feedback content (same format as missing gh CLI scenario)
|
||||
- **AND** displays pre-filled GitHub issue URL for manual submission
|
||||
- **AND** displays authentication instructions: "To auto-submit in the future: gh auth login"
|
||||
- **AND** exits with zero code (successful fallback)
|
||||
|
||||
#### Scenario: Authenticated gh CLI
|
||||
|
||||
- **WHEN** user runs `openspec feedback "message"`
|
||||
- **AND** `gh auth status` returns success (authenticated)
|
||||
- **THEN** the system proceeds with feedback submission
|
||||
|
||||
### Requirement: Feedback skill for agents
|
||||
|
||||
The system SHALL install a `feedback` workflow — a skill and a slash command — as part of the core profile, so that `openspec init` and `openspec update` deliver it without the user selecting it. The skill SHALL guide an agent through drafting and submitting a report.
|
||||
|
||||
The skill SHALL find facts rather than ask for them. Everything observable from the conversation or the environment — the task in progress, the failing command and its output, the OpenSpec version, the platform, and the agent and model — SHALL be filled in by the skill, and SHALL NOT be put to the user as a question.
|
||||
|
||||
The skill SHALL refine the report with the user before drafting it, because a report that arrives already scoped is the labour it saves later. It SHALL put to the user the judgements only the user holds — what the report is really asking for, who it affects, what it deliberately excludes, and whether a reported problem is the problem or a symptom — and SHALL carry a recommended answer with each, so that agreement costs a word. It SHALL order questions by dependency, never asking one whose answer depends on a question still open.
|
||||
|
||||
Refinement SHALL end when nothing material is left unsettled, rather than at a fixed number of questions: a one-line typo report and a half-formed feature idea do not need the same conversation. The skill SHALL then present one complete draft and submit only on confirmation.
|
||||
|
||||
The drafted fields SHALL be the fields of the issue form matching the report type, so that a report filed by the skill and a report filed through the form read the same.
|
||||
|
||||
#### Scenario: Agent-initiated feedback
|
||||
|
||||
- **WHEN** user invokes the feedback skill in an agent conversation
|
||||
- **THEN** the agent gathers context from the conversation
|
||||
- **AND** drafts a report with enriched content
|
||||
- **AND** anonymizes sensitive information
|
||||
- **AND** presents the draft to the user for approval
|
||||
- **AND** submits via the `openspec feedback` command on user confirmation
|
||||
|
||||
#### Scenario: Installed by default
|
||||
|
||||
- **WHEN** a user runs `openspec init` or `openspec update` on the default profile
|
||||
- **THEN** the feedback skill and its slash command are installed alongside the other core workflows
|
||||
- **AND** a project on a custom profile is told the workflow is available rather than having it added silently
|
||||
|
||||
#### Scenario: Facts are found, not asked for
|
||||
|
||||
- **WHEN** the version, the platform, the failing command, and the model are available from the conversation or the environment
|
||||
- **THEN** the agent fills those fields itself
|
||||
- **AND** does not put any of them to the user as a question
|
||||
|
||||
#### Scenario: Refining before drafting
|
||||
|
||||
- **WHEN** the user reports a problem whose scope, audience, or intended outcome is unsettled
|
||||
- **THEN** the agent puts those judgements to the user before drafting
|
||||
- **AND** carries a recommended answer with each one
|
||||
- **AND** asks nothing whose answer depends on a question still open
|
||||
- **AND** proceeds to the draft once nothing material is left unsettled
|
||||
|
||||
#### Scenario: A report that needs no refining
|
||||
|
||||
- **WHEN** the report is already unambiguous, such as a typo or a one-line reproduction
|
||||
- **THEN** the agent drafts it without manufacturing questions to ask
|
||||
|
||||
#### Scenario: Context enrichment
|
||||
|
||||
- **WHEN** agent drafts feedback
|
||||
- **THEN** the agent includes relevant context such as:
|
||||
- What task was being performed
|
||||
- What worked well or poorly
|
||||
- Specific friction points or praise
|
||||
|
||||
#### Scenario: Anonymization
|
||||
|
||||
- **WHEN** agent drafts feedback
|
||||
- **THEN** the agent removes or replaces:
|
||||
- File paths with `<path>` or generic descriptions
|
||||
- API keys, tokens, secrets with `<redacted>`
|
||||
- Company/organization names with `<company>`
|
||||
- Personal names with `<user>`
|
||||
- Specific URLs with `<url>` unless public/relevant
|
||||
|
||||
#### Scenario: User confirmation required
|
||||
|
||||
- **WHEN** agent has drafted feedback
|
||||
- **THEN** the agent MUST show the complete draft to the user
|
||||
- **AND** ask for explicit approval before submitting
|
||||
- **AND** allow the user to request modifications
|
||||
- **AND** only submit after user confirms
|
||||
|
||||
### Requirement: Shell completions
|
||||
|
||||
The system SHALL provide shell completions for the feedback command.
|
||||
|
||||
#### Scenario: Command completion
|
||||
|
||||
- **WHEN** user types `openspec fee<TAB>`
|
||||
- **THEN** the shell completes to `openspec feedback`
|
||||
|
||||
#### Scenario: Flag completion
|
||||
|
||||
- **WHEN** user types `openspec feedback "msg" --<TAB>`
|
||||
- **THEN** the shell suggests available flags (`--body`, `--type`)
|
||||
|
||||
#### Scenario: Type value completion
|
||||
|
||||
- **WHEN** user types `openspec feedback "msg" --type <TAB>`
|
||||
- **THEN** the shell suggests `bug`, `feature`, and `feedback`
|
||||
@@ -1,168 +0,0 @@
|
||||
## 1. Author the missing command template
|
||||
|
||||
- [ ] 1.1 Add `getOpsxFeedbackCommandTemplate()` to
|
||||
`src/core/templates/workflows/feedback.ts`, following the shape of
|
||||
`getOpsxVerifyCommandTemplate`
|
||||
- [ ] 1.2 Include an `$ARGUMENTS` placeholder so the message can be passed at
|
||||
invocation — `test/core/command-generation/adapters.test.ts:158` asserts
|
||||
`onboard` is the only command without one, and that assertion is the
|
||||
tripwire for this task
|
||||
- [ ] 1.3 Re-export it from `src/core/templates/skill-templates.ts:21` beside the
|
||||
existing skill export
|
||||
|
||||
## 2. Rewrite the feedback skill to refine, then draft
|
||||
|
||||
- [ ] 2.1 Replace the skill instructions in
|
||||
`src/core/templates/workflows/feedback.ts`: find the observable facts
|
||||
first and never ask for one of them; put the user's judgements to them —
|
||||
what the report is really asking for, who it affects, what it excludes,
|
||||
whether the reported problem is the problem or a symptom — each with a
|
||||
recommended answer; order by dependency; stop when nothing material is
|
||||
unsettled; then one complete draft, submitted on one confirmation
|
||||
- [ ] 2.2 Give it a way to skip refinement entirely when the report is already
|
||||
unambiguous. A skill that manufactures questions for a typo teaches users to
|
||||
stop invoking it
|
||||
- [ ] 2.3 Make the drafted fields the issue forms' fields, per type, in the forms'
|
||||
order
|
||||
- [ ] 2.4 Teach it `openspec feedback --type` and when to choose each type
|
||||
- [ ] 2.5 Keep the anonymization rules and the show-draft-before-submitting
|
||||
guardrail from the current template — they are contract, not style
|
||||
- [ ] 2.6 Write it in the current style rather than the template's original: state
|
||||
why something matters instead of stacking capitalised MUSTs, phrase
|
||||
instructions positively rather than as prohibitions, and cut anything not
|
||||
pulling its weight. The whole skill has to fit one SKILL.md — see design.md,
|
||||
"One file, no reference layer"
|
||||
- [ ] 2.7 Change the template's `name` from `feedback` to `openspec-feedback`
|
||||
(`src/core/templates/workflows/feedback.ts:11`). The frontmatter `name:` in
|
||||
the generated SKILL.md comes from this field
|
||||
(`src/core/shared/skill-generation.ts:142`), and every other registered
|
||||
template sets it equal to its skill directory. Left alone, this would be the
|
||||
one skill whose frontmatter disagrees with its directory,
|
||||
`OPENSPEC_SKILL_NAMES`, and the `/openspec-feedback` reference in task 3.7.
|
||||
The workflow id stays `feedback`
|
||||
|
||||
## 3. Register `feedback` as a core workflow
|
||||
|
||||
Six id lists carry this information. Two are caught by `tsc` or a test; the rest
|
||||
drift silently, so all six change in one pass.
|
||||
|
||||
- [ ] 3.1 `src/core/profiles.ts:19` — add `feedback` to `ALL_WORKFLOWS`
|
||||
- [ ] 3.2 `src/core/profiles.ts:14` — add `feedback` to `CORE_WORKFLOWS`
|
||||
- [ ] 3.3 `src/core/profile-sync-drift.ts:28` — add `feedback: 'openspec-feedback'`
|
||||
to `WORKFLOW_TO_SKILL_DIR` (compile-enforced: `Record<WorkflowId, string>`)
|
||||
- [ ] 3.4 `src/core/init.ts:111` — add the same entry to the duplicate map there
|
||||
(typed `Record<string, string>`, so nothing catches its absence)
|
||||
- [ ] 3.5 `src/core/config.ts:3` — add `openspec-feedback` to
|
||||
`OPENSPEC_SKILL_NAMES`, which drives tool detection and skill-status counts
|
||||
- [ ] 3.6 `src/core/shared/tool-detection.ts:41` — add `feedback` to `COMMAND_IDS`
|
||||
- [ ] 3.7 `src/utils/command-references.ts:53` — add the entry to
|
||||
`COMMAND_TO_SKILL_NAME`, without which `transformCommandInvocations` leaves
|
||||
the id unrewritten
|
||||
- [ ] 3.8 `src/core/shared/skill-generation.ts:70` and `:97` — register the skill
|
||||
template and the command template, with their imports
|
||||
- [ ] 3.9 `src/commands/config.ts:47` — add a `WORKFLOW_PROMPT_META` entry, or the
|
||||
picker shows the raw id (`test/commands/config.test.ts:399` enforces this)
|
||||
- [ ] 3.10 Confirm `src/core/legacy-cleanup.ts:78` needs **no** entry: that list
|
||||
cleans up Codex prompts that once shipped, and `feedback` never did
|
||||
|
||||
## 4. `--type` on the CLI
|
||||
|
||||
- [ ] 4.1 `src/cli/index.ts:567` — add `--type <type>` beside `--body`
|
||||
- [ ] 4.2 `src/core/completions/command-registry.ts:479` — add the same flag with
|
||||
its accepted values, or completions and the CLI disagree
|
||||
- [ ] 4.3 `src/commands/feedback.ts` — validate the value, rejecting an
|
||||
unrecognized type before contacting GitHub
|
||||
- [ ] 4.4 Build the title per type: the `Feedback: ` prefix for `feedback`, none
|
||||
for `bug` and `feature`; keep the existing 72-character grapheme-safe
|
||||
shortening for all three
|
||||
- [ ] 4.5 Build the body sections per type, matching the issue forms' fields and
|
||||
order, with the existing metadata footer unchanged
|
||||
- [ ] 4.6 Request the labels the matching form applies — `bug, needs-triage` for
|
||||
`--type bug`, `enhancement, needs-triage` for `--type feature`, `feedback`
|
||||
for the default; keep the missing-label retry for all three
|
||||
- [ ] 4.7 Repository setting, not code: create the `feedback` label in
|
||||
`Fission-AI/OpenSpec`, which the command has always requested and the
|
||||
repository has never defined
|
||||
|
||||
## 5. Prefill the issue form in the manual fallback
|
||||
|
||||
Blocked on [#1847](https://github.com/Fission-AI/OpenSpec/pull/1847), which adds
|
||||
the forms these field ids come from. Nothing else in this change is.
|
||||
|
||||
- [ ] 5.1 `generateManualSubmissionUrl()` in `src/commands/feedback.ts:127` — take
|
||||
the type, target `template=bug_report.yml` / `feature_request.yml`, and emit
|
||||
one parameter per form field id
|
||||
- [ ] 5.2 Omit a field the command has nothing for, rather than sending it empty
|
||||
- [ ] 5.3 Cap the encoded URL; over the cap, return today's blank-issue URL with
|
||||
the body intact
|
||||
- [ ] 5.4 Return the blank-issue URL unconditionally for `--type feedback`, which
|
||||
has no form
|
||||
- [ ] 5.5 Keep the `--- FORMATTED FEEDBACK ---` block printing the complete body
|
||||
on every fallback path, so no report depends on the URL surviving
|
||||
|
||||
## 6. Tests
|
||||
|
||||
The first one fails as soon as task 3.2 lands; the rest fail at 13.
|
||||
|
||||
- [ ] 6.1 `test/core/profiles.test.ts:12` — `expect(CORE_WORKFLOWS).toEqual([...])`
|
||||
is an exact-equality assertion on the six core ids and breaks at seven
|
||||
- [ ] 6.2 `test/core/profiles.test.ts:27` (`ALL_WORKFLOWS` length) and `:31`
|
||||
(exact id list)
|
||||
- [ ] 6.3 `test/core/shared/skill-generation.test.ts` — the count assertions at
|
||||
`:13`, `:94`, `:149`
|
||||
- [ ] 6.4 `test/core/shared/tool-detection.test.ts:33`
|
||||
- [ ] 6.5 `test/core/onboarding-commands.test.ts:52` — the note's exact text; note
|
||||
that `feedback` joining the **core** profile keeps this at "6 more
|
||||
workflows", so verify rather than assume
|
||||
- [ ] 6.6 `test/core/init.test.ts:2117` and `test/core/update.test.ts:3441` —
|
||||
optional-workflow enumerations
|
||||
- [ ] 6.7 `test/utils/command-references.test.ts:92` and `:139`
|
||||
- [ ] 6.8 `test/commands/config-profile.test.ts` — a core workflow is checked by
|
||||
default, unlike the non-core pattern at `:222`
|
||||
|
||||
New coverage:
|
||||
|
||||
- [ ] 6.9 `test/commands/feedback.test.ts` — one case per `--type` asserting
|
||||
title, body sections, and requested label; the unrecognized-type rejection;
|
||||
and that the bare command is byte-identical to today's output
|
||||
- [ ] 6.10 Fallback URL: form targeted per type, fields prefilled by id, blank-issue
|
||||
URL for `feedback`, and blank-issue URL when over the cap
|
||||
- [ ] 6.11 A test asserting the field ids the CLI hard-codes are exactly the ids
|
||||
in `.github/ISSUE_TEMPLATE/bug_report.yml` and `feature_request.yml`, so a
|
||||
renamed form field fails CI here rather than emptying a field for users
|
||||
- [ ] 6.12 `test/core/init.test.ts` / `test/core/update.test.ts` — the feedback
|
||||
skill and command are written on the default profile
|
||||
|
||||
## 7. Parity: hashes and the static mirror
|
||||
|
||||
`scripts/parity-hash-shared.mjs` refuses to run while a registered skill has no
|
||||
pinned hash, so 7.1 comes before 7.2.
|
||||
|
||||
- [ ] 7.1 `test/core/templates/skill-templates-parity.test.ts` — add
|
||||
`openspec-feedback` to `EXPECTED_GENERATED_SKILL_CONTENT_HASHES` and to
|
||||
`GENERATED_SKILL_FACTORIES`, add the command template to
|
||||
`EXPECTED_FUNCTION_HASHES`, and delete the comment at `:83` that explains
|
||||
why feedback was excluded
|
||||
- [ ] 7.2 `pnpm build && pnpm regen:parity-hashes`, then
|
||||
`pnpm vitest run test/core/templates/skill-templates-parity.test.ts`
|
||||
- [ ] 7.3 `pnpm build && pnpm generate:skills` to write
|
||||
`skills/openspec-feedback/SKILL.md`, and commit it —
|
||||
`test/core/templates/skillssh-parity.test.ts` asserts the committed
|
||||
directory set equals `getSkillTemplates()` exactly
|
||||
|
||||
## 8. Docs
|
||||
|
||||
- [ ] 8.1 Add the workflow to `docs/commands.md`, `docs/workflows.md`,
|
||||
`docs/supported-tools.md`, `docs/glossary.md`, and `README.md`, matching how
|
||||
each already lists `verify` and `onboard`
|
||||
- [ ] 8.2 `docs-lab/reference/skills.md`, `docs-lab/customize/profiles.md`, and
|
||||
`docs-lab/reference/configuration/config-json.md`
|
||||
|
||||
## 9. Gate
|
||||
|
||||
- [ ] 9.1 `pnpm build && pnpm test && pnpm exec tsc --noEmit && pnpm lint` — the
|
||||
four commands CI runs; `pnpm build` first, because the suite runs against
|
||||
the build output
|
||||
- [ ] 9.2 `pnpm changeset` — this changes what every user gets from `init` and
|
||||
`update`
|
||||
- [ ] 9.3 `openspec validate ship-feedback-workflow --strict`
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Grok Command Adapter
|
||||
*
|
||||
* Formats commands for xAI's Grok Build CLI (`grok`).
|
||||
*
|
||||
* Grok has no separate "commands" subsystem: its skill loader also scans
|
||||
* `<vendor-dir>/commands/` and turns each Markdown file there into a slash
|
||||
* command. Two properties of that loader shape this adapter:
|
||||
*
|
||||
* - The scan is flat. `find_command_paths` reads `commands/` with a single
|
||||
* `read_dir` and keeps only files, so a nested `commands/opsx/<id>.md`
|
||||
* is silently ignored rather than namespaced. Commands must be written
|
||||
* directly in `commands/`, which makes the filename the command name —
|
||||
* the `flat` invocation style, `/opsx-<id>`.
|
||||
* - The name comes from frontmatter `name` when present and from the file
|
||||
* stem otherwise, and either is normalized to `[a-z0-9-]`. `CommandContent`
|
||||
* carries a display name ("OPSX: New"), not a command id, so emitting it
|
||||
* would hand Grok a name to normalize instead of the stem OpenSpec
|
||||
* controls. This adapter emits `description` only and lets the stem name
|
||||
* the command, which keeps the generated name identical to the one
|
||||
* advertised in skills, docs, and the getting-started hint.
|
||||
*
|
||||
* Grok also scans `.agents/`, `.claude/`, and `.cursor/` for skills and
|
||||
* commands. Nothing writes `.agents/commands/`, and Claude Code nests its
|
||||
* commands under `opsx/`, which the flat scan skips — but Cursor writes them
|
||||
* flat as `opsx-<id>.md`, and every one of those roots can hold an identical
|
||||
* `skills/openspec-<name>/SKILL.md`. So a project configured for Grok alongside
|
||||
* Cursor or Claude Code can present Grok with the same name from two roots.
|
||||
* The copies differ only in the frontmatter each tool needs, so whichever one
|
||||
* Grok resolves to runs the same workflow. OpenSpec writes `.grok/`
|
||||
* unconditionally rather than trying to predict that resolution: the user may
|
||||
* drop the other tool at any time, and a missing `.grok/` tree would then
|
||||
* leave Grok with nothing.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { CommandContent, ToolCommandAdapter } from '../types.js';
|
||||
import { escapeYamlValue } from '../yaml.js';
|
||||
|
||||
/**
|
||||
* Grok adapter for command generation.
|
||||
* File path: .grok/commands/opsx-<id>.md
|
||||
* Frontmatter: description
|
||||
*/
|
||||
export const grokAdapter: ToolCommandAdapter = {
|
||||
toolId: 'grok',
|
||||
|
||||
getFilePath(commandId: string): string {
|
||||
return path.join('.grok', 'commands', `opsx-${commandId}.md`);
|
||||
},
|
||||
|
||||
formatFile(content: CommandContent): string {
|
||||
return `---
|
||||
description: ${escapeYamlValue(content.description)}
|
||||
---
|
||||
|
||||
${content.body}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -20,6 +20,7 @@ export { devinAdapter } from './devin.js';
|
||||
export { factoryAdapter } from './factory.js';
|
||||
export { geminiAdapter } from './gemini.js';
|
||||
export { githubCopilotAdapter } from './github-copilot.js';
|
||||
export { grokAdapter } from './grok.js';
|
||||
export { iflowAdapter } from './iflow.js';
|
||||
export { junieAdapter } from './junie.js';
|
||||
export { kilocodeAdapter } from './kilocode.js';
|
||||
|
||||
@@ -22,6 +22,7 @@ import { cursorAdapter } from './adapters/cursor.js';
|
||||
import { factoryAdapter } from './adapters/factory.js';
|
||||
import { geminiAdapter } from './adapters/gemini.js';
|
||||
import { githubCopilotAdapter } from './adapters/github-copilot.js';
|
||||
import { grokAdapter } from './adapters/grok.js';
|
||||
import { iflowAdapter } from './adapters/iflow.js';
|
||||
import { junieAdapter } from './adapters/junie.js';
|
||||
import { kilocodeAdapter } from './adapters/kilocode.js';
|
||||
@@ -61,6 +62,7 @@ export class CommandAdapterRegistry {
|
||||
CommandAdapterRegistry.register(factoryAdapter);
|
||||
CommandAdapterRegistry.register(geminiAdapter);
|
||||
CommandAdapterRegistry.register(githubCopilotAdapter);
|
||||
CommandAdapterRegistry.register(grokAdapter);
|
||||
CommandAdapterRegistry.register(iflowAdapter);
|
||||
CommandAdapterRegistry.register(junieAdapter);
|
||||
CommandAdapterRegistry.register(kilocodeAdapter);
|
||||
|
||||
@@ -62,6 +62,7 @@ export const AI_TOOLS: AIToolOption[] = [
|
||||
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' },
|
||||
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },
|
||||
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'], requiresIdeRestart: true },
|
||||
{ name: 'Grok Build', value: 'grok', available: true, successLabel: 'Grok Build', skillsDir: '.grok' },
|
||||
{ name: 'Hermes Agent', value: 'hermes', available: true, successLabel: 'Hermes Agent', skillsDir: '.hermes', detectionPaths: ['.hermes', 'HERMES.md', '.hermes.md'], setupNote: "Hermes only loads skills from ~/.hermes/skills by default. Add this project's .hermes/skills directory to skills.external_dirs in ~/.hermes/config.yaml so Hermes picks up the generated OpenSpec skills." },
|
||||
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },
|
||||
{ name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie', requiresIdeRestart: true },
|
||||
|
||||
@@ -533,6 +533,25 @@ describe('available-tools', () => {
|
||||
expect(ohMyPiTool?.skillsDir).toBe('.omp');
|
||||
});
|
||||
|
||||
it('should detect Grok Build when .grok directory exists', async () => {
|
||||
await fs.mkdir(path.join(testDir, '.grok'), { recursive: true });
|
||||
|
||||
const tools = getAvailableTools(testDir);
|
||||
const toolValues = tools.map((t) => t.value);
|
||||
expect(toolValues).toContain('grok');
|
||||
|
||||
const grokTool = tools.find((t) => t.value === 'grok');
|
||||
expect(grokTool?.name).toBe('Grok Build');
|
||||
expect(grokTool?.skillsDir).toBe('.grok');
|
||||
// `available` gates the workset tool picker. The cli-e2e `--tools` list
|
||||
// derives its expectation from AI_TOOLS itself, so it cancels out when
|
||||
// this flag flips and nothing else would notice Grok disappearing.
|
||||
expect(grokTool?.available).toBe(true);
|
||||
// Grok is a CLI: it picks up new command and skill files without an
|
||||
// editor restart, so no restart hint should be offered (#1067).
|
||||
expect(grokTool?.requiresIdeRestart).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should detect SourceCraft Code Assistant when .codeassistant directory exists', async () => {
|
||||
await fs.mkdir(path.join(testDir, '.codeassistant'), { recursive: true });
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { devinAdapter } from '../../../src/core/command-generation/adapters/devi
|
||||
import { factoryAdapter } from '../../../src/core/command-generation/adapters/factory.js';
|
||||
import { geminiAdapter } from '../../../src/core/command-generation/adapters/gemini.js';
|
||||
import { githubCopilotAdapter } from '../../../src/core/command-generation/adapters/github-copilot.js';
|
||||
import { grokAdapter } from '../../../src/core/command-generation/adapters/grok.js';
|
||||
import { iflowAdapter } from '../../../src/core/command-generation/adapters/iflow.js';
|
||||
import { junieAdapter } from '../../../src/core/command-generation/adapters/junie.js';
|
||||
import { kilocodeAdapter } from '../../../src/core/command-generation/adapters/kilocode.js';
|
||||
@@ -1136,7 +1137,7 @@ describe('command-generation/adapters', () => {
|
||||
amazonQAdapter, antigravityAdapter, auggieAdapter, bobAdapter, clineAdapter,
|
||||
codebuddyAdapter, continueAdapter, costrictAdapter,
|
||||
crushAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter,
|
||||
iflowAdapter, kilocodeAdapter, kiroAdapter, lingmaAdapter, ohMyPiAdapter,
|
||||
grokAdapter, iflowAdapter, kilocodeAdapter, kiroAdapter, lingmaAdapter, ohMyPiAdapter,
|
||||
opencodeAdapter, piAdapter, qoderAdapter, qwenAdapter, roocodeAdapter,
|
||||
traeAdapter, zcodeAdapter
|
||||
];
|
||||
@@ -1148,6 +1149,85 @@ describe('command-generation/adapters', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('grokAdapter', () => {
|
||||
it('should have correct toolId', () => {
|
||||
expect(grokAdapter.toolId).toBe('grok');
|
||||
});
|
||||
|
||||
it('should generate correct file path', () => {
|
||||
const filePath = grokAdapter.getFilePath('explore');
|
||||
expect(filePath).toBe(path.join('.grok', 'commands', 'opsx-explore.md'));
|
||||
});
|
||||
|
||||
it('should generate correct file path for different command IDs', () => {
|
||||
expect(grokAdapter.getFilePath('new')).toBe(path.join('.grok', 'commands', 'opsx-new.md'));
|
||||
expect(grokAdapter.getFilePath('bulk-archive')).toBe(
|
||||
path.join('.grok', 'commands', 'opsx-bulk-archive.md')
|
||||
);
|
||||
});
|
||||
|
||||
// Grok's command loader reads `commands/` with a single non-recursive
|
||||
// scan, so a file one directory deeper is skipped rather than namespaced.
|
||||
// Nesting these under `opsx/` would leave Grok users with no commands at
|
||||
// all, and nothing else in the suite would notice.
|
||||
it('writes commands directly in commands/, which Grok scans without recursion', () => {
|
||||
for (const commandId of ['explore', 'propose', 'bulk-archive']) {
|
||||
const segments = grokAdapter.getFilePath(commandId).split(path.sep);
|
||||
expect(segments).toEqual(['.grok', 'commands', `opsx-${commandId}.md`]);
|
||||
}
|
||||
});
|
||||
|
||||
// The file stem is what Grok registers, so it has to survive Grok's own
|
||||
// name rules, which its loader applies to commands and skills alike:
|
||||
// lowercase [a-z0-9-], no leading or trailing hyphen, no `--`, and at most
|
||||
// 64 characters. Grok normalizes a stem that breaks the character rules,
|
||||
// so the risk is a silently renamed command rather than a rejected one.
|
||||
it('names every command with a stem Grok accepts verbatim', () => {
|
||||
const contents = getCommandContents();
|
||||
expect(contents.length).toBeGreaterThan(0);
|
||||
for (const { id } of contents) {
|
||||
const stem = path.basename(grokAdapter.getFilePath(id), '.md');
|
||||
expect(stem).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
||||
expect(stem.length).toBeLessThanOrEqual(64);
|
||||
}
|
||||
});
|
||||
|
||||
it('should format file with correct YAML frontmatter', () => {
|
||||
const output = grokAdapter.formatFile(sampleContent);
|
||||
|
||||
const frontmatter = output.match(/^---\n([\s\S]*?)\n---\n\n/);
|
||||
expect(frontmatter).not.toBeNull();
|
||||
expect(parseYaml(frontmatter![1])).toEqual({ description: sampleContent.description });
|
||||
expect(output.slice(frontmatter![0].length)).toBe(`${sampleContent.body}\n`);
|
||||
});
|
||||
|
||||
// `CommandContent.name` is a display name ("OPSX: New"). Grok normalizes a
|
||||
// frontmatter `name` to [a-z0-9-] and prefers it over the stem, so
|
||||
// emitting one would hand Grok a name OpenSpec does not control while
|
||||
// docs, skills, and the getting-started hint all advertise the stem.
|
||||
it('omits frontmatter name so the file stem names the command', () => {
|
||||
const output = grokAdapter.formatFile({ ...sampleContent, name: 'OPSX: Explore' });
|
||||
const frontmatter = output.match(/^---\n([\s\S]*?)\n---/);
|
||||
expect(Object.keys(parseYaml(frontmatter![1]) as object)).toEqual(['description']);
|
||||
expect(frontmatter![1]).not.toContain('OPSX: Explore');
|
||||
});
|
||||
|
||||
it('generates registered commands with hyphenated workflow references', () => {
|
||||
const content: CommandContent = {
|
||||
...sampleContent,
|
||||
body: 'Use /opsx:propose, /opsx:update, and /opsx:bulk-archive. Keep /opsx:unknown.',
|
||||
};
|
||||
const adapter = CommandAdapterRegistry.get('grok');
|
||||
expect(adapter).toBe(grokAdapter);
|
||||
const generated = generateCommand(content, adapter!);
|
||||
|
||||
expect(generated.path).toBe(path.join('.grok', 'commands', 'opsx-explore.md'));
|
||||
expect(generated.fileContent).toContain(
|
||||
'Use /opsx-propose, /opsx-update, and /opsx-bulk-archive. Keep /opsx:unknown.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codeassistantAdapter', () => {
|
||||
it('should have correct toolId', () => {
|
||||
expect(codeassistantAdapter.toolId).toBe('codeassistant');
|
||||
|
||||
@@ -1540,6 +1540,47 @@ describe('InitCommand', () => {
|
||||
expect(content).not.toContain('/opsx-');
|
||||
});
|
||||
|
||||
it('should generate Grok skills and flat commands', async () => {
|
||||
const initCommand = new InitCommand({ tools: 'grok', force: true });
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const skillFile = path.join(testDir, '.grok', 'skills', 'openspec-propose', 'SKILL.md');
|
||||
expect(await fileExists(skillFile)).toBe(true);
|
||||
|
||||
// Grok scans `.grok/commands/` without recursion, so the command file
|
||||
// must sit directly in it — a nested `opsx/` directory is not
|
||||
// namespaced, it is skipped, and the user gets no commands at all.
|
||||
const cmdFile = path.join(testDir, '.grok', 'commands', 'opsx-propose.md');
|
||||
expect(await fileExists(cmdFile)).toBe(true);
|
||||
expect(await fileExists(path.join(testDir, '.grok', 'commands', 'opsx'))).toBe(false);
|
||||
|
||||
const content = await fs.readFile(cmdFile, 'utf-8');
|
||||
expect(content).toMatch(/^---\ndescription: "/);
|
||||
// The filename names the command, so bodies must advertise the hyphen form.
|
||||
expect(content).toContain('/opsx-');
|
||||
expect(content).not.toContain('/opsx:');
|
||||
});
|
||||
|
||||
it('should advertise the hyphen form in Grok skills and the getting-started hint', async () => {
|
||||
const initCommand = new InitCommand({ tools: 'grok', force: true });
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const skillContent = await fs.readFile(
|
||||
path.join(testDir, '.grok', 'skills', 'openspec-propose', 'SKILL.md'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(skillContent).toContain('/opsx-');
|
||||
expect(skillContent).not.toContain('/opsx:');
|
||||
|
||||
const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls
|
||||
.flat()
|
||||
.map(String);
|
||||
const startHint = logCalls.find((entry) => entry.includes('Start your first change'));
|
||||
expect(startHint).toBeTruthy();
|
||||
expect(startHint).toContain('/opsx-propose');
|
||||
expect(startHint).not.toContain('/opsx:propose');
|
||||
});
|
||||
|
||||
it('should generate Continue prompt files', async () => {
|
||||
const initCommand = new InitCommand({ tools: 'continue', force: true });
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
@@ -1261,6 +1261,42 @@ metadata:
|
||||
expect(skillContent).not.toContain('/opsx-');
|
||||
});
|
||||
|
||||
it.each(['both', 'commands'] as const)(
|
||||
'should discover and refresh Grok Build commands with delivery=%s',
|
||||
async (delivery) => {
|
||||
setMockConfig({ featureFlags: {}, profile: 'core', delivery });
|
||||
const commandsDir = path.join(testDir, '.grok', 'commands');
|
||||
await fs.mkdir(commandsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(commandsDir, 'opsx-apply.md'), 'old command content');
|
||||
const skillFile = path.join(testDir, '.grok', 'skills', 'openspec-apply-change', 'SKILL.md');
|
||||
if (delivery === 'both') {
|
||||
await fs.mkdir(path.dirname(skillFile), { recursive: true });
|
||||
await fs.writeFile(skillFile, 'old skill content');
|
||||
}
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const commandContent = await fs.readFile(path.join(commandsDir, 'opsx-apply.md'), 'utf-8');
|
||||
expect(commandContent).toMatch(/^---\ndescription: /);
|
||||
expect(commandContent).toContain('/opsx-archive');
|
||||
expect(commandContent).not.toContain('/opsx:');
|
||||
expect(await FileSystemUtils.fileExists(path.join(commandsDir, 'opsx-propose.md'))).toBe(true);
|
||||
// Flat, never nested: Grok's command scan does not recurse.
|
||||
expect(await FileSystemUtils.fileExists(path.join(commandsDir, 'opsx'))).toBe(false);
|
||||
|
||||
expect(await FileSystemUtils.fileExists(skillFile)).toBe(delivery === 'both');
|
||||
if (delivery === 'both') {
|
||||
const skillContent = await fs.readFile(skillFile, 'utf-8');
|
||||
expect(skillContent).toContain('/opsx-archive');
|
||||
expect(skillContent).not.toContain('/opsx:');
|
||||
}
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
await updateCommand.execute(testDir);
|
||||
expect(consoleSpy.mock.calls.flat().map(String).some((entry) => entry.includes('up to date'))).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['both', 'commands'] as const)(
|
||||
'should discover and refresh SourceCraft Code Assistant commands with delivery=%s',
|
||||
async (delivery) => {
|
||||
@@ -3563,6 +3599,42 @@ More user content after markers.
|
||||
expect(updateSkillContent).toContain('/openspec-');
|
||||
});
|
||||
|
||||
it.each(['skills', 'commands'] as const)(
|
||||
'should switch Grok Build to delivery=%s without deleting custom files',
|
||||
async (delivery) => {
|
||||
await new InitCommand({ tools: 'grok', force: true }).execute(testDir);
|
||||
const toolDir = path.join(testDir, '.grok');
|
||||
const customCommand = path.join(toolDir, 'commands', 'my-command.md');
|
||||
const customSkill = path.join(toolDir, 'skills', 'custom-review', 'SKILL.md');
|
||||
const userConfig = path.join(toolDir, 'config.toml');
|
||||
await fs.mkdir(path.dirname(customSkill), { recursive: true });
|
||||
await fs.writeFile(customCommand, 'custom command');
|
||||
await fs.writeFile(customSkill, 'custom skill');
|
||||
await fs.writeFile(userConfig, '[permission]\n');
|
||||
|
||||
setMockConfig({ featureFlags: {}, profile: 'core', delivery });
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
expect(await FileSystemUtils.fileExists(path.join(toolDir, 'commands', 'opsx-apply.md'))).toBe(delivery === 'commands');
|
||||
const skillFile = path.join(toolDir, 'skills', 'openspec-apply-change', 'SKILL.md');
|
||||
expect(await FileSystemUtils.fileExists(skillFile)).toBe(delivery === 'skills');
|
||||
if (delivery === 'skills') {
|
||||
// No command files are written, so skill bodies must invoke skills by
|
||||
// name. Grok registers a user-invocable skill as `/<skill-name>`.
|
||||
const skillContent = await fs.readFile(skillFile, 'utf-8');
|
||||
expect(skillContent).toContain('/openspec-archive-change');
|
||||
expect(skillContent).not.toContain('/opsx:');
|
||||
expect(skillContent).not.toContain('/opsx-');
|
||||
expect(await FileSystemUtils.fileExists(path.join(toolDir, 'commands', 'opsx-propose.md'))).toBe(false);
|
||||
}
|
||||
// Grok's own project config and the user's hand-written command and
|
||||
// skill are never OpenSpec's to touch.
|
||||
expect(await fs.readFile(customCommand, 'utf-8')).toBe('custom command');
|
||||
expect(await fs.readFile(customSkill, 'utf-8')).toBe('custom skill');
|
||||
expect(await fs.readFile(userConfig, 'utf-8')).toBe('[permission]\n');
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['skills', 'commands'] as const)(
|
||||
'should switch SourceCraft Code Assistant to delivery=%s without deleting custom files',
|
||||
async (delivery) => {
|
||||
|
||||
@@ -272,7 +272,7 @@ describe('getTransformerForTool', () => {
|
||||
it('selects hyphen commands for every flat-invocation tool when commands are generated', () => {
|
||||
// These tools invoke commands by filename (/opsx-<id>), so skills must
|
||||
// reference the hyphen form their command files actually answer to.
|
||||
for (const toolId of ['bob', 'cursor', 'github-copilot', 'oh-my-pi', 'opencode', 'pi', 'qwen'] as const) {
|
||||
for (const toolId of ['bob', 'cursor', 'github-copilot', 'grok', 'oh-my-pi', 'opencode', 'pi', 'qwen'] as const) {
|
||||
for (const delivery of ['both', 'commands'] as const) {
|
||||
const transformer = getTransformerForTool(toolId, delivery, 'adapter-backed', FLAT_SLASH);
|
||||
expect(transformer?.('/opsx:apply'), `${toolId} ${delivery}`).toBe('/opsx-apply');
|
||||
|
||||
Reference in New Issue
Block a user