Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions packages/agents/content/scripts/describe-change.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# Resolve commit, ticket, and PR prefixes from preferences.
#
# Reads prefix conventions from `.agents/preferences.yaml` (project) with
# fallback to `~/.agents/preferences.yaml` (global), then to empty string.
#
# Usage:
# describe-change.sh [--scope SCOPE] [--type TYPE]
#
# Output: JSON object with `commit_prefix`, `ticket_prefix`, and `pr_prefix`.
# Non-empty values include a trailing `: `.

set -euo pipefail

scope=""
type=""

while [[ $# -gt 0 ]]; do
case "$1" in
--scope)
scope="$2"
shift 2
;;
--type)
type="$2"
shift 2
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done

# Parse a specific prefix value from a YAML file.
# Reads line-by-line, tracks the current top-level section, and matches
# `prefix:` within the target section (commit, ticket, or pr).
# Outputs "FOUND:{value}" when the key is present (value may be empty),
# or nothing when the key is absent. This lets callers distinguish
# "key absent" from "key present with empty value."
parse_prefix() {
local file="$1"
local section="$2"
local current_section=""

if [[ ! -f "$file" ]]; then
return
fi

while IFS= read -r line || [[ -n "$line" ]]; do
# Skip blank lines and comments
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue

# Detect top-level keys (no leading whitespace, ends with colon)
if [[ "$line" =~ ^[a-zA-Z_] ]]; then
current_section="${line%%:*}"
continue
fi

# Match prefix: within the target section
if [[ "$current_section" == "$section" && "$line" =~ ^[[:space:]]+prefix:[[:space:]]*(.*) ]]; then
local value="${BASH_REMATCH[1]}"
# Strip inline comments
value="${value%%#*}"
# Trim trailing whitespace
value="${value%"${value##*[![:space:]]}"}"
# Strip surrounding quotes
if [[ "$value" =~ ^\'(.*)\'$ ]]; then
value="${BASH_REMATCH[1]}"
elif [[ "$value" =~ ^\"(.*)\"$ ]]; then
value="${BASH_REMATCH[1]}"
fi
echo "FOUND:${value}"
return
fi
done < "$file"
}

# Resolve a prefix value by checking project, then global, then defaulting to empty.
# parse_prefix returns "FOUND:{value}" when the key is present, or empty when absent.
# This lets an explicit empty value at the project level override a global non-empty value.
resolve_prefix() {
local section="$1"
local result

# Project preferences
result="$(parse_prefix ".agents/preferences.yaml" "$section")"
if [[ "$result" == FOUND:* ]]; then
echo "${result#FOUND:}"
return
fi

# Global preferences
result="$(parse_prefix "$HOME/.agents/preferences.yaml" "$section")"
if [[ "$result" == FOUND:* ]]; then
echo "${result#FOUND:}"
return
fi

echo ""
}

# Format a prefix string from a convention template, scope, and type.
# Convention placeholders: {scope}, {type}.
# When convention is empty, always emit empty.
# When only --type is provided, emit `{type}: ` regardless of convention.
# When only --scope or neither is provided, emit empty.
format_prefix() {
local convention="$1"

# Empty convention means no prefix
if [[ -z "$convention" ]]; then
echo ""
return
fi

# Neither scope nor type
if [[ -z "$scope" && -z "$type" ]]; then
echo ""
return
fi

# Only scope, no type
if [[ -n "$scope" && -z "$type" ]]; then
echo ""
return
fi

# Only type, no scope
if [[ -z "$scope" && -n "$type" ]]; then
echo "${type}: "
return
fi

# Both scope and type: substitute into convention
local result="$convention"
result="${result//\{scope\}/$scope}"
result="${result//\{type\}/$type}"
echo "${result}: "
}

# Escape backslashes and double quotes for safe JSON interpolation.
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
echo "$s"
}

commit_convention="$(resolve_prefix "commit")"
ticket_convention="$(resolve_prefix "ticket")"
pr_convention="$(resolve_prefix "pr")"

commit_prefix="$(json_escape "$(format_prefix "$commit_convention")")"
ticket_prefix="$(json_escape "$(format_prefix "$ticket_convention")")"
pr_prefix="$(json_escape "$(format_prefix "$pr_convention")")"

printf '{"commit_prefix":"%s","ticket_prefix":"%s","pr_prefix":"%s"}\n' \
"$commit_prefix" "$ticket_prefix" "$pr_prefix"
94 changes: 51 additions & 43 deletions packages/agents/content/skills/_data/commit-format.md
Original file line number Diff line number Diff line change
@@ -1,50 +1,75 @@
# Git commit format

## Commit title format
## Commit title prefix

The standard commit title is 72 characters max (hard limit):
Commit titles may include a prefix that identifies scope (workspace, package, module) and work type. The prefix format is configurable per repository and per user.

```txt
{workspace}|{WORK_TYPE}: {commit title}
### Resolving the prefix

Run the `describe-change.sh` script to resolve the correct prefix:

```bash
{skills_root}/../scripts/describe-change.sh --scope {scope} --type {type}
```

If the project does not have monorepo workspaces, omit the `{workspace}`:
The script reads `commit.prefix`, `ticket.prefix`, and `pr.prefix` from `.agents/preferences.yaml` (project) then `~/.agents/preferences.yaml` (global), falling back to empty string. It outputs JSON:

```json
{ "commit_prefix": "agents|feat: ", "ticket_prefix": "agents|feat: ", "pr_prefix": "agents|feat: " }
```
{WORK_TYPE}: {description}

Use the `commit_prefix` field for commit titles. Non-empty values already include the trailing `: `.

If the script is not found, produce no prefix.

### Supported conventions

Configure the prefix convention in `.agents/preferences.yaml` or `~/.agents/preferences.yaml`:

```yaml
commit:
prefix: '{scope}|{type}'
```

Add `!` after the work type to indicate breaking changes: `ts|feat!: Remove deprecated API`
| Convention | Example with scope + type | Example with type only |
| ----------------- | ------------------------------------ | ---------------------------- |
| `{scope}\|{type}` | `agents\|feat: Add script installer` | `feat: Add script installer` |
| `{type}({scope})` | `feat(agents): Add script installer` | `feat: Add script installer` |
| `{type}` | `feat: Add script installer` | `feat: Add script installer` |
| `''` (empty) | `Add script installer` | `Add script installer` |

## Ticket ID
When only `--type` is provided (no `--scope`), the prefix is always `{type}: ` regardless of convention. When only `--scope` or neither is provided, the prefix is empty.

Do not include the ticket ID in the commit title. The branch name already carries it.
### Scope

Include the ticket ID at the end of the commit body only if the branch covers more than one ticket (rare).
The scope identifies the part of the codebase affected by the commit:

## Line length
- In a monorepo, the scope is typically the workspace name or abbreviation.
- Use `root` if the commit touches only files in the monorepo root.
- Use `*` if the commit spans multiple workspaces, or root and one or more workspaces.
- If a root change is tightly associated with only one workspace, don't count it as a root change.

- **Title**: 72 characters max (hard limit).
- **Body**: No hard wrapping. Write naturally — do not insert newlines to wrap at a column width.
Common example: if a package is added to `packages/workspace-a`, that updates the package lock file in root. Don't treat that as a change to root.

## Examples
## Title constraints

### Monorepo workspace
- **72 characters max** (hard limit).
- **Describes the code change, not what prompted it.** Ask: "what does the diff do?" Bad: "Address review findings". Good: "Add error logging to `handleStateUpdate`".
- **No ephemeral references.** If it won't make sense to a reader who has only `git log`, leave it out.
- **Only document what's in the diff.** External actions (e.g., updating a ticket) don't belong.

In a monorepo the workspace is usually the name (or abbreviated name) of the workspace changed by the commit:
Add `!` after the work type to indicate breaking changes: `agents|feat!: Remove deprecated API`

```
web|tests: Fix ProgressNotes tests broken by upgrades
*|internal: Add user route and user profile component
admin|deps!: Upgrade React to v18
```
## Ticket ID

### Non-monorepo
Do not include the ticket ID in the commit title. The branch name already carries it.

```
feat: Add user profile component
deps: Upgrade React to v18
```
Include the ticket ID at the end of the commit body only if the branch covers more than one ticket (rare).

## Line length

- **Title**: 72 characters max (hard limit).
- **Body**: No hard wrapping. Write naturally — do not insert newlines to wrap at a column width.

## Body formatting

Expand All @@ -57,20 +82,3 @@ deps: Upgrade React to v18
## Branch naming

See `branch-format.md` for branch naming conventions. Branch format: `{ticket}/{description}`.

## Legacy format

This was the previously used format. Some projects still use it, but don't propagate it. The `{TICKET}` prefix in these templates is part of the old format and should not be used in new commits.

```txt
{workspace} {TICKET}: [{WORK_TYPE}] {description}

# No ticket
{workspace} [{WORK_TYPE}] {description}

# Not a monorepo
{TICKET}: [{WORK_TYPE}] {description}

# No ticket, not a monorepo
[{WORK_TYPE}] {description}
```
18 changes: 4 additions & 14 deletions packages/agents/content/skills/commit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,12 @@ user-invocable: true

## Commit message format

Commit titles follow this format:

```txt
{workspace}|{WORK_TYPE}: {commit title}
```

See `../_data/commit-format.md` for full specification.
See `../_data/commit-format.md` for the full specification, including how to resolve the commit title prefix using `describe-change.sh`.

## Commit metadata

- `WORK_TYPE` describes the category of work (see `../_data/work-types.md`)

Example: `web|tests: Fix PromoPage tests`

- WORK_TYPE: `tests`

## Ticket ID

Do not include the ticket ID in the commit title. The branch name carries it. Include it at the end of the commit body only if the branch covers more than one ticket (rare).
Expand All @@ -45,11 +35,11 @@ Do not include the ticket ID in the commit title. The branch name carries it. In

See `../_data/commit-format.md` for body formatting rules (punctuation, backtick formatting, paragraph structure, and what to omit).

## Changes touching multiple workspaces
## Changes touching multiple scopes

- Use `root` if commit touches only files in monorepo root
- Use `*` if commit comprises changes to multiple workspaces, or root and one or more workspaces
- If a root change is tightly associated with only one workspace, don't count it as a root change
- Use `*` if commit comprises changes to multiple scopes, or root and one or more scopes
- If a root change is tightly associated with only one scope, don't count it as a root change

Common example: If a package is added to `packages/workspace-a`, that updates the package lock file in root. Don't treat that as a change to root.

Expand Down
8 changes: 4 additions & 4 deletions packages/agents/content/skills/condense-branch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,13 @@ Use `summarize-change` to compose a good commit message. Save the description pe

## Commit format

Follow [commit-format.md](../_data/commit-format.md):
Follow [commit-format.md](../_data/commit-format.md). Use `describe-change.sh` to resolve the commit title prefix:

```bash
{skills_root}/../scripts/describe-change.sh --scope {scope} --type {type}
```
{workspace}|{WORK_TYPE}: {title}

{body}
```
Use the `commit_prefix` field from the JSON output as the title prefix.

## Safety

Expand Down
9 changes: 8 additions & 1 deletion packages/agents/content/skills/create-ticket/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,17 @@ Determine where to create the remote ticket:

#### GitHub path

Resolve the ticket prefix using `describe-change.sh`:

```bash
json=$({skills_root}/../scripts/describe-change.sh --scope {scope} --type {type})
change_prefix=$(echo "$json" | grep -o '"ticket_prefix":"[^"]*"' | cut -d'"' -f4)
```

Create the issue **without** the ticket ID prefix in the title:

```bash
url=$(gh issue create --title "{scope}|{type}: {title}" --body "{ticket body}")
url=$(gh issue create --title "${change_prefix}{title}" --body "{ticket body}")
```

Extract the issue number from the returned URL:
Expand Down
11 changes: 10 additions & 1 deletion packages/agents/content/skills/prepare-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,16 @@ git rev-parse --short HEAD

4. **If no match found**: Use `summarize-change` first, then continue

5. **Create PR description**:
5. **Resolve PR title prefix** using `describe-change.sh`:

```bash
json=$({skills_root}/../scripts/describe-change.sh --scope {scope} --type {type})
pr_prefix=$(echo "$json" | grep -o '"pr_prefix":"[^"]*"' | cut -d'"' -f4)
```

Use `${pr_prefix}{title}` as the PR title. See [commit-format.md](../_data/commit-format.md) for prefix conventions.

6. **Create PR description**:
- Copy change summary content
- Save per the [Saving](#saving) section

Expand Down
Loading
Loading