From c885d66bcf801b6b789582c5069f8827c3d00b2a Mon Sep 17 00:00:00 2001 From: eipastel Date: Mon, 22 Jun 2026 01:07:19 -0300 Subject: [PATCH 1/2] refactor(draft): registra issue sem brief, move brief para refine [NO-TICKET] O /ps:draft deixa de criar brief.md e a pasta local: passa a apenas registrar a mudanca como card no Backlog, com uma descricao curta no corpo da Issue. A pasta da change e o brief.md nascem agora no /ps:refine (a partir da descricao da Issue), antes do refine.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0169ZBEN4gvpvXGAwEE2siYZ --- .changeset/draft-registers-issue-only.md | 11 ++++++++ src/core/content/commands/draft.ts | 32 +++++++++++++++--------- src/core/content/commands/refine.ts | 14 ++++++++--- src/core/content/skills/refine.ts | 25 +++++++++++------- 4 files changed, 57 insertions(+), 25 deletions(-) create mode 100644 .changeset/draft-registers-issue-only.md diff --git a/.changeset/draft-registers-issue-only.md b/.changeset/draft-registers-issue-only.md new file mode 100644 index 0000000..d0ac972 --- /dev/null +++ b/.changeset/draft-registers-issue-only.md @@ -0,0 +1,11 @@ +--- +"@thiagodiogo/pscode": minor +--- + +refactor(draft): `/ps:draft` apenas registra a Issue, brief migra para o refine + +O `/ps:draft` deixa de criar `brief.md` (e a pasta local): agora só registra a +mudança como card no Backlog, com uma descrição curta no corpo da Issue. Sem +GitHub, há um fallback que grava um `brief.md` local mínimo. A pasta da change e o +`brief.md` passam a nascer no `/ps:refine` (a partir da descrição da Issue), antes +do `refine.md`. diff --git a/src/core/content/commands/draft.ts b/src/core/content/commands/draft.ts index 5510e36..e2fd040 100644 --- a/src/core/content/commands/draft.ts +++ b/src/core/content/commands/draft.ts @@ -6,23 +6,31 @@ export const draft: CommandSpec = { description: 'Captures a natural-language request as a short draft/brief in the Backlog.', body: `# /ps:draft -Take a natural-language request and capture it as a short **draft** — nothing -more. No analysis, no code: that is \`/ps:refine\`'s job. +Take a natural-language request and register it as a card in the **Backlog** — +nothing more. No analysis, no code, **and no \`brief.md\`**: structuring the change +is \`/ps:refine\`'s job. The draft only gets the idea onto the board. Use the **pscode-guided-sdd** skill (draft step). -1. Understand the request well enough to name it. -2. Create the folder \`pscode/changes/\` (slug in kebab-case). -3. Write a short \`brief.md\` (objective, expected behavior, out of scope). -4. **Stop and ask for validation.** +1. Understand the request well enough to name it (kebab-case slug = title). +2. Draft a **short description** — objective plus a line on expected behavior and + what's out of scope. A few lines, not a spec. +3. **Stop and ask for validation** of that description. -Do not ask the Grill Me questions and do not write code here. +Do not ask the Grill Me questions, do not write a \`brief.md\`, and do not write +code here. -## GitHub sync (if \`pscode/github.yaml\` exists) +## Register the card -Use the **pscode-github-sync** skill: create the Issue, add it to the Project, -**set status Backlog** (confirm it landed), and save the number to \`.issue\`. -Then post the **next-step comment** (\`/ps:refine \` in a fenced block). -Non-blocking only on \`gh\` failure. +**With GitHub (\`pscode/github.yaml\` exists):** use the **pscode-github-sync** +skill — create the Issue with that short description as its **body**, add it to +the Project, **set status Backlog** (confirm it landed). No local files are +created here; the Issue *is* the draft (the change folder and \`.issue\` are +written later, by \`/ps:refine\`). Then post the **next-step comment** +(\`/ps:refine \` in a fenced block). Non-blocking only on \`gh\` failure. + +**Without GitHub:** there is nowhere to register the card, so fall back to a +local record — create \`pscode/changes//\` and save the short description as +a minimal \`brief.md\`. This is the only case where \`/ps:draft\` writes a file. `, }; diff --git a/src/core/content/commands/refine.ts b/src/core/content/commands/refine.ts index 851d52d..2e6ec68 100644 --- a/src/core/content/commands/refine.ts +++ b/src/core/content/commands/refine.ts @@ -22,15 +22,21 @@ does not replace the status move; confirm the move landed. Non-blocking only on ## Then refine -1. Gather context before refining: - - Read \`brief.md\` (the draft). +1. **Set up the change and write the brief.** The draft now lives on the card, + not in a file. Resolve the slug (Issue title in kebab-case), create + \`pscode/changes//\`, and save the card number to \`.issue\`. Turn the + **Issue description** (the draft) into \`brief.md\` (objective, expected + behavior, out of scope). If a local \`brief.md\` already exists (no-GitHub + draft), use it as-is. +2. Gather context before refining: + - Read \`brief.md\`. - Read \`questions.md\`: fold in answered questions, note any still open. - **Analyze the code** the change will touch, so the refinement is grounded. - If \`pscode/github.yaml\` exists, read the Issue **description + comments** via **pscode-github-sync** — recent discussion may add or cut scope. -2. Run the **Grill Me** logic (skill \`pscode-grill-me\`) to close blocking +3. Run the **Grill Me** logic (skill \`pscode-grill-me\`) to close blocking ambiguities — at most 5 questions. -3. Write \`refine.md\` in the standard format: lean summary, technical detail, +4. Write \`refine.md\` in the standard format: lean summary, technical detail, in/out of scope, and a **\`## Subtasks\`** checklist (the unit \`/ps:dev\` runs). Do not write production code in this step. **Stop and ask for approval.** diff --git a/src/core/content/skills/refine.ts b/src/core/content/skills/refine.ts index 8497c9d..606a12c 100644 --- a/src/core/content/skills/refine.ts +++ b/src/core/content/skills/refine.ts @@ -17,24 +17,31 @@ consistent. to **assign the current user** *and* move the card → **In Refinement** (\`proposed\`). The assign does not replace the status move — run both and confirm the move landed. Non-blocking only on \`gh\` failure. -2. **Gather context.** Read everything that describes the demand: - - \`pscode/changes//brief.md\` — the draft. +2. **Set up the change and write the brief.** The draft now lives on the card, + not in a file. Resolve the slug (Issue title in kebab-case), create + \`pscode/changes//\`, and save the card number to \`.issue\`. Read the + **Issue description** (the draft) via \`pscode-github-sync\` and turn it into + \`brief.md\` with \`pscode-mini-spec\` (objective, expected behavior, out of + scope). If a local \`brief.md\` already exists (no-GitHub draft), use it as-is. +3. **Gather context.** Read everything that describes the demand: + - \`pscode/changes//brief.md\` — the brief you just wrote (or the + existing one). - \`pscode/changes//questions.md\` — fold in answered questions, note open ones (\`- [ ]\`). - **Analyze the relevant code** so the refinement is grounded in reality. - If \`pscode/github.yaml\` exists, read the Issue **description and comments** (\`gh issue view --repo --comments\` via \`pscode-github-sync\`). -3. Use \`pscode-grill-me\` to close blocking ambiguities (max 5 questions). The - spec format itself follows \`pscode-mini-spec\`. **Don't write production code.** -4. Write \`pscode/changes//refine.md\` in the standard format below. -5. Keep it short — fits on one terminal screen. -6. **Close the iteration with \`AskUserQuestion\`.** Ask how refined the change +4. Use \`pscode-grill-me\` to close blocking ambiguities (max 5 questions). + **Don't write production code.** +5. Write \`pscode/changes//refine.md\` in the standard format below. +6. Keep it short — fits on one terminal screen. +7. **Close the iteration with \`AskUserQuestion\`.** Ask how refined the change is, offering a predefined **"Está refinada"** answer; the free-text field lets the user say what is still missing. If the user answers anything other than "Está refinada", treat the input as the next gap to close, **loop back - to step 2**, and ask again at the end. Only move on once the user picks + to step 3**, and ask again at the end. Only move on once the user picks "Está refinada". -7. **Once refined**, use \`pscode-github-sync\` to, in order: +8. **Once refined**, use \`pscode-github-sync\` to, in order: - **Create one native sub-issue per \`## Subtasks\` item**, linked to the card, so the board shows the breakdown and its progress (idempotent — skip titles that already exist). From 9b49ed0206e7a32f30d64d15df99ccf94b2ac464 Mon Sep 17 00:00:00 2001 From: eipastel Date: Mon, 22 Jun 2026 01:07:28 -0300 Subject: [PATCH 2/2] feat(init): torna o fluxo de PR opcional [NO-TICKET] Adiciona a pergunta 'usar fluxo de PR?' no init (antes da pergunta do board) e as flags --pr/--no-pr, gravando pr_flow em pscode/config.yaml. O conteudo dos comandos/skills de dev passa a ser condicional via marcadores {{#pr}}/{{^pr}} (core/content/flags.ts), resolvidos no render: instala o fluxo com pull request (PR draft, Ready for Review, nunca faz merge) ou o fluxo direto na branch atual (commit direto, sem PR). O update re-renderiza respeitando o pr_flow do projeto. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0169ZBEN4gvpvXGAwEE2siYZ --- .changeset/optional-pr-flow.md | 14 ++++++ CLAUDE.md | 7 ++- src/cli/index.ts | 3 ++ src/commands/init.ts | 29 +++++++++++- src/commands/update.ts | 12 +++-- src/core/adapters.ts | 20 ++++---- src/core/content/commands/complete.ts | 2 +- src/core/content/commands/dev.ts | 18 +++++--- src/core/content/flags.ts | 21 +++++++++ src/core/content/skills/complete.ts | 2 +- src/core/content/skills/dev.ts | 21 ++++++--- src/core/content/skills/github-sync.ts | 28 +++++------ src/core/content/skills/guided-sdd.ts | 21 +++++---- src/core/i18n.ts | 4 ++ src/core/installer.ts | 18 ++++++-- src/core/pscode-config.ts | 11 ++++- test/cli/lifecycle.test.ts | 21 +++++++++ test/unit/flags.test.ts | 64 ++++++++++++++++++++++++++ 18 files changed, 258 insertions(+), 58 deletions(-) create mode 100644 .changeset/optional-pr-flow.md create mode 100644 src/core/content/flags.ts create mode 100644 test/unit/flags.test.ts diff --git a/.changeset/optional-pr-flow.md b/.changeset/optional-pr-flow.md new file mode 100644 index 0000000..a5fee11 --- /dev/null +++ b/.changeset/optional-pr-flow.md @@ -0,0 +1,14 @@ +--- +"@thiagodiogo/pscode": minor +--- + +feat(init): torna o fluxo de PR opcional + +Adiciona a pergunta "usar fluxo de PR?" no `pscode init` (antes da pergunta do +board) e as flags `--pr` / `--no-pr`. A escolha é gravada em +`pscode/config.yaml` (`pr_flow`) e seleciona qual forma dos comandos/skills de +dev é instalada: o fluxo com pull request (abre PR draft, marca Ready for Review, +não faz merge) ou o fluxo direto na branch atual (commit direto, sem PR). O +conteúdo condicional é resolvido via marcadores `{{#pr}}` / `{{^pr}}` +(`core/content/flags.ts`) no momento da renderização; `update` re-renderiza +respeitando o `pr_flow` do projeto. diff --git a/CLAUDE.md b/CLAUDE.md index a33d10f..4fd3d8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,10 @@ One adapter per agent (claude, codex, cursor, gemini). All share a uniform layou Writes/removes the rails: `installAgent`, `removeAgent`, `installChangeTemplates`, `ensureProjectStructure`, plus status helpers (`installedVersion`, `agentArtifactStatus`, `isAgentInstalled`). **Config** (`src/core/pscode-config.ts`) -`pscode/config.yaml` (Zod-validated): agents, profile, the short-document `limits`, and the two guardrails (`apply_mode: one_task_at_a_time`, `approval_required`). +`pscode/config.yaml` (Zod-validated): agents, profile, the short-document `limits`, the two guardrails (`apply_mode: one_task_at_a_time`, `approval_required`), and `pr_flow` (whether the dev step opens a pull request or commits directly to the current branch). + +**Conditional content** (`src/core/content/flags.ts`) +Command/skill bodies (and descriptions) carry `{{#pr}}…{{/pr}}` / `{{^pr}}…{{/pr}}` markers. `applyContentFlags` resolves them at render time so one source of truth installs either the PR flow or the commit-directly flow. The adapter (`renderCommand`/`renderSkill`) applies the flags; `installAgent(root, id, { prFlow })` selects which shape to write, and `update` re-renders with the project's recorded `pr_flow`. **Detection & Instruction Files** (`src/core/detect.ts`, `src/core/agents-md.ts`) `detectAgents` finds agents in use. `agents-md.ts` writes the PSCode block into the instruction file each selected agent reads (Claude Code → `CLAUDE.md`, the others → `AGENTS.md`; both when mixed). Only the text between the `` markers is rewritten; user content is preserved. @@ -103,7 +106,7 @@ test/ ``` **CLI Commands** (`src/commands/`) -- `pscode init` — install the workflow (detects/prompts agents; `--agent`, `--lang`, `--bypass-permissions` / `--no-bypass-permissions`, `--open` / `--no-open`, `--yes`). For Claude Code it can also write `permissions.defaultMode: bypassPermissions` into `.claude/settings.json` (see `core/claude-settings.ts`). When done it can open the selected agent's CLI — Claude Code preferred — handing off the terminal (`core/launch.ts`); with no TTY it prints how to start instead. +- `pscode init` — install the workflow (detects/prompts agents; `--agent`, `--lang`, `--bypass-permissions` / `--no-bypass-permissions`, `--pr` / `--no-pr`, `--open` / `--no-open`, `--yes`). A wizard question (asked before the GitHub/board question) toggles the pull-request flow; `--pr` / `--no-pr` force it. For Claude Code it can also write `permissions.defaultMode: bypassPermissions` into `.claude/settings.json` (see `core/claude-settings.ts`). When done it can open the selected agent's CLI — Claude Code preferred — handing off the terminal (`core/launch.ts`); with no TTY it prints how to start instead. - `pscode update` — refresh PSCode-controlled files in place, preserving user content. - `pscode doctor` — verify config, structure, and per-agent install/version; non-zero exit on issues. - `pscode clean` — remove the rails (`--all` also removes `pscode/`); destructive actions need `--yes`. diff --git a/src/cli/index.ts b/src/cli/index.ts index 14fec65..2867b55 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -34,6 +34,8 @@ export function buildProgram(): Command { .option('-l, --lang ', 'wizard language: en, pt') .option('--bypass-permissions', 'enable Claude Code bypassPermissions mode in .claude/settings.json') .option('--no-bypass-permissions', 'do not enable bypassPermissions mode') + .option('--pr', 'install the pull-request flow (a draft PR per change)') + .option('--no-pr', 'install the no-PR flow (commit directly to the current branch)') .option('--open', 'open the selected agent CLI when init finishes (Claude Code preferred)') .option('--no-open', 'do not open an agent when init finishes') .option('--github', 'set up GitHub Projects + Issues sync') @@ -45,6 +47,7 @@ export function buildProgram(): Command { agents: opts.agent, lang: opts.lang, bypassPermissions: opts.bypassPermissions, + pr: opts.pr, open: opts.open, github: opts.github, project: opts.project, diff --git a/src/commands/init.ts b/src/commands/init.ts index 4c718d4..c86f944 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -36,6 +36,13 @@ export interface InitOptions { * force it from a CLI flag. Only applies when Claude Code is selected. */ bypassPermissions?: boolean; + /** + * Install the pull-request flow (a draft PR per change) instead of committing + * directly to the current branch. Tri-state: `undefined` defers to the wizard + * (default yes), `true`/`false` force it from a CLI flag. Independent of the + * board; PR-on presumes a GitHub repo with a remote. + */ + pr?: boolean; /** * Open the selected agent's CLI after install (Claude Code preferred). * Tri-state: `undefined` defers to the wizard (default yes), `true`/`false` @@ -135,6 +142,20 @@ async function resolveBypassPermissions( return instantConfirm({ message: t.bypassPermissionsPrompt, default: true }); } +/** + * Resolve whether to install the pull-request flow. Independent of the board — + * you can use PRs without a Project (and a Project without PRs) — and asked just + * before the GitHub question. PR-on presumes a GitHub repo with a remote, which + * the agent resolves via `gh` at run time. + */ +async function resolvePrFlow(opts: InitOptions, interactive: boolean, t: InitMessages): Promise { + if (opts.pr !== undefined) return opts.pr; + if (!interactive) return true; + + const { instantConfirm } = await import('../core/prompts.js'); + return instantConfirm({ message: t.prFlowPrompt, default: true }); +} + /** * Resolve which agent (if any) to open after install. Prioritizes Claude Code; * returns null when the user opts out or no selected agent is launchable. @@ -177,6 +198,10 @@ export async function runInit(opts: InitOptions = {}): Promise { const preflight = collectPreflight(projectRoot, { agents }); printPreflight(preflight, t); + // Asked before the board question — the PR flow is independent of GitHub + // Projects (you can use either without the other). + const prFlow = await resolvePrFlow(opts, interactive, t); + const githubEnabled = await runGitHubSetup( projectRoot, { github: opts.github, project: opts.project }, @@ -185,9 +210,9 @@ export async function runInit(opts: InitOptions = {}): Promise { ); ensureProjectStructure(projectRoot); - writeConfig(projectRoot, buildConfig({ agents, githubEnabled })); + writeConfig(projectRoot, buildConfig({ agents, githubEnabled, prFlow })); installChangeTemplates(projectRoot); - for (const agentId of agents) installAgent(projectRoot, agentId); + for (const agentId of agents) installAgent(projectRoot, agentId, { prFlow }); const instructionFiles = syncInstructionFiles(projectRoot, agents); const settingsFile = bypassPermissions ? enableBypassPermissions(projectRoot) : undefined; diff --git a/src/commands/update.ts b/src/commands/update.ts index ac23e41..6305a13 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -35,13 +35,19 @@ export async function runUpdate(opts: UpdateOptions = {}): Promise { .filter((id) => isAgentInstalled(projectRoot, id)); const agents = Array.from(new Set([...fromConfig, ...detected])); - for (const agentId of agents) installAgent(projectRoot, agentId); + // Re-render with the project's recorded PR-flow choice so update keeps the + // same shape of the dev commands/skills the project was installed with. + const prFlow = config.pr_flow; + for (const agentId of agents) installAgent(projectRoot, agentId, { prFlow }); installChangeTemplates(projectRoot); const instructionFiles = syncInstructionFiles(projectRoot, agents); // Refresh the version (and recorded agents) in place, preserving the GitHub - // integration flag — update must not silently disable it. - writeConfig(projectRoot, buildConfig({ agents, githubEnabled: config.github.enabled })); + // integration and PR-flow flags — update must not silently change them. + writeConfig( + projectRoot, + buildConfig({ agents, githubEnabled: config.github.enabled, prFlow }) + ); console.log(chalk.green(`\n✓ PSCode updated to v${PSCODE_VERSION}\n`)); console.log(` ${chalk.bold('Agents:')} ${agents.join(', ') || '(none)'}`); diff --git a/src/core/adapters.ts b/src/core/adapters.ts index 3f16391..5520e92 100644 --- a/src/core/adapters.ts +++ b/src/core/adapters.ts @@ -10,6 +10,10 @@ import path from 'path'; import { AGENTS, getAgent, PSCODE_VERSION } from './config.js'; import type { CommandSpec, SkillSpec } from './content/types.js'; +import { applyContentFlags, type ContentFlags } from './content/flags.js'; + +/** Default rendering flags: the full pull-request flow. */ +const DEFAULT_FLAGS: ContentFlags = { pr: true }; /** Quote a YAML scalar if it contains characters that need escaping. */ function yamlScalar(value: string): string { @@ -25,8 +29,8 @@ export interface AgentAdapter { dir: string; commandPath(id: string): string; skillPath(name: string): string; - renderCommand(spec: CommandSpec): string; - renderSkill(spec: SkillSpec): string; + renderCommand(spec: CommandSpec, flags?: ContentFlags): string; + renderSkill(spec: SkillSpec, flags?: ContentFlags): string; } function createAdapter(id: string): AgentAdapter { @@ -40,18 +44,18 @@ function createAdapter(id: string): AgentAdapter { dir, commandPath: (cmdId) => path.join(dir, 'commands', 'ps', `${cmdId}.md`), skillPath: (skillName) => path.join(dir, 'skills', skillName, 'SKILL.md'), - renderCommand: (spec) => + renderCommand: (spec, flags = DEFAULT_FLAGS) => `---\n` + `name: ${yamlScalar(spec.name)}\n` + - `description: ${yamlScalar(spec.description)}\n` + + `description: ${yamlScalar(applyContentFlags(spec.description, flags))}\n` + `generatedBy: ${yamlScalar(PSCODE_VERSION)}\n` + - `---\n\n${spec.body}`, - renderSkill: (spec) => + `---\n\n${applyContentFlags(spec.body, flags)}`, + renderSkill: (spec, flags = DEFAULT_FLAGS) => `---\n` + `name: ${yamlScalar(spec.name)}\n` + - `description: ${yamlScalar(spec.description)}\n` + + `description: ${yamlScalar(applyContentFlags(spec.description, flags))}\n` + `generatedBy: ${yamlScalar(PSCODE_VERSION)}\n` + - `---\n\n${spec.body}`, + `---\n\n${applyContentFlags(spec.body, flags)}`, }; } diff --git a/src/core/content/commands/complete.ts b/src/core/content/commands/complete.ts index 18b96f9..b302a9b 100644 --- a/src/core/content/commands/complete.ts +++ b/src/core/content/commands/complete.ts @@ -23,6 +23,6 @@ Use the **pscode-complete** skill. Use the **pscode-github-sync** skill: **move the card → Done** (\`done\`) and confirm the move landed, comment the conclusion, then **close** the Issue. -Merging the PR stays human/CI. Non-blocking only on \`gh\` failure. +{{#pr}}Merging the PR stays human/CI. {{/pr}}Non-blocking only on \`gh\` failure. `, }; diff --git a/src/core/content/commands/dev.ts b/src/core/content/commands/dev.ts index cc020fb..dc9a371 100644 --- a/src/core/content/commands/dev.ts +++ b/src/core/content/commands/dev.ts @@ -4,7 +4,7 @@ export const dev: CommandSpec = { id: 'dev', name: 'ps:dev', description: - 'Develops a Ready-to-Dev card: opens a draft PR, moves to In Development, implements one subtask at a time, then walks the card through Code Review → Test → Ready to Deploy.', + 'Develops a Ready-to-Dev card: {{#pr}}opens a draft PR, {{/pr}}moves to In Development, implements one subtask at a time{{^pr}} on the current branch{{/pr}}, then walks the card through Code Review → Test → Ready to Deploy.', body: `# /ps:dev Take a **refined** change (Ready to Dev) and build it. Accepts the board @@ -16,9 +16,11 @@ scope mid-subtask. ## Start (if \`pscode/github.yaml\` exists) Use the **pscode-github-sync** skill, in order: -1. Open the **PR as a draft** and link it to the Issue (\`Closes #\`). +{{#pr}}1. Open the **PR as a draft** and link it to the Issue (\`Closes #\`). 2. **Move the card → In Development** (\`in_progress\`) and **assign the user** — - the assign does not replace the status move; confirm the move landed. + the assign does not replace the status move; confirm the move landed.{{/pr}}{{^pr}}1. **Move the card → In Development** (\`in_progress\`) — confirm the move landed. +2. **Assign the user.** The assign does not replace the status move; both must + run. Work directly on the current branch — no PR is opened.{{/pr}} ## Implement @@ -31,15 +33,17 @@ Use the **pscode-github-sync** skill, in order: short diff, run the relevant validation, and ask before ticking it \`[x]\`. After ticking, **close its sub-issue** on the card. Repeat for each subtask. 5. When every subtask is done **and the project builds and its tests pass** (use - the project's own build/test commands), mark the PR **Ready for Review** and - move the card → **In Code Review** (\`review\`). + the project's own build/test commands), {{#pr}}mark the PR **Ready for Review** + and move the card → **In Code Review** (\`review\`).{{/pr}}{{^pr}}move the card → + **In Code Review** (\`review\`).{{/pr}} 6. With the user's approval, move the card → **In Test** (\`in_test\`). 7. Once the user confirms it is **working**, move the card → **Ready to Deploy** (\`ready_to_deploy\`) and post the **next-step comment** (\`/ps:complete \` in a fenced block). Each of steps 5–7 **moves the card** on the board — confirm every move landed, -don't leave the card behind. Merging the PR stays a human/CI decision — never -merge here. \`gh\` calls are non-blocking only on failure, never optional. +don't leave the card behind. {{#pr}}Merging the PR stays a human/CI decision — +never merge here. {{/pr}}\`gh\` calls are non-blocking only on failure, never +optional. `, }; diff --git a/src/core/content/flags.ts b/src/core/content/flags.ts new file mode 100644 index 0000000..f8a8e83 --- /dev/null +++ b/src/core/content/flags.ts @@ -0,0 +1,21 @@ +/** + * Conditional-content markers for command/skill bodies. + * + * `{{#pr}}…{{/pr}}` is kept only when the PR flow is enabled; `{{^pr}}…{{/pr}}` + * is kept only when it's disabled. This lets one source of truth install two + * shapes of the dev flow — pull-request-based vs. commit-directly — without + * duplicating whole files. Removing a block can leave blank-line runs (or a + * dangling space) behind, so we tidy those up to keep the Markdown clean. + */ +export interface ContentFlags { + /** Install the pull-request flow (draft PR per change). */ + pr: boolean; +} + +export function applyContentFlags(text: string, flags: ContentFlags): string { + return text + .replace(/\{\{#pr\}\}([\s\S]*?)\{\{\/pr\}\}/g, flags.pr ? '$1' : '') + .replace(/\{\{\^pr\}\}([\s\S]*?)\{\{\/pr\}\}/g, flags.pr ? '' : '$1') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n'); +} diff --git a/src/core/content/skills/complete.ts b/src/core/content/skills/complete.ts index 6c36268..e391cc9 100644 --- a/src/core/content/skills/complete.ts +++ b/src/core/content/skills/complete.ts @@ -31,7 +31,7 @@ Close out a change and keep the history tidy. Two paths share this skill: \`pscode/changes/archive/-/\` (use today's date). 4. If \`pscode/github.yaml\` exists, use \`pscode-github-sync\`: **move the card → Done** (\`done\`) and confirm it landed, comment the conclusion, then **close** - the Issue. Merging the PR stays human/CI. + the Issue.{{#pr}} Merging the PR stays human/CI.{{/pr}} ## Cancel path (\`/ps:cancel\`) diff --git a/src/core/content/skills/dev.ts b/src/core/content/skills/dev.ts index 1b0fd4d..c25b213 100644 --- a/src/core/content/skills/dev.ts +++ b/src/core/content/skills/dev.ts @@ -3,22 +3,28 @@ import type { SkillSpec } from '../types.js'; export const dev: SkillSpec = { name: 'pscode-dev', description: - 'Develops a refined change: opens a draft PR linked to the Issue, moves the card to In Development, implements the refine.md subtasks one at a time, then walks the card through Code Review → Test → Ready to Deploy. Use it from /ps:dev. Never merges the PR.', + 'Develops a refined change: {{#pr}}opens a draft PR linked to the Issue, {{/pr}}moves the card to In Development, implements the refine.md subtasks one at a time{{^pr}} on the current branch{{/pr}}, then walks the card through Code Review → Test → Ready to Deploy. Use it from /ps:dev.{{#pr}} Never merges the PR.{{/pr}}', body: `# Dev Build a **refined** change (Ready to Dev) by walking it across the board, one -subtask at a time. The PR is the unit of delivery; **merging is never your call** -— that stays human/CI. +subtask at a time. {{#pr}}The PR is the unit of delivery; **merging is never your +call** — that stays human/CI.{{/pr}}{{^pr}}You commit directly to the current +branch — there is no PR.{{/pr}} ## How to act -### 1. Open the PR and claim the card (if \`pscode/github.yaml\` exists) +{{#pr}}### 1. Open the PR and claim the card (if \`pscode/github.yaml\` exists) Use \`pscode-github-sync\`: - Create a branch and open the **PR as a draft**, linked to the Issue with \`Closes #\` in the body. +- **Move the card → In Development** (\`in_progress\`) *and* **assign the user** — + the assign does not replace the status move; run both and confirm it landed.{{/pr}}{{^pr}}### 1. Claim the card (if \`pscode/github.yaml\` exists) + +Use \`pscode-github-sync\`: - **Move the card → In Development** (\`in_progress\`) *and* **assign the user** — the assign does not replace the status move; run both and confirm it landed. + Work directly on the current branch — no PR is opened.{{/pr}} ### 2. Gather context before coding @@ -46,8 +52,9 @@ Use \`pscode-task-runner\` against \`refine.md\`'s \`## Subtasks\`: When all subtasks are done **and the project builds and its tests pass** — run the project's own build/test commands (e.g. the scripts in its package manifest or Makefile); don't assume a specific tool: -- Mark the PR **Ready for Review** and move the card → **In Code Review** - (\`review\`) via \`pscode-github-sync\`. +- {{#pr}}Mark the PR **Ready for Review** and move the card → **In Code Review** + (\`review\`) via \`pscode-github-sync\`.{{/pr}}{{^pr}}Move the card → **In Code + Review** (\`review\`) via \`pscode-github-sync\`.{{/pr}} ### 5. Test, then Ready to Deploy @@ -60,7 +67,7 @@ or Makefile); don't assume a specific tool: One subtask at a time, always with human validation (\`apply_mode\` + \`approval_required\` in \`pscode/config.yaml\`). Each gate above **moves the card** — -confirm every move landed, never leave it behind. Don't merge the PR; \`gh\` calls +confirm every move landed, never leave it behind. {{#pr}}Don't merge the PR; {{/pr}}\`gh\` calls are non-blocking only on failure, never optional. `, }; diff --git a/src/core/content/skills/github-sync.ts b/src/core/content/skills/github-sync.ts index 3e97c33..bc3f120 100644 --- a/src/core/content/skills/github-sync.ts +++ b/src/core/content/skills/github-sync.ts @@ -3,10 +3,10 @@ import type { SkillSpec } from '../types.js'; export const githubSync: SkillSpec = { name: 'pscode-github-sync', description: - 'Keeps the GitHub Issue, board status, assignee, PR and comments in sync with the guided flow, using gh. Use it from every /ps:* step when pscode/github.yaml exists. Every gh call is non-blocking.', + 'Keeps the GitHub Issue, board status, assignee,{{#pr}} PR and{{/pr}} comments in sync with the guided flow, using gh. Use it from every /ps:* step when pscode/github.yaml exists. Every gh call is non-blocking.', body: `# GitHub Sync -Keep the change's **GitHub Issue + Project board + PR** in sync with the flow. +Keep the change's **GitHub Issue + Project board{{#pr}} + PR{{/pr}}** in sync with the flow. Run this from the steps that change state. **Conditional, not optional**: it acts whenever \`pscode/github.yaml\` exists, and then you run *every* action the step prescribes — assign, **move the card**, comment. "Non-blocking" means **tolerate @@ -35,12 +35,12 @@ one, use it instead of resolving. | Step | Board column | Stage key | Also | |------------------|-----------------|-------------------|------| -| \`/ps:draft\` | Backlog | \`backlog\` | create Issue, add to Project, save \`.issue\` | +| \`/ps:draft\` | Backlog | \`backlog\` | create Issue (body = the short draft), add to Project | | \`/ps:refine\` (in)| In Refinement | \`proposed\` | **assign user** | | \`/ps:refine\` (out)| Ready to Dev | \`ready_to_dev\` | **create a sub-issue per subtask**, update Issue body from \`refine.md\` | -| \`/ps:dev\` (start)| In Development | \`in_progress\` | open **draft PR** (\`Closes #\`), **assign user** | +| \`/ps:dev\` (start)| In Development | \`in_progress\` | {{#pr}}open **draft PR** (\`Closes #\`), {{/pr}}**assign user** | | \`/ps:dev\` (subtask)| — | — | on each subtask \`[x]\`, **close its sub-issue** | -| \`/ps:dev\` (review)| In Code Review | \`review\` | mark PR **Ready for Review** | +| \`/ps:dev\` (review)| In Code Review | \`review\` | {{#pr}}mark PR **Ready for Review**{{/pr}}{{^pr}}—{{/pr}} | | \`/ps:dev\` (test) | In Test | \`in_test\` | — | | \`/ps:dev\` (deploy)| Ready to Deploy| \`ready_to_deploy\` | — | | \`/ps:complete\` | Done | \`done\` | comment, then **close** the Issue | @@ -77,13 +77,15 @@ failure" rule — always attempt it after confirming the move landed. ## Commands -**Create the Issue (\`/ps:draft\`):** +**Create the Issue (\`/ps:draft\`):** the short draft description is the Issue +body — there is **no \`brief.md\`** at draft time, so pass it inline (a heredoc or +\`--body-file -\` keeps the line breaks): \`\`\`bash -gh issue create --repo --title "" \\ - --body-file pscode/changes//brief.md +gh issue create --repo --title "" --body "" \`\`\` -Capture the printed URL, extract its number, write it to -\`pscode/changes//.issue\`, add it to the board and set \`backlog\`. +Capture the printed URL, extract its number, add it to the board and set +\`backlog\`. The local change folder and \`.issue\` are **not** written here — that +happens in \`/ps:refine\`. **Assign the current user (\`/ps:refine\`, \`/ps:dev\`):** \`\`\`bash @@ -124,7 +126,7 @@ the card's progress bar honest by closing the matching sub-issue. gh issue close --repo \`\`\` -**Open the PR as a draft, linked to the Issue (\`/ps:dev\`):** +{{#pr}}**Open the PR as a draft, linked to the Issue (\`/ps:dev\`):** \`\`\`bash gh pr create --repo --draft --fill --body "Closes #" \`\`\` @@ -134,7 +136,7 @@ gh pr create --repo --draft --fill --body "Closes #" gh pr ready --repo \`\`\` -**Add to the Project (returns the item id):** +{{/pr}}**Add to the Project (returns the item id):** \`\`\`bash gh project item-add --owner --url --format json \`\`\` @@ -177,6 +179,6 @@ step prescribes — the **status move is the whole point**, never skip it just because it costs two commands or the \`assign\` already ran. If \`gh\` is missing, unauthenticated, or the repo has no remote, say how to fix it (\`gh auth login\`, etc.) and **continue the flow** — the guided steps never depend on the sync -succeeding. **Never merge the PR** — that stays a human/CI decision. +succeeding.{{#pr}} **Never merge the PR** — that stays a human/CI decision.{{/pr}} `, }; diff --git a/src/core/content/skills/guided-sdd.ts b/src/core/content/skills/guided-sdd.ts index f1c0ead..c4c8d9b 100644 --- a/src/core/content/skills/guided-sdd.ts +++ b/src/core/content/skills/guided-sdd.ts @@ -13,15 +13,18 @@ without approval, and each step moves the card to the matching column (via ## Flow (command → board column) -1. **\`/ps:draft\`** → **Backlog**. Capture the request as a short \`brief.md\`. No - grilling, no code. +1. **\`/ps:draft\`** → **Backlog**. Register the request as a Backlog card (the + Issue body is a short description). No \`brief.md\`, no grilling, no code. 2. **\`/ps:refine \`** → **In Refinement** → **Ready to Dev**. Claim the - card, analyze the code, run \`pscode-grill-me\`, and write \`refine.md\` (summary, - technical detail, scope, \`## Subtasks\` — mirrored as native **sub-issues** on - the card). Uses \`pscode-refine\`. + card, create the local change folder, write \`brief.md\` from the card's + description, analyze the code, run \`pscode-grill-me\`, and write \`refine.md\` + (summary, technical detail, scope, \`## Subtasks\` — mirrored as native + **sub-issues** on the card). Uses \`pscode-refine\`. 3. **\`/ps:dev \`** → **In Development** → **In Code Review** → **In Test** - → **Ready to Deploy**. Open a draft PR, implement one subtask at a time, and - walk the card across the columns. Uses \`pscode-dev\` + \`pscode-task-runner\`. + → **Ready to Deploy**. {{#pr}}Open a draft PR, implement one subtask at a time, + and walk the card across the columns.{{/pr}}{{^pr}}Implement one subtask at a + time on the current branch and walk the card across the columns.{{/pr}} Uses + \`pscode-dev\` + \`pscode-task-runner\`. 4. **\`/ps:complete \`** → **Done**. Write a short delta spec and archive the change. Uses \`pscode-complete\`. (\`/ps:cancel\` → **Cancelled**.) @@ -40,11 +43,11 @@ without approval, and each step moves the card to the matching column (via \`\`\` pscode/changes// -├── brief.md # objective, expected behavior, out of scope (/ps:draft) +├── brief.md # objective, expected behavior, out of scope (/ps:refine; /ps:draft only without GitHub) ├── questions.md # Grill Me questions (/ps:refine) ├── refine.md # summary, technical detail, scope, subtasks (/ps:refine) ├── delta-spec.md # what the spec/behavior added, changed, removed (/ps:complete) -└── .issue # GitHub issue number (when synced) +└── .issue # GitHub issue number (written by /ps:refine when synced) \`\`\` \`/ps:complete\` archives the folder to \`pscode/changes/archive/-/\`. diff --git a/src/core/i18n.ts b/src/core/i18n.ts index 2907749..989574d 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -38,6 +38,8 @@ export interface InitMessages { atLeastOneAgent: string; /** Confirm prompt for Claude Code's bypassPermissions mode. */ bypassPermissionsPrompt: string; + /** Confirm prompt: use a pull-request flow (draft PR per change)? */ + prFlowPrompt: string; /** Confirm prompt to open the agent after install (agent name interpolated). */ openAgentPrompt: (agent: string) => string; /** Hint shown (instead of launching) when there is no terminal to hand off. */ @@ -102,6 +104,7 @@ const MESSAGES: Record = { atLeastOneAgent: 'Select at least one agent.', bypassPermissionsPrompt: 'Enable Claude Code bypassPermissions mode (skips approval prompts) in .claude/settings.json?', + prFlowPrompt: 'Use a pull-request flow (open a draft PR for each change)?', openAgentPrompt: (agent) => `Open ${agent} now?`, openHint: (command) => `Run \`${command}\` to start your agent.`, initialized: 'PSCode initialized', @@ -140,6 +143,7 @@ const MESSAGES: Record = { atLeastOneAgent: 'Selecione pelo menos um agente.', bypassPermissionsPrompt: 'Ativar o modo bypassPermissions do Claude Code (pula os prompts de aprovação) em .claude/settings.json?', + prFlowPrompt: 'Usar um fluxo de pull request (abrir um PR draft para cada mudança)?', openAgentPrompt: (agent) => `Abrir ${agent} agora?`, openHint: (command) => `Rode \`${command}\` para iniciar seu agente.`, initialized: 'PSCode inicializado', diff --git a/src/core/installer.ts b/src/core/installer.ts index 77fd177..29a7f0f 100644 --- a/src/core/installer.ts +++ b/src/core/installer.ts @@ -42,9 +42,19 @@ function cleanAgentArtifacts(projectRoot: string, agentId: string): void { } } -/** Write all command + skill files for one agent. Returns relative paths written. */ -export function installAgent(projectRoot: string, agentId: string): string[] { +/** + * Write all command + skill files for one agent. Returns relative paths written. + * + * `prFlow` (default on) selects which shape of the dev flow gets rendered — the + * pull-request flow or the commit-directly one (see `content/flags.ts`). + */ +export function installAgent( + projectRoot: string, + agentId: string, + opts: { prFlow?: boolean } = {} +): string[] { const adapter = getAdapter(agentId); + const flags = { pr: opts.prFlow ?? true }; const written: string[] = []; // Start clean so renamed/removed artifacts don't linger across updates. @@ -52,12 +62,12 @@ export function installAgent(projectRoot: string, agentId: string): string[] { for (const cmd of COMMANDS) { const rel = adapter.commandPath(cmd.id); - writeFile(path.join(projectRoot, rel), adapter.renderCommand(cmd)); + writeFile(path.join(projectRoot, rel), adapter.renderCommand(cmd, flags)); written.push(rel); } for (const skill of SKILLS) { const rel = adapter.skillPath(skill.name); - writeFile(path.join(projectRoot, rel), adapter.renderSkill(skill)); + writeFile(path.join(projectRoot, rel), adapter.renderSkill(skill, flags)); written.push(rel); } return written; diff --git a/src/core/pscode-config.ts b/src/core/pscode-config.ts index 15c91ff..96e9e38 100644 --- a/src/core/pscode-config.ts +++ b/src/core/pscode-config.ts @@ -24,6 +24,10 @@ const ConfigSchema = z.object({ .default({ ...DEFAULT_LIMITS }), apply_mode: z.string().default('one_task_at_a_time'), approval_required: z.boolean().default(true), + // Whether the dev step uses a pull-request flow (draft PR per change) or + // commits directly to the current branch. Drives which shape of the dev + // commands/skills gets installed. Defaults on to preserve prior behavior. + pr_flow: z.boolean().default(true), github: z .object({ enabled: z.boolean().default(false), @@ -41,7 +45,11 @@ export function configExists(projectRoot: string): boolean { return exists(configPath(projectRoot)); } -export function buildConfig(opts: { agents: string[]; githubEnabled?: boolean }): PscodeConfig { +export function buildConfig(opts: { + agents: string[]; + githubEnabled?: boolean; + prFlow?: boolean; +}): PscodeConfig { return ConfigSchema.parse({ version: PSCODE_VERSION, profile: DEFAULT_PROFILE, @@ -49,6 +57,7 @@ export function buildConfig(opts: { agents: string[]; githubEnabled?: boolean }) limits: { ...DEFAULT_LIMITS }, apply_mode: 'one_task_at_a_time', approval_required: true, + pr_flow: opts.prFlow ?? true, github: { enabled: opts.githubEnabled ?? false }, }); } diff --git a/test/cli/lifecycle.test.ts b/test/cli/lifecycle.test.ts index 92337c2..3a03e3d 100644 --- a/test/cli/lifecycle.test.ts +++ b/test/cli/lifecycle.test.ts @@ -40,6 +40,27 @@ describe('pscode CLI lifecycle', () => { expect(existsSync(path.join(dir, 'pscode/github.yaml'))).toBe(false); }); + it('init installs the PR flow by default', async () => { + dir = makeTmpProject(); + const res = await runCLI(['init', '--agent', 'claude', '--yes'], { cwd: dir }); + expect(res.exitCode).toBe(0); + expect(readFileSync(path.join(dir, 'pscode/config.yaml'), 'utf-8')).toContain('pr_flow: true'); + const devCmd = readFileSync(path.join(dir, '.claude/commands/ps/dev.md'), 'utf-8'); + expect(devCmd).toContain('PR as a draft'); + expect(devCmd).not.toMatch(/\{\{[#^/]?pr\}\}/); + }); + + it('init --no-pr installs the commit-directly flow and strips PR steps', async () => { + dir = makeTmpProject(); + const res = await runCLI(['init', '--agent', 'claude', '--no-pr', '--yes'], { cwd: dir }); + expect(res.exitCode).toBe(0); + expect(readFileSync(path.join(dir, 'pscode/config.yaml'), 'utf-8')).toContain('pr_flow: false'); + const devCmd = readFileSync(path.join(dir, '.claude/commands/ps/dev.md'), 'utf-8'); + expect(devCmd).not.toContain('PR as a draft'); + expect(devCmd).toContain('no PR is opened'); + expect(devCmd).not.toMatch(/\{\{[#^/]?pr\}\}/); + }); + it('init writes bypassPermissions by default in non-interactive mode', async () => { dir = makeTmpProject(); const res = await runCLI(['init', '--agent', 'claude', '--yes'], { cwd: dir }); diff --git a/test/unit/flags.test.ts b/test/unit/flags.test.ts new file mode 100644 index 0000000..6f115d9 --- /dev/null +++ b/test/unit/flags.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { applyContentFlags } from '../../src/core/content/flags'; +import { getAdapter } from '../../src/core/adapters'; +import { COMMANDS } from '../../src/core/content/commands'; +import { SKILLS } from '../../src/core/content/skills'; + +const dev = COMMANDS.find((c) => c.id === 'dev')!; +const devSkill = SKILLS.find((s) => s.name === 'pscode-dev')!; +const githubSync = SKILLS.find((s) => s.name === 'pscode-github-sync')!; + +describe('conditional content flags', () => { + it('keeps {{#pr}} blocks and drops {{^pr}} blocks when the PR flow is on', () => { + const out = applyContentFlags('a {{#pr}}PR{{/pr}}{{^pr}}no-PR{{/pr}} b', { pr: true }); + expect(out).toBe('a PR b'); + }); + + it('drops {{#pr}} blocks and keeps {{^pr}} blocks when the PR flow is off', () => { + const out = applyContentFlags('a {{#pr}}PR{{/pr}}{{^pr}}no-PR{{/pr}} b', { pr: false }); + expect(out).toBe('a no-PR b'); + }); + + it('collapses the blank-line run a removed block leaves behind', () => { + const out = applyContentFlags('one\n\n{{#pr}}two\n\n{{/pr}}three', { pr: false }); + expect(out).not.toMatch(/\n\n\n/); + expect(out).toBe('one\n\nthree'); + }); + + it('never leaks a marker into rendered output, in either mode', () => { + const adapter = getAdapter('claude'); + for (const pr of [true, false]) { + for (const cmd of COMMANDS) { + expect(adapter.renderCommand(cmd, { pr })).not.toMatch(/\{\{[#^/]?pr\}\}/); + } + for (const skill of SKILLS) { + expect(adapter.renderSkill(skill, { pr })).not.toMatch(/\{\{[#^/]?pr\}\}/); + } + } + }); + + it('renders the PR steps only when the PR flow is on', () => { + const adapter = getAdapter('claude'); + const onDev = adapter.renderCommand(dev, { pr: true }); + const offDev = adapter.renderCommand(dev, { pr: false }); + expect(onDev).toContain('PR as a draft'); + expect(offDev).not.toContain('PR as a draft'); + expect(offDev).toContain('no PR is opened'); + + const onSkill = adapter.renderSkill(devSkill, { pr: true }); + const offSkill = adapter.renderSkill(devSkill, { pr: false }); + expect(onSkill).toContain('Ready for Review'); + expect(offSkill).not.toContain('Ready for Review'); + + const onSync = adapter.renderSkill(githubSync, { pr: true }); + const offSync = adapter.renderSkill(githubSync, { pr: false }); + expect(onSync).toContain('gh pr create'); + expect(offSync).not.toContain('gh pr create'); + expect(offSync).not.toContain('Never merge the PR'); + }); + + it('defaults to the PR flow when no flags are passed', () => { + const adapter = getAdapter('claude'); + expect(adapter.renderCommand(dev)).toContain('PR as a draft'); + }); +});