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
25 changes: 24 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,30 @@

## Unreleased

### Added
### Added (save-hook: weld the boundary)
- `hooks/validate-issue-on-save.sh`: a Claude Code PreToolUse hook for `mcp__linear__save_issue` that runs the Gate-1 validator at the tool boundary, so the check cannot be skipped on the headless batch-filing path (a PreToolUse hook fires for subagent tool calls too). Create-only (an update with an `id` is left alone, so the pre-Anchor corpus is never orphaned); build-lane scoped (governance labels `human`/`eval` exempt); `observe` by default (logs would-blocks, allows) and `enforce` on an operator flip (denies with the validator's re-fileable output via `permissionDecision: deny`). Fails open on any unexpected input. On a create, a missing or prose-shaped Anchor is promoted from WARN to a block. `hooks/README.md` documents install + the operator-owned enforce flip (gated on a clean corpus replay); `hooks/test_hook.sh` covers the decision matrix (7 cases).

### Added (Anchor + validator hygiene)
- **Anchor field.** Every story and task now carries a `**Anchor:**` line: one machine-resolvable target (`file.ext:line`, `file.ext:symbol`, `module.function`, `METHOD /path -> status`, or `playwright:selector`). It is the interface the downstream held-out probe binds to; a probe cannot test a thing the issue never named. The validator WARNs on a missing or prose-shaped anchor; a save-hook (separate increment) promotes it to a create-only ERROR.
- **Task archetype** for a single bug, chore, spike, or ops change too small for the full story ceremony. Selected with `**Type:** Bug|Chore|Spike|Ops` (or a title prefix); shape is Anchor / Problem / Verification. Fixes the coverage gap where a one-line bug had to be inflated into a Story or mis-filed as a one-issue Hardening ticket. New title pattern (6) and canonical Example 5.
- **Compiler-input framing** in `SKILL.md` and README: the issue is read by an autonomous pipeline (planner, held-out probe, implementer, reviewer, merge gate), so concreteness is the product and prose is not.
- **Two-mode operation.** Interactive mode keeps the three gates and human approval. Headless / pipeline mode (a subagent filing a batch to the execution pipeline) drops the per-issue Gate 3 reviewer and the draft-approval ceremony, and relies on Gate 1 passing before every `save_issue` (enforced at the tool boundary by a save-hook). This is the path where 80 issues once shipped with the validator never run.
- Story template gains an `## Out of scope` non-goals section (the implementer agent gold-plates without a boundary) and a `## Post-ship follow-up` line for soak/dashboard checks that cannot run headless. Hardening issues gain a per-issue `**Verify:**` line (their only testable contract, since they have no Requirements section).
- New validator tests: fenced-code blindness, the `---` tail-drop split, a standalone story, an adjacent-metric false-negative, and the Anchor shape check.

### Changed (validator correctness)
- **`epic-backlink` demoted to WARN.** A standalone or refactor story with no parent no longer hard-fails; write `**Epic:** none` to mark the omission deliberate. This was the single most common self-inflicted validator failure.
- **`labels-verify` WARN deleted.** It fired on every block every run and could never be discharged by the script, pure noise.
- **Vague-verb check now requires an adjacent metric.** The metric must sit within a few words of `improve`/`enhance`/`optimize`; a bare integer elsewhere on the line (a version, issue id, or port) no longer launders the verb.
- **Vague-verb matches action verbs only, not their noun forms.** A corpus replay over 100 real issues found the old `improv\w*|optimiz\w*` firing on terms of art (`self-improvement`, `optimizer`, `evaluator-optimizer`), which blocked legitimate creates. The regex now matches only `improve`/`enhance`/`optimize` verb and gerund forms. (This was the one validator-defect false-fail the replay surfaced; none of the Increment-1 changes introduced any.)
- **Code-fence and inline-code awareness.** The em-dash, AI-tell, vague-verb, and section scans skip fenced code and inline-code spans, so a quoted `## Description`, `robust-parser.ts`, or a code sample no longer false-trips.
- **`---` splitting is fence-aware and merges title-less tails.** A `---` inside a code block, or one that only splits body prose, is content; it can no longer silently drop the tail of an issue (and everything after it) from validation.
- **`epic-story-count` counts any bullet** under "Stories under this epic" (verbatim child titles and ordered lists included), and role-prefixed child titles are exempt from the em-dash check, resolving the contradiction with "list child titles verbatim".
- **Estimate** is set as the Linear field at `save_issue` time, not mandated as a body line (no execution stage reads it); the reviewer treats a missing estimate as at most Minor. Priority enum reconciled to `Urgent / High / Medium / Low / No priority` with a validator WARN on off-enum values; `Urgent` is reserved for an active incident.
- The canonical examples carry Anchor lines and runnable acceptance closers; the 24h-soak closers moved to Post-ship follow-up, and Example 2's "operator" persona is annotated as a real workspace role (not the invented-persona anti-pattern).
- Portable validator invocation in `SKILL.md` (resolve from the skill dir), and the Gate-1 loophole is closed: time pressure and "too trivial to check" are not exemptions.

### Added (original three-gate work)
- `skill/review/issue-reviewer.md`: Gate 3, an independent issue-reviewer subagent template adapted from the code-review pattern. A fresh-context agent re-verifies every claim in a draft against the real repo and Linear workspace (file:line references, "already done" diagnoses, "change everywhere" sweeps, scope bundling, missing estimates, data-vs-code gaps, dedup and epic/child linkage) and returns graded findings with a File-as-is / Fix-before-filing / Decompose-first verdict. Grounded in real issue-creation failures (a false "there is no middleware.ts" diagnosis that would have shipped the wrong locale; a partial copy sweep that left the storefront and checkout disagreeing).
- `skill/scripts/validate_issue.py`: a stdlib-only validator that mechanically enforces the house-style rules (em-dash in prose, vague verbs without a metric, AI tells, section names and order per archetype, hardening `Fix:` lines, epic story count, label shape, title rules). The skill runs it as a hard gate before any draft is shown. Tests in `skill/scripts/tests/`.
- Validator support for the bundled-concept story variant (`### Concept` blocks replace the `## Solution` H2), so those drafts pass the gate; added a fixture.
Expand Down
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
# linear-issue-craft

A portable Claude Code skill that captures a tight, repeatable Linear issue writing style — designed to be applied to any Linear workspace.
A portable Claude Code skill that captures a tight, repeatable Linear issue writing style, designed to be applied to any Linear workspace.

Every pattern in this repo is grounded in real shipped issues, not invented. The three canonical exemplars are quoted verbatim (with workspace identifiers anonymized) in `skill/references/06-canonical-examples.md`.
The issue is treated as a compiler input, not an essay: an autonomous execution pipeline reads it (a planner, a held-out probe that writes a frozen test from a Story's title plus Requirements and Evaluation, or a Task's Problem and Verification, an implementer, a reviewer, a merge gate), so concreteness is the product. Every pattern in this repo is grounded in real shipped issues, not invented. The canonical exemplars are quoted verbatim (with workspace identifiers anonymized) in `skill/references/06-canonical-examples.md`.

## What's in the box

```
skill/
├── SKILL.md Main skill entry: decision flow, golden rules, three-gate output contract
├── references/
│ ├── 01-title-patterns.md 5 title shapes with real examples
│ ├── 01-title-patterns.md 6 title shapes with real examples
│ ├── 02-description-templates.md Body skeletons per archetype
│ ├── 03-tone-guide.md Voice characteristics, hard "no" list
│ ├── 04-labels-and-priority.md Layered labels + category-driven priority
│ ├── 05-anti-patterns.md Common AI failures and their fixes
│ └── 06-canonical-examples.md Three full verbatim exemplars (Epic, Story, Hardening)
│ └── 06-canonical-examples.md Five full verbatim exemplars (Epic, Story, Hardening, Bundled, Task)
├── prompts/
│ ├── brief-to-epic.md Workflow: 1-paragraph brief → epic + N child stories
│ ├── hardening-issue.md Workflow: list of defects in one file → Hardening ticket
Expand All @@ -26,13 +26,16 @@ skill/
└── issue-reviewer.md Gate 3: independent reviewer subagent template
```

## The three archetypes
## The archetypes

| Archetype | Title shape | Body sections |
|---|---|---|
| **Epic** | `Epic: <Domain>` | Goal · Outcomes · Out of scope · Stories under this epic |
| **Story** | imperative verb + concrete deliverable, or `<Role> — <Capability>` | (epic backlink) · User Story · Problem · Solution · Requirements · Evaluation |
| **Hardening** | `Harden <file.ts>: <symptom1>, <symptom2>, <symptom3>` | Summary · Issues (numbered, each with **Fix:**) |
| **Story** | imperative verb + concrete deliverable, or `<Role> — <Capability>` | (epic backlink) · Anchor · User Story · Problem · Solution · Out of scope · Requirements · Evaluation |
| **Hardening** | `Harden <file.ts>: <symptom1>, <symptom2>, <symptom3>` | Summary · Issues (numbered, each with **Fix:** + **Verify:**) |
| **Task** | `Bug:` / `Chore:` / `Spike:` / `Ops:` (or set `**Type:**`) | Anchor · Problem · Verification |

Every **Story** and **Task** carries an **Anchor**: one machine-resolvable target (`file.ext:line`, `file.ext:symbol`, `module.function`, `METHOD /path -> status`, or `playwright:selector`) that the held-out probe binds to.

## How to use it

Expand All @@ -52,7 +55,7 @@ You can also paste the contents of `skill/SKILL.md` and any relevant `references

### Filing the drafts

Drafts produced under this skill are designed to be fed into the Linear MCP server's `save_issue` tool. Before a draft is shown, it clears three gates: a mechanical validator (`scripts/validate_issue.py`), a judgment checklist, and an independent issue-reviewer subagent (`review/issue-reviewer.md`) that re-verifies every claim against the actual code and workspace (file:line references, "already done" diagnoses, "change everywhere" sweeps, scope, estimates, dedup). The skill requires explicit user approval before any `save_issue` call.
Drafts produced under this skill are designed to be fed into the Linear MCP server's `save_issue` tool. In interactive mode, before a draft is shown it clears three gates: a mechanical validator (`scripts/validate_issue.py`), a judgment checklist, and an independent issue-reviewer subagent (`review/issue-reviewer.md`) that re-verifies every claim against the actual code and workspace (file:line references, Anchor existence, "already done" diagnoses, "change everywhere" sweeps, scope, dedup). The skill requires explicit user approval before any `save_issue` call. In headless / pipeline mode (a subagent filing a batch straight to the execution pipeline) the per-issue reviewer and approval step are replaced by a save-hook that runs the validator at the tool boundary, so validation cannot be skipped under load.

## Why this exists

Expand All @@ -64,7 +67,7 @@ This skill is the bridge.

## Source material

The patterns were distilled from 64 real shipped issues across one well-structured Linear workspace (Web / Mobile / API monorepo). The three canonical exemplars in `references/06-canonical-examples.md` are quoted verbatim with the workspace-specific identifiers anonymized to `PROJ-N`.
The patterns were distilled from real shipped issues across well-structured Linear workspaces. The canonical exemplars in `references/06-canonical-examples.md` are quoted verbatim with the workspace-specific identifiers anonymized to `PROJ-N` (Anchor lines and runnable acceptance closers reflect the current contract; the originals predate both).

## Extensions (not built yet)

Expand Down
62 changes: 62 additions & 0 deletions hooks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Save-hook: validate a Linear issue at the tool boundary

`validate-issue-on-save.sh` is a Claude Code **PreToolUse** hook for `mcp__linear__save_issue`. It runs the same validator as the skill's Gate 1, but at the point of creation, so the check cannot be skipped: a PreToolUse hook fires for tool calls made inside subagents too, which is exactly the headless batch-filing path where the interactive gates get bypassed.

## What it does

- Fires only on **create** calls (`save_issue` with no `id`). An update is left alone, so the existing corpus of pre-Anchor issues is never re-validated or orphaned. The Anchor requirement is therefore create-only.
- Reconstructs a validator-shaped document from the create payload (synthesizes the `**Title:**` / `**Labels:**` / `**Priority:**` header lines from the Linear fields when the description does not already carry them), then runs `skill/scripts/validate_issue.py`.
- Scopes to the **build lane**: an issue whose only labels are governance labels (default `human`, `eval`) is exempt. Decision and adjudication issues are not code and do not go through the build pipeline.
- Two modes:
- `observe` (default): logs a would-block on any validator ERROR (or, on a create, a missing/prose Anchor) and **allows**. This measures the gap without blocking filing.
- `enforce`: **denies** the create with the validator's re-fileable output as the reason (`permissionDecision: deny`).
- Fails **open** on anything unexpected (no `jq`/`python3`, unparseable payload, unreadable validator). A validation gate must never brick a session on its own bug.

## Install (observe mode)

Wire it into `~/.claude/settings.json` under `hooks.PreToolUse`, matched to the save tool by name:

```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__linear__save_issue",
"hooks": [
{ "type": "command",
"command": "/ABSOLUTE/PATH/TO/linear-issue-craft/hooks/validate-issue-on-save.sh",
"timeout": 20 }
]
}
]
}
}
```

Config is env-only, so the script stays portable:

| Env var | Default | Meaning |
|---|---|---|
| `LINEAR_ISSUE_GATE_MODE` | `observe` | `observe` or `enforce` |
| `LINEAR_ISSUE_GATE_VALIDATOR` | sibling `../skill/scripts/validate_issue.py` | validator path |
| `LINEAR_ISSUE_GATE_EXEMPT` | `human,eval` | governance labels that are never gated |
| `LINEAR_ISSUE_GATE_LOG` | `$TMPDIR/linear-issue-gate.log` | would-block / decision log |

## Flipping to enforce (operator-owned)

Do **not** flip to enforce on faith. The gate blocks real creates, so first prove the validator does not false-fail the workspace's existing issues:

1. **Replay precondition.** Run the current validator over a representative sample of the workspace's existing issues, rendered in create-payload shape, and confirm **zero** create-blocking false-fails (a false-fail = a validator bug, not a genuine style violation). Only then is blocking safe.
2. Run `bash hooks/test_hook.sh` and confirm it passes.
3. Set `LINEAR_ISSUE_GATE_MODE=enforce` (e.g. in the hook's environment, or a wrapper) for **one** workspace first, watch the log for a week, and confirm `filed-count == intended-count` on a bulk run (a gap means a subagent got a `deny` and did not re-draft, the one blind spot this gate has).
4. Fan out only after the single-workspace bake is clean.

The agent that authors issues does not flip this itself; the flip is a human step, the same stance as the held-out merge gate.

## Test

```bash
bash hooks/test_hook.sh
```

Covers: observe never denies, enforce denies a bad create, enforce allows a good create, updates are ignored, a create with no Anchor is blocked in enforce, governance-only labels are exempt, and observe logs the would-block.
50 changes: 50 additions & 0 deletions hooks/test_hook.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Tests for validate-issue-on-save.sh. Feeds PreToolUse stdin payloads and asserts
# the allow/deny decision. Run: bash hooks/test_hook.sh
set -uo pipefail
HOOK="$(cd "$(dirname "$0")" && pwd)/validate-issue-on-save.sh"
LOG="$(mktemp)"
export LINEAR_ISSUE_GATE_LOG="$LOG"
pass=0; fail=0
ok() { pass=$((pass+1)); }
bad() { fail=$((fail+1)); printf ' FAIL: %s\n' "$1"; }

# Returns "deny" if the hook emitted a deny decision, else "allow".
decide() { # $1 = mode, stdin = payload
local mode="$1" out
out=$(LINEAR_ISSUE_GATE_MODE="$mode" bash "$HOOK")
if printf '%s' "$out" | grep -q '"permissionDecision": *"deny"'; then
echo deny
else
echo allow
fi
}

# Bodies with REAL newlines; jq --arg escapes them into valid JSON.
GOOD_BODY=$'**Epic:** none\n**Title:** Add an LRU cache to the search endpoint\n**Anchor:** api/src/search.ts:handleSearch\n\n## User Story\n\nAs a user, I want fast search, so results feel instant.\n\n## Problem\n\nToday /search recomputes each call.\n\n## Solution\n\nAdd an LRU cache. One module.\n\n## Out of scope\n\n* Query syntax.\n\n## Requirements\n\n1. Add cache to handleSearch.\n\n## Evaluation\n\n1. **Validates R1**: pytest -k search asserts p95 < 200ms.'
BAD_BODY=$'**Title:** Improve search performance\n\n## User Story\n\nbad'
NOANCHOR_BODY=$'## User Story\n\nAs an op, I want logs, so I can query.\n\n## Problem\n\nToday text logs.\n\n## Solution\n\nAdd pino. One module.\n\n## Out of scope\n\n* rotation\n\n## Requirements\n\n1. Add api/src/log.ts.\n\n## Evaluation\n\n1. **Validates R1**: pytest -k log passes.'

good_create() { jq -nc --arg d "$GOOD_BODY" '{tool_name:"mcp__linear__save_issue",tool_input:{title:"Add an LRU cache to the search endpoint",description:$d,labels:["api","tech-debt"],priority:3}}'; }
bad_create() { jq -nc --arg d "$BAD_BODY" '{tool_name:"mcp__linear__save_issue",tool_input:{title:"Improve search performance",description:$d,labels:["api","tech-debt"],priority:3}}'; }
bad_update() { jq -nc --arg d "$BAD_BODY" '{tool_name:"mcp__linear__save_issue",tool_input:{id:"ISS-1",title:"Improve search performance",description:$d,labels:["api","tech-debt"]}}'; }
missing_anchor() { jq -nc --arg d "$NOANCHOR_BODY" '{tool_name:"mcp__linear__save_issue",tool_input:{title:"Wire structured logging into the api",description:$d,labels:["api","tech-debt"],priority:3}}'; }
governance() { jq -nc --arg d "$BAD_BODY" '{tool_name:"mcp__linear__save_issue",tool_input:{title:"Decide bourse: wire or decommission",description:$d,labels:["human","eval"]}}'; }

assert() { # $1 = expected, $2 = actual, $3 = name
if [ "$2" = "$1" ]; then ok; else bad "$3 (expected $1, got $2)"; fi
}

assert allow "$(bad_create | decide observe)" "observe allows bad create"
assert deny "$(bad_create | decide enforce)" "enforce denies bad create"
assert allow "$(good_create | decide enforce)" "enforce allows good create"
assert allow "$(bad_update | decide enforce)" "enforce ignores updates"
assert deny "$(missing_anchor | decide enforce)" "enforce blocks missing anchor on create"
assert allow "$(governance | decide enforce)" "enforce exempts governance labels"

bad_create | LINEAR_ISSUE_GATE_MODE=observe bash "$HOOK" >/dev/null
if grep -q 'would-block(observe)' "$LOG"; then ok; else bad "observe logs would-block"; fi

printf '\n%d passed, %d failed\n' "$pass" "$fail"
rm -f "$LOG"
[ "$fail" -eq 0 ]
Loading
Loading