Feat(ship): add configurable PR body sections (#3391)

* feat: add configurable ship PR body sections

* chore: add changeset for ship PR sections

* docs: avoid prompt scanner trigger in PR body guide

* docs: escape pr body source separator
This commit is contained in:
Tom Boucher
2026-05-11 15:27:40 -04:00
committed by GitHub
parent fd20373cf4
commit e79c472d7b
17 changed files with 732 additions and 7 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3391
---
**`/gsd-ship` can append configured PRD-style PR body sections** — projects can use `ship.pr_body_sections` to add user stories, acceptance criteria, risks, release criteria, and stakeholder review notes without patching the shipped workflow.

View File

@@ -291,6 +291,9 @@ Create PR from completed phase work with auto-generated body.
- Requirements addressed (REQ-IDs)
- Verification status
- Key decisions
- Optional configured PRD-style sections from `ship.pr_body_sections`
See [Custom PR Body Sections](ship-pr-body-sections.md) for onboarding, examples, and validation rules.
---

View File

@@ -59,6 +59,9 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new
"build_command": null,
"test_command": null
},
"ship": {
"pr_body_sections": []
},
"hooks": {
"context_warnings": true,
"workflow_guard": false
@@ -225,6 +228,56 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
| `workflow.build_command` | string | (none) | Shell command to build the project in the post-merge build gate (Step A of step 5.6 in execute-phase). When unset, the gate auto-detects: Xcode (`.xcodeproj` present) → `xcodebuild build`, `Makefile` with `build:` target → `make build`, Justfile → `just build`, `Cargo.toml` → `cargo build`, `go.mod` → `go build ./...`, Python → `python -m py_compile`, `package.json` with `build` script → `npm run build`. Runs with a 5-minute timeout; failure increments `WAVE_FAILURE_COUNT`. Added in v1.39 |
| `workflow.test_command` | string | (none) | Shell command to run the project's test suite in the post-merge test gate (Step B of step 5.6 in execute-phase) and the regression gate. When unset, the gate auto-detects: Xcode (`.xcodeproj` present) → `xcodebuild test`, `Makefile` with `test:` target → `make test`, Justfile → `just test`, `package.json` → `npm test`, `Cargo.toml` → `cargo test`, `go.mod` → `go test ./...`, Python → `python -m pytest`. Runs with a 5-minute timeout; failure increments `WAVE_FAILURE_COUNT`. Added in v1.39 |
## Ship Settings
`ship.pr_body_sections` adds additional PR body sections for project-specific PRD/PR body content in `/gsd-ship` without editing `get-shit-done/workflows/ship.md`.
For a user guide with onboarding examples and troubleshooting, see [Custom PR Body Sections](ship-pr-body-sections.md).
This list is append-only: configured entries are added after the core `Summary`, `Changes`, `Requirements Addressed`, `Verification`, and `Key Decisions` sections. They cannot replace, remove, or reorder required sections.
Recommended lean/agile PRD uses include user stories, acceptance criteria, Definition of Done or release criteria, risks and dependencies, success metrics, and stakeholder review notes. Keep these sections short and evidence-oriented so the PR body remains a living release artifact rather than a static requirements dump.
Each entry supports:
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `heading` | string | required | Markdown section heading rendered as `## {heading}`. Must be a single line. |
| `enabled` | boolean | `true` | When `false`, onboarding can keep a candidate section in config without rendering it in generated PR bodies. |
| `source` | string | (none) | Optional fallback chain of planning artifact headings, such as `PLAN.md ## Risks \|\| VERIFICATION.md ## Manual Checks`. Allowed artifacts are `ROADMAP.md`, `PLAN.md`, `SUMMARY.md`, `VERIFICATION.md`, `STATE.md`, `REQUIREMENTS.md`, and `CONTEXT.md`. |
| `template` | string | (none) | Literal Markdown with closed tokens: `{phase_number}`, `{phase_name}`, `{phase_dir}`, `{base_branch}`, `{padded_phase}`. |
| `fallback` | string | (none) | Literal Markdown used when `source` yields no content and no `template` is provided. |
At least one of `source`, `template`, or `fallback` is required for each section. The default is `[]`, so existing projects keep their current `/gsd-ship` output until onboarding adds enabled entries.
Example:
```json
{
"ship": {
"pr_body_sections": [
{
"heading": "User Stories & Acceptance Criteria",
"enabled": true,
"source": "REQUIREMENTS.md ## User Stories || REQUIREMENTS.md ## Acceptance Criteria",
"fallback": "- Acceptance criteria are covered by the linked requirements and verification evidence."
},
{
"heading": "Risks & Rollback",
"enabled": true,
"source": "PLAN.md ## Risks || PLAN.md ## Rollback",
"fallback": "- Rollback: revert this PR."
},
{
"heading": "Stakeholder Sign-off",
"enabled": false,
"template": "- Product owner: pending for {phase_name}"
}
]
}
}
```
### Recommended Presets
| Scenario | mode | granularity | profile | research | plan_check | verifier |

View File

@@ -412,10 +412,13 @@
- REQ-SHIP-03: System MUST auto-generate PR body from SUMMARY.md, VERIFICATION.md, and REQUIREMENTS.md
- REQ-SHIP-04: System MUST update STATE.md with shipping status and PR number
- REQ-SHIP-05: System MUST support `--draft` flag for draft PRs
- REQ-SHIP-06: System MUST support append-only project PR body sections configured with `ship.pr_body_sections`
**Prerequisites:** Phase verified, `gh` CLI installed and authenticated, work on feature branch
**Produces:** GitHub PR with rich body, STATE.md updated
**Produces:** GitHub PR with rich body, optional configured PRD-style sections, STATE.md updated
**User documentation:** [Custom PR Body Sections](ship-pr-body-sections.md)
---

View File

@@ -13,6 +13,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
| [Feature Reference](FEATURES.md) | All users | Feature narratives and requirements for released features (see [CHANGELOG](../CHANGELOG.md) for latest additions) |
| [Command Reference](COMMANDS.md) | All users | Stable commands with syntax, flags, options, and examples |
| [Configuration Reference](CONFIGURATION.md) | All users | Full config schema, workflow toggles, model profiles, git branching |
| [Custom PR Body Sections](ship-pr-body-sections.md) | All users | How to append project-specific PRD sections to `/gsd-ship` PR bodies |
| [CLI Tools Reference](CLI-TOOLS.md) | Contributors, agent authors | `gsd-tools.cjs` programmatic API for workflows and agents |
| [Agent Reference](AGENTS.md) | Contributors, advanced users | Role cards for primary agents — roles, tools, spawn patterns (the `agents/` filesystem is authoritative) |
| [User Guide](USER-GUIDE.md) | All users | Workflow walkthroughs, troubleshooting, and recovery |
@@ -29,5 +30,6 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
- **Full workflow walkthrough:** [User Guide](USER-GUIDE.md)
- **All commands at a glance:** [Command Reference](COMMANDS.md)
- **Configuring GSD:** [Configuration Reference](CONFIGURATION.md)
- **Customizing ship PR bodies:** [Custom PR Body Sections](ship-pr-body-sections.md)
- **How the system works internally:** [Architecture](ARCHITECTURE.md)
- **Contributing or extending:** [CLI Tools Reference](CLI-TOOLS.md) + [Agent Reference](AGENTS.md)

View File

@@ -249,6 +249,10 @@ Once a phase is verified, ship it:
/gsd-ship 1 # Creates a PR with auto-generated body
```
The PR body always includes the required GSD sections: `Summary`, `Changes`, `Requirements Addressed`, `Verification`, and `Key Decisions`. During `/gsd-new-project`, you can also enable optional PRD-style sections such as user stories, acceptance criteria, risks, release criteria, and stakeholder approval. These are appended through `ship.pr_body_sections` and do not change the required core sections.
For setup examples, field definitions, and troubleshooting, see [Custom PR Body Sections](ship-pr-body-sections.md).
For multi-phase projects, repeat the loop:
```

View File

@@ -0,0 +1,170 @@
# Custom PR Body Sections
`/gsd-ship` creates a pull request body from the planning artifacts for a verified phase. Projects can append extra PRD-style sections to that body with `ship.pr_body_sections` in `.planning/config.json`.
Use this when your project needs the PR to carry more release context than the default GSD sections, such as user stories, acceptance criteria, risks, release criteria, or stakeholder approval notes.
## What GSD Always Includes
Every generated `/gsd-ship` PR body keeps the required core sections:
- `Summary`
- `Changes`
- `Requirements Addressed`
- `Verification`
- `Key Decisions`
Custom sections are append-only. They render after `Key Decisions`; they cannot replace, remove, or reorder the core sections.
## Configure Sections During Onboarding
During `/gsd-new-project`, GSD can seed optional PRD-style sections into `.planning/config.json`.
Recommended onboarding choices:
- `User Stories & Acceptance Criteria` for user-facing stories and acceptance checks.
- `Risks & Dependencies` for rollout risks, dependencies, and rollback notes.
- `Success Metrics & Release Criteria` for Definition of Done, measurable outcomes, and release checks.
- `Stakeholder Review & Approval` for sign-off traceability.
Selected sections are written with `"enabled": true`. Seeded but unselected sections are written with `"enabled": false`, so you can enable them later without editing the shipped `/gsd-ship` workflow.
## Configure Sections Manually
Set `ship.pr_body_sections` with `gsd-sdk query config-set`:
```bash
gsd-sdk query config-set ship.pr_body_sections '[{"heading":"Risks & Dependencies","enabled":true,"source":"PLAN.md ## Risks || PLAN.md ## Dependencies","fallback":"- No known high-risk rollout dependencies."}]'
```
You can also edit `.planning/config.json` directly:
```json
{
"ship": {
"pr_body_sections": [
{
"heading": "User Stories & Acceptance Criteria",
"enabled": true,
"source": "REQUIREMENTS.md ## User Stories || REQUIREMENTS.md ## Acceptance Criteria",
"fallback": "- Acceptance criteria are covered by the linked requirements and verification evidence."
},
{
"heading": "Risks & Dependencies",
"enabled": true,
"source": "PLAN.md ## Risks || PLAN.md ## Dependencies",
"fallback": "- No known high-risk rollout dependencies."
},
{
"heading": "Stakeholder Review & Approval",
"enabled": false,
"template": "- Product owner approval pending for {phase_name}."
}
]
}
}
```
## Section Fields
Each section is an object with these fields:
| Field | Required | Description |
|-------|----------|-------------|
| `heading` | Yes | Markdown heading text rendered as `## {heading}`. Must be one line. |
| `enabled` | No | Defaults to `true`. Set `false` to keep a section in config without rendering it. |
| `source` | No | Fallback chain of planning artifact headings to copy into the PR body. |
| `template` | No | Literal Markdown with a small set of supported tokens. |
| `fallback` | No | Literal Markdown used when `source` finds no content and no `template` is present. |
Each section must include at least one of `source`, `template`, or `fallback`.
## Source Selectors
`source` points at headings in planning artifacts. Use `||` to provide fallbacks:
```text
REQUIREMENTS.md ## User Stories || REQUIREMENTS.md ## Acceptance Criteria
```
Allowed source files:
- `ROADMAP.md`
- `PLAN.md`
- `SUMMARY.md`
- `VERIFICATION.md`
- `STATE.md`
- `REQUIREMENTS.md`
- `CONTEXT.md`
If the first selector has no content, GSD tries the next selector. If no selector produces content, GSD uses `fallback` when present. Empty final bodies are omitted.
## Template Tokens
`template` supports only these tokens:
- `{phase_number}`
- `{phase_name}`
- `{phase_dir}`
- `{base_branch}`
- `{padded_phase}`
Unknown tokens are rejected by config validation. This keeps PR body generation predictable and avoids accidental prompt or shell expansion.
Example:
```json
{
"heading": "Stakeholder Review & Approval",
"enabled": true,
"template": "- Product owner approval pending for {phase_name}."
}
```
## Agile PRD Examples
For a lightweight agile PRD trail, use sections that map to the increment being shipped:
```json
{
"heading": "User Stories & Acceptance Criteria",
"enabled": true,
"source": "REQUIREMENTS.md ## User Stories || REQUIREMENTS.md ## Acceptance Criteria",
"fallback": "- Acceptance criteria are covered by the linked requirements and verification evidence."
}
```
```json
{
"heading": "Success Metrics & Release Criteria",
"enabled": true,
"source": "REQUIREMENTS.md ## Definition of Done || VERIFICATION.md ## Release Criteria",
"fallback": "- Release when automated verification and required manual checks pass."
}
```
These sections make the PR body useful as a release artifact: concise enough for review, but traceable back to requirements and verification.
## Troubleshooting
### `ship.pr_body_sections` is rejected
Check that the value is a JSON array and each entry has:
- a one-line `heading`
- `enabled` as `true` or `false`, not a string
- at least one of `source`, `template`, or `fallback`
- only supported fields
### A section does not appear in the PR body
Check these conditions:
- `enabled` is not `false`
- the selected source heading exists in the allowed artifact
- `fallback` or `template` is present if source content may be missing
- the rendered body is not empty after trimming
### A template token is rejected
Use only the supported token list above. Arbitrary environment variables, shell substitutions, and project-specific tokens are intentionally unsupported.

View File

@@ -43,6 +43,7 @@ const VALID_CONFIG_KEYS = new Set([
'workflow.security_block_on',
'workflow.drift_threshold',
'workflow.drift_action',
'ship.pr_body_sections',
'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
'planning.commit_docs', 'planning.search_gitignored', 'planning.sub_repos',
'review.ollama_host', 'review.lm_studio_host', 'review.llama_cpp_host',

View File

@@ -30,6 +30,16 @@ const CONFIG_KEY_SUGGESTIONS = {
'plan_checker': 'workflow.plan_check',
};
const SHIP_PR_BODY_SECTION_KEYS = new Set(['heading', 'enabled', 'source', 'fallback', 'template']);
const SHIP_PR_BODY_TEMPLATE_TOKENS = new Set([
'phase_number',
'phase_name',
'phase_dir',
'base_branch',
'padded_phase',
]);
const SHIP_PR_BODY_SOURCE_RE = /^(ROADMAP|PLAN|SUMMARY|VERIFICATION|STATE|REQUIREMENTS|CONTEXT)\.md\s+##\s+[^\r\n#][^\r\n]*$/;
function validateKnownConfigKeyPath(keyPath) {
const suggested = CONFIG_KEY_SUGGESTIONS[keyPath];
if (suggested) {
@@ -37,6 +47,64 @@ function validateKnownConfigKeyPath(keyPath) {
}
}
function validateShipPrBodySections(value) {
if (!Array.isArray(value)) {
error('Invalid ship.pr_body_sections value. Expected a JSON array of section objects.');
}
value.forEach((section, index) => {
const prefix = `Invalid ship.pr_body_sections[${index}]`;
if (!section || typeof section !== 'object' || Array.isArray(section)) {
error(`${prefix}. Expected an object.`);
}
const unknownKeys = Object.keys(section).filter((key) => !SHIP_PR_BODY_SECTION_KEYS.has(key));
if (unknownKeys.length > 0) {
error(`${prefix}. Unknown field(s): ${unknownKeys.join(', ')}.`);
}
if (typeof section.heading !== 'string' || section.heading.trim() === '') {
error(`${prefix}. heading must be a non-empty string.`);
}
if (/[\r\n]/.test(section.heading)) {
error(`${prefix}. heading must be a single line.`);
}
if ('enabled' in section && typeof section.enabled !== 'boolean') {
error(`${prefix}. enabled must be true or false.`);
}
for (const field of ['source', 'fallback', 'template']) {
if (field in section && typeof section[field] !== 'string') {
error(`${prefix}. ${field} must be a string.`);
}
}
const hasContent = ['source', 'fallback', 'template'].some((field) => {
return typeof section[field] === 'string' && section[field].trim() !== '';
});
if (!hasContent) {
error(`${prefix}. Provide at least one of source, fallback, or template.`);
}
if (typeof section.source === 'string' && section.source.trim() !== '') {
const selectors = section.source.split('||').map((selector) => selector.trim()).filter(Boolean);
if (selectors.length === 0 || selectors.some((selector) => !SHIP_PR_BODY_SOURCE_RE.test(selector))) {
error(`${prefix}. source must use selectors like "PLAN.md ## Risks", separated with "||".`);
}
}
if (typeof section.template === 'string') {
const tokens = section.template.matchAll(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g);
for (const match of tokens) {
if (!SHIP_PR_BODY_TEMPLATE_TOKENS.has(match[1])) {
error(`${prefix}. Unsupported template token: {${match[1]}}.`);
}
}
}
});
}
/**
* Build a fully-materialized config object for a new project.
*
@@ -127,6 +195,9 @@ function buildNewProjectConfig(userChoices) {
security_asvs_level: CONFIG_DEFAULTS.security_asvs_level,
security_block_on: CONFIG_DEFAULTS.security_block_on,
},
ship: {
pr_body_sections: [],
},
hooks: {
context_warnings: true,
},
@@ -137,7 +208,7 @@ function buildNewProjectConfig(userChoices) {
};
// Three-level deep merge: hardcoded <- userDefaults <- choices
return {
const config = {
...hardcoded,
...userDefaults,
...choices,
@@ -151,6 +222,11 @@ function buildNewProjectConfig(userChoices) {
...(userDefaults.workflow || {}),
...(choices.workflow || {}),
},
ship: {
...hardcoded.ship,
...(userDefaults.ship || {}),
...(choices.ship || {}),
},
hooks: {
...hardcoded.hooks,
...(userDefaults.hooks || {}),
@@ -162,6 +238,9 @@ function buildNewProjectConfig(userChoices) {
...(choices.agent_skills || {}),
},
};
validateShipPrBodySections(config.ship.pr_body_sections);
return config;
}
/**
@@ -355,6 +434,10 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
}
}
if (keyPath === 'ship.pr_body_sections') {
validateShipPrBodySections(parsedValue);
}
// Human verification checkpoint mode (#3309)
const VALID_HUMAN_VERIFY_MODES = ['mid-flight', 'end-of-phase'];
if (keyPath === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) {

View File

@@ -270,6 +270,14 @@ Set via `workflow.*` namespace in config.json (e.g., `"workflow": { "research":
| `workflow.security_block_on` | string | `"high"` | `"high"`, `"medium"`, `"low"` | Minimum severity that blocks phase advancement |
| `workflow.post_planning_gaps` | boolean | `true` | `true`, `false` | Post-planning gap report (#2493). After plans are generated, scans REQUIREMENTS.md and CONTEXT.md `<decisions>` against all PLAN.md files and emits a unified `Source \| Item \| Status` table. Non-blocking. Set to `false` to skip Step 13e of plan-phase. _Alias:_ `post_planning_gaps` is the flat-key form used in `CONFIG_DEFAULTS`; `workflow.post_planning_gaps` is the canonical namespaced form. |
### Ship Fields
Set via `ship.*` namespace in config.json. These fields affect `/gsd-ship` PRD-style pull request body composition only.
| Key | Type | Default | Allowed Values | Description |
|-----|------|---------|----------------|-------------|
| `ship.pr_body_sections` | array | `[]` | Array of section objects | Append-only project-specific PR body sections. Each entry has `heading`, optional `enabled`, and one or more of `source`, `template`, or `fallback`. Disabled entries remain in onboarding config but do not render. Core sections remain required and cannot be removed or replaced. |
### Git Fields
Set via `git.*` namespace (e.g., `"git": { "branching_strategy": "phase" }`).

View File

@@ -20,6 +20,9 @@
"cross_ai_command": "",
"cross_ai_timeout": 300
},
"ship": {
"pr_body_sections": []
},
"planning": {
"commit_docs": true,
"search_gitignored": false,

View File

@@ -235,11 +235,35 @@ AskUserQuestion([
])
```
**Round 3 — PR body onboarding:**
Ask which optional PRD-style sections `/gsd-ship` should append to generated PR bodies. These map to `ship.pr_body_sections`; selected sections are written with `"enabled": true`, unselected seeded sections are written with `"enabled": false` so the project can enable them later without editing `ship.md`.
Prefer lean/agile PRD sections that make the delivered increment clear: user stories, acceptance criteria, Definition of Done or release criteria, risks, dependencies, and stakeholder review.
```
AskUserQuestion([
{
header: "PR Body",
question: "Which optional PRD-style sections should /gsd-ship include in PR bodies?",
multiSelect: true,
options: [
{ label: "User Stories & Acceptance Criteria", description: "Append user-facing stories and acceptance checks from REQUIREMENTS.md" },
{ label: "Risks & Dependencies", description: "Append rollout risks, dependencies, and rollback notes from PLAN.md" },
{ label: "Success Metrics & Release Criteria", description: "Append measurable Definition of Done and release checks for stakeholder review" },
{ label: "Stakeholder Review & Approval", description: "Append approval checklist for projects that need sign-off traceability" }
]
}
])
```
Build `ship.pr_body_sections` from those choices. For selected options, set `enabled: true`; for seeded but unselected options, set `enabled: false`. If the user selects none, use `"ship":{"pr_body_sections":[]}`.
Create `.planning/config.json` with all settings (CLI fills in remaining defaults automatically):
```bash
mkdir -p .planning
gsd-sdk query config-new-project '{"mode":"yolo","granularity":"[selected]","parallelization":true|false,"commit_docs":true|false,"model_profile":"quality|balanced|budget|inherit","workflow":{"research":true|false,"plan_check":true|false,"verifier":true|false,"nyquist_validation":true|false,"auto_advance":true}}'
gsd-sdk query config-new-project '{"mode":"yolo","granularity":"[selected]","parallelization":true|false,"commit_docs":true|false,"model_profile":"quality|balanced|budget|inherit","workflow":{"research":true|false,"plan_check":true|false,"verifier":true|false,"nyquist_validation":true|false,"auto_advance":true},"ship":{"pr_body_sections":[{"heading":"User Stories & Acceptance Criteria","enabled":true|false,"source":"REQUIREMENTS.md ## User Stories || REQUIREMENTS.md ## Acceptance Criteria","fallback":"- Acceptance criteria are covered by the linked requirements and verification evidence."},{"heading":"Risks & Dependencies","enabled":true|false,"source":"PLAN.md ## Risks || PLAN.md ## Dependencies","fallback":"- No known high-risk rollout dependencies."},{"heading":"Success Metrics & Release Criteria","enabled":true|false,"source":"REQUIREMENTS.md ## Definition of Done || VERIFICATION.md ## Release Criteria","fallback":"- Release when automated verification and required manual checks pass."},{"heading":"Stakeholder Review & Approval","enabled":true|false,"template":"- Product owner approval pending for {phase_name}."}]}}'
```
**If commit_docs = No:** Add `.planning/` to `.gitignore`.
@@ -657,11 +681,20 @@ questions: [
]
```
**PR body onboarding:** Ask which optional PRD-style sections `/gsd-ship` should append to generated PR bodies. Use the same `ship.pr_body_sections` mapping as Step 2a: selected sections get `enabled: true`, seeded-but-unselected sections get `enabled: false`, and selecting none writes an empty list. Prefer lean/agile PRD sections that make user value, acceptance criteria, Definition of Done, and stakeholder traceability explicit.
Recommended options:
- `User Stories & Acceptance Criteria`
- `Risks & Dependencies`
- `Success Metrics & Release Criteria`
- `Stakeholder Review & Approval`
Create `.planning/config.json` with all settings (CLI fills in remaining defaults automatically):
```bash
mkdir -p .planning
gsd-sdk query config-new-project '{"mode":"[yolo|interactive]","granularity":"[selected]","parallelization":true|false,"commit_docs":true|false,"model_profile":"quality|balanced|budget|inherit","workflow":{"research":true|false,"plan_check":true|false,"verifier":true|false,"nyquist_validation":[false if granularity=coarse, true otherwise]}}'
gsd-sdk query config-new-project '{"mode":"[yolo|interactive]","granularity":"[selected]","parallelization":true|false,"commit_docs":true|false,"model_profile":"quality|balanced|budget|inherit","workflow":{"research":true|false,"plan_check":true|false,"verifier":true|false,"nyquist_validation":[false if granularity=coarse, true otherwise]},"ship":{"pr_body_sections":[{"heading":"User Stories & Acceptance Criteria","enabled":true|false,"source":"REQUIREMENTS.md ## User Stories || REQUIREMENTS.md ## Acceptance Criteria","fallback":"- Acceptance criteria are covered by the linked requirements and verification evidence."},{"heading":"Risks & Dependencies","enabled":true|false,"source":"PLAN.md ## Risks || PLAN.md ## Dependencies","fallback":"- No known high-risk rollout dependencies."},{"heading":"Success Metrics & Release Criteria","enabled":true|false,"source":"REQUIREMENTS.md ## Definition of Done || VERIFICATION.md ## Release Criteria","fallback":"- Release when automated verification and required manual checks pass."},{"heading":"Stakeholder Review & Approval","enabled":true|false,"template":"- Product owner approval pending for {phase_name}."}]}}'
```
**Note:** Run `/gsd-settings` anytime to update model profile, workflow agents, branching strategy, and other preferences.

View File

@@ -141,16 +141,69 @@ For each SUMMARY.md in the phase directory:
{Decisions from STATE.md accumulated context relevant to this phase}
```
**7. Configured project sections:**
Read append-only project-specific PRD/PR body sections from config:
```bash
CUSTOM_PR_SECTIONS=$(gsd-sdk query config-get ship.pr_body_sections --default '[]' 2>/dev/null || echo '[]')
```
`ship.pr_body_sections` is an onboarding-time extension point for teams that need extra PRD-style sections such as `User Stories & Acceptance Criteria`, `Risks & Dependencies`, `Success Metrics`, `Release Criteria`, or `Stakeholder Review & Approval`.
Use these sections for lean/agile PRD material that should travel with the PR without making the core `/gsd-ship` body configurable:
- User stories and acceptance criteria that explain the functional increment from the user's point of view.
- Definition of Done or release criteria that make the completion standard explicit.
- Risks, dependencies, stakeholder review, and traceability notes needed by regulated or approval-heavy projects.
Rules:
- Treat configured sections as append-only. They are rendered after `Key Decisions` and cannot replace, remove, or reorder the required core sections: `Summary`, `Changes`, `Requirements Addressed`, `Verification`, and `Key Decisions`.
- Each entry must have `heading` plus at least one of `source`, `template`, or `fallback`.
- `enabled` defaults to `true`; when `enabled` is `false`, skip the section without warning. This lets onboarding seed optional sections that a project can enable later.
- `source` is a fallback chain of planning artifact headings: `PLAN.md ## Risks || VERIFICATION.md ## Manual Checks`. Allowed artifacts are `ROADMAP.md`, `PLAN.md`, `SUMMARY.md`, `VERIFICATION.md`, `STATE.md`, `REQUIREMENTS.md`, and `CONTEXT.md`.
- `template` is literal Markdown with a closed token namespace only: `{phase_number}`, `{phase_name}`, `{phase_dir}`, `{base_branch}`, `{padded_phase}`.
- `fallback` is literal Markdown used when `source` finds no content and no `template` is present.
- Omit sections whose final rendered body is empty after trimming.
Example configured sections:
```json
[
{
"heading": "User Stories & Acceptance Criteria",
"enabled": true,
"source": "REQUIREMENTS.md ## User Stories || REQUIREMENTS.md ## Acceptance Criteria",
"fallback": "- Acceptance criteria are covered by the linked requirements and verification evidence."
},
{
"heading": "Risks & Dependencies",
"enabled": true,
"source": "PLAN.md ## Risks || PLAN.md ## Dependencies",
"fallback": "- No known high-risk rollout dependencies."
},
{
"heading": "Stakeholder Review & Approval",
"enabled": false,
"template": "- Product owner approval pending for {phase_name}."
}
]
```
</step>
<step name="create_pr">
Create the PR using the generated body:
Create the PR using the generated body. Write the body to a temp file first so large generated PRD sections do not hit shell argument limits:
```bash
PR_BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/gsd-pr-body.XXXXXX.md")
trap 'rm -f "${PR_BODY_FILE:-}"' EXIT
printf '%s\n' "${PR_BODY}" > "${PR_BODY_FILE}"
gh pr create \
--title "Phase ${PHASE_NUMBER}: ${PHASE_NAME}" \
--body "${PR_BODY}" \
--base ${BASE_BRANCH}
--body-file "${PR_BODY_FILE}" \
--base "${BASE_BRANCH}"
```
If `--draft` flag was passed: add `--draft`.

View File

@@ -389,6 +389,43 @@ describe('configSet', () => {
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
expect(raw.commit_docs).toBe(true);
});
it('validates ship.pr_body_sections arrays (#3167)', async () => {
const { configSet } = await import('./config-mutation.js');
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({}),
);
const sections = JSON.stringify([
{
heading: 'Risks & Rollback',
enabled: true,
source: 'PLAN.md ## Risks || PLAN.md ## Rollback',
fallback: '- Rollback: revert this PR.',
},
{
heading: 'Stakeholder Sign-off',
enabled: false,
template: '- Product owner: {phase_name}',
},
]);
const result = await configSet(['ship.pr_body_sections', sections], tmpDir);
expect((result.data as { updated: boolean }).updated).toBe(true);
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
expect(raw.ship.pr_body_sections).toHaveLength(2);
expect(raw.ship.pr_body_sections[1].enabled).toBe(false);
await expect(configSet(
['ship.pr_body_sections', JSON.stringify([{ heading: 'Bad', enabled: 'yes', fallback: '- item' }])],
tmpDir,
)).rejects.toThrow(/enabled/);
await expect(configSet(
['ship.pr_body_sections', JSON.stringify([{ heading: 'Bad', template: '- {unknown}' }])],
tmpDir,
)).rejects.toThrow(/Unsupported template token/);
});
});
// ─── configSetModelProfile ─────────────────────────────────────────────────
@@ -452,6 +489,22 @@ describe('configNewProject', () => {
expect(raw.commit_docs).toBe(true);
});
it('validates ship.pr_body_sections choices before writing config', async () => {
const { configNewProject } = await import('./config-mutation.js');
const choices = JSON.stringify({
ship: {
pr_body_sections: [
{
heading: 'Invalid source',
source: 'package.json ## Scripts',
},
],
},
});
await expect(configNewProject([choices], tmpDir)).rejects.toThrow(GSDError);
});
it('does not overwrite existing config', async () => {
const { configNewProject } = await import('./config-mutation.js');
await writeFile(

View File

@@ -72,6 +72,81 @@ const CONFIG_KEY_SUGGESTIONS: Record<string, string> = {
'plan_checker': 'workflow.plan_check',
};
const SHIP_PR_BODY_SECTION_KEYS = new Set(['heading', 'enabled', 'source', 'fallback', 'template']);
const SHIP_PR_BODY_TEMPLATE_TOKENS = new Set([
'phase_number',
'phase_name',
'phase_dir',
'base_branch',
'padded_phase',
]);
const SHIP_PR_BODY_SOURCE_RE = /^(ROADMAP|PLAN|SUMMARY|VERIFICATION|STATE|REQUIREMENTS|CONTEXT)\.md\s+##\s+[^\r\n#][^\r\n]*$/;
function validateShipPrBodySections(value: unknown): void {
if (!Array.isArray(value)) {
throw new GSDError(
'Invalid ship.pr_body_sections value. Expected a JSON array of section objects.',
ErrorClassification.Validation,
);
}
value.forEach((section, index) => {
const prefix = `Invalid ship.pr_body_sections[${index}]`;
if (!section || typeof section !== 'object' || Array.isArray(section)) {
throw new GSDError(`${prefix}. Expected an object.`, ErrorClassification.Validation);
}
const record = section as Record<string, unknown>;
const unknownKeys = Object.keys(record).filter((key) => !SHIP_PR_BODY_SECTION_KEYS.has(key));
if (unknownKeys.length > 0) {
throw new GSDError(`${prefix}. Unknown field(s): ${unknownKeys.join(', ')}.`, ErrorClassification.Validation);
}
if (typeof record.heading !== 'string' || record.heading.trim() === '') {
throw new GSDError(`${prefix}. heading must be a non-empty string.`, ErrorClassification.Validation);
}
if (/[\r\n]/.test(record.heading)) {
throw new GSDError(`${prefix}. heading must be a single line.`, ErrorClassification.Validation);
}
if ('enabled' in record && typeof record.enabled !== 'boolean') {
throw new GSDError(`${prefix}. enabled must be true or false.`, ErrorClassification.Validation);
}
for (const field of ['source', 'fallback', 'template']) {
if (field in record && typeof record[field] !== 'string') {
throw new GSDError(`${prefix}. ${field} must be a string.`, ErrorClassification.Validation);
}
}
const hasContent = ['source', 'fallback', 'template'].some((field) => {
return typeof record[field] === 'string' && record[field].trim() !== '';
});
if (!hasContent) {
throw new GSDError(`${prefix}. Provide at least one of source, fallback, or template.`, ErrorClassification.Validation);
}
if (typeof record.source === 'string' && record.source.trim() !== '') {
const selectors = record.source.split('||').map((selector) => selector.trim()).filter(Boolean);
if (selectors.length === 0 || selectors.some((selector) => !SHIP_PR_BODY_SOURCE_RE.test(selector))) {
throw new GSDError(
`${prefix}. source must use selectors like "PLAN.md ## Risks", separated with "||".`,
ErrorClassification.Validation,
);
}
}
if (typeof record.template === 'string') {
const tokens = record.template.matchAll(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g);
for (const match of tokens) {
if (!SHIP_PR_BODY_TEMPLATE_TOKENS.has(match[1])) {
throw new GSDError(`${prefix}. Unsupported template token: {${match[1]}}.`, ErrorClassification.Validation);
}
}
}
});
}
// ─── isValidConfigKey ─────────────────────────────────────────────────────
/**
@@ -215,6 +290,10 @@ export const configSet: QueryHandler = async (args, projectDir, workstream) => {
);
}
if (keyPath === 'ship.pr_body_sections') {
validateShipPrBodySections(parsedValue);
}
// D6: Lock protection for read-modify-write (match CJS config.cjs:296)
const paths = planningPaths(projectDir, workstream);
const lockPath = await acquireStateLock(paths.config);
@@ -395,6 +474,9 @@ export const configNewProject: QueryHandler = async (args, projectDir, workstrea
code_review: true,
code_review_depth: 'standard',
},
ship: {
pr_body_sections: [],
},
hooks: {
context_warnings: true,
},
@@ -419,6 +501,11 @@ export const configNewProject: QueryHandler = async (args, projectDir, workstrea
...((globalDefaults.workflow as Record<string, unknown>) || {}),
...((userChoices.workflow as Record<string, unknown>) || {}),
},
ship: {
...(defaults.ship as Record<string, unknown>),
...((globalDefaults.ship as Record<string, unknown>) || {}),
...((userChoices.ship as Record<string, unknown>) || {}),
},
hooks: {
...(defaults.hooks as Record<string, unknown>),
...((globalDefaults.hooks as Record<string, unknown>) || {}),
@@ -436,6 +523,9 @@ export const configNewProject: QueryHandler = async (args, projectDir, workstrea
},
};
const ship = config.ship as Record<string, unknown>;
validateShipPrBodySections(ship.pr_body_sections);
await atomicWriteConfig(paths.config, config);
return { data: { created: true, path: paths.config } };

View File

@@ -45,6 +45,7 @@ export const VALID_CONFIG_KEYS: ReadonlySet<string> = new Set([
'workflow.security_block_on',
'workflow.drift_threshold',
'workflow.drift_action',
'ship.pr_body_sections',
'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
'planning.commit_docs', 'planning.search_gitignored', 'planning.sub_repos',
'review.ollama_host', 'review.lm_studio_host', 'review.llama_cpp_host',

View File

@@ -0,0 +1,160 @@
/**
* Regression tests for issue #3167: configurable /gsd-ship PR body sections.
*/
// allow-test-rule: source-text-is-the-product
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { describe, test, afterEach } = require('node:test');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const repoRoot = path.resolve(__dirname, '..');
const tmpDirs = [];
function readRepoFile(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
function makeProject() {
const tmpDir = createTempProject('gsd-3167-');
tmpDirs.push(tmpDir);
return tmpDir;
}
afterEach(() => {
while (tmpDirs.length) {
cleanup(tmpDirs.pop());
}
});
describe('ship.pr_body_sections config (#3167)', () => {
test('CLI config-set accepts additional PR body section arrays', () => {
const cwd = makeProject();
const value = JSON.stringify([
{
heading: 'Risks & Rollback',
enabled: true,
source: 'PLAN.md ## Risks || PLAN.md ## Rollback',
fallback: '- Rollback: revert this PR.',
},
{
heading: 'Stakeholder Sign-off',
enabled: false,
template: '- Product owner: pending',
},
]);
const result = runGsdTools(['config-set', 'ship.pr_body_sections', value, '--raw'], cwd, { HOME: cwd });
assert.equal(result.success, true, result.error);
const config = JSON.parse(fs.readFileSync(path.join(cwd, '.planning', 'config.json'), 'utf8'));
assert.deepEqual(config.ship.pr_body_sections, [
{
heading: 'Risks & Rollback',
enabled: true,
source: 'PLAN.md ## Risks || PLAN.md ## Rollback',
fallback: '- Rollback: revert this PR.',
},
{
heading: 'Stakeholder Sign-off',
enabled: false,
template: '- Product owner: pending',
},
]);
});
test('CLI config-set rejects malformed PR body section values before writing config', () => {
const cwd = makeProject();
const notArray = runGsdTools(
['config-set', 'ship.pr_body_sections', JSON.stringify({ heading: 'Not an array' }), '--raw'],
cwd,
{ HOME: cwd }
);
assert.equal(notArray.success, false);
assert.match(notArray.error, /ship\.pr_body_sections.*JSON array/);
const missingHeading = runGsdTools(
['config-set', 'ship.pr_body_sections', JSON.stringify([{ fallback: '- Missing heading' }]), '--raw'],
cwd,
{ HOME: cwd }
);
assert.equal(missingHeading.success, false);
assert.match(missingHeading.error, /heading/);
const invalidEnabled = runGsdTools(
['config-set', 'ship.pr_body_sections', JSON.stringify([{ heading: 'Toggle', enabled: 'yes', fallback: '- item' }]), '--raw'],
cwd,
{ HOME: cwd }
);
assert.equal(invalidEnabled.success, false);
assert.match(invalidEnabled.error, /enabled/);
assert.equal(fs.existsSync(path.join(cwd, '.planning', 'config.json')), false);
});
test('CLI config-new-project validates onboarded PR body sections before writing config', () => {
const cwd = makeProject();
const choices = JSON.stringify({
ship: {
pr_body_sections: [
{
heading: 'Invalid source',
source: 'package.json ## Scripts',
},
],
},
});
const result = runGsdTools(['config-new-project', choices], cwd, { HOME: cwd });
assert.equal(result.success, false);
assert.match(result.error, /source must use selectors/);
assert.equal(fs.existsSync(path.join(cwd, '.planning', 'config.json')), false);
});
test('ship workflow composes configured sections as append-only extensions', () => {
const workflow = readRepoFile('get-shit-done/workflows/ship.md');
assert.match(workflow, /config-get ship\.pr_body_sections --default '\[\]'/);
assert.match(workflow, /append-only/i);
assert.match(workflow, /enabled.*false/i);
assert.match(workflow, /cannot replace/i);
assert.match(workflow, /Summary[\s\S]*Changes[\s\S]*Requirements Addressed[\s\S]*Verification[\s\S]*Key Decisions/);
assert.match(workflow, /\{phase_number\}[\s\S]*\{phase_name\}[\s\S]*\{phase_dir\}[\s\S]*\{base_branch\}[\s\S]*\{padded_phase\}/);
assert.match(workflow, /User Stories & Acceptance Criteria/);
assert.match(workflow, /Definition of Done/);
assert.match(workflow, /--body-file/);
assert.match(workflow, /trap 'rm -f "\$\{PR_BODY_FILE:-\}"' EXIT/);
});
test('default config and documentation describe ship.pr_body_sections', () => {
const template = JSON.parse(readRepoFile('get-shit-done/templates/config.json'));
assert.deepEqual(template.ship.pr_body_sections, []);
const docs = readRepoFile('docs/CONFIGURATION.md');
assert.match(docs, /`ship\.pr_body_sections`/);
assert.match(docs, /additional PR body sections/i);
assert.match(docs, /append-only/i);
assert.match(docs, /lean\/agile PRD/i);
assert.match(docs, /Definition of Done/);
const planningConfig = readRepoFile('get-shit-done/references/planning-config.md');
assert.match(planningConfig, /ship\.pr_body_sections/);
});
test('new-project onboarding can seed enabled or disabled PR body sections', () => {
const workflow = readRepoFile('get-shit-done/workflows/new-project.md');
assert.match(workflow, /ship\.pr_body_sections/);
assert.match(workflow, /enabled.*true/);
assert.match(workflow, /enabled.*false/);
assert.match(workflow, /User Stories & Acceptance Criteria/);
assert.match(workflow, /Risks & Dependencies/);
assert.match(workflow, /Success Metrics & Release Criteria/);
assert.match(workflow, /Stakeholder Review & Approval/);
});
});