Skip to content
Open
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
63 changes: 63 additions & 0 deletions specs/frs/STE-375.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
title: Jira Epic milestone object — first-class Epic owning FR Tasks via parent
milestone: M101
status: active
archived_at: null
tracker:
linear: STE-375
created_at: 2026-07-09T17:12:29Z
---

# STE-375: Jira Epic milestone object — first-class Epic owning FR Tasks {#STE-375}

## Requirement

Jira milestones today are realized as `milestone-M<N>` labels smeared across every FR
issue (STE-329), so a milestone has no first-class identity: membership is reconstructed by
scanning every labelled task, and there is no object that *owns* the set. This FR gives each
Jira milestone a first-class **Epic**.

The Jira adapter creates (or finds) an Epic as the milestone object; each FR Task binds to
it via the native `parent` field, so membership is a single `parent = <epic-key>` query
rather than a label scan. `attach_project_milestone` gains a third `"epic"` binding
alongside the existing `"object"` (Linear) and `"label"` (Jira legacy). Jira
`listMilestones()` enumerates Epics (`issuetype = Epic`) — a handful of objects — instead of
paginating every labelled issue. Where a Jira template lacks the Epic type or the `parent`
field, the binding degrades to the existing `label` path so no project is left unable to
attach a milestone. Jira-only: Linear keeps its native `object` binding, `mode:none` is
vacuous.

## Acceptance Criteria

- AC-STE-375.1: **`epic` binding.** `attach_project_milestone` (`adapters/_shared/src/attach_project_milestone.ts`) accepts `milestoneBinding: "epic"` alongside `"object"`/`"label"`. The epic path find-or-creates the milestone Epic (matched by the canonical name derived from the plan-file heading per STE-118) and sets the FR Task's `parent` to the Epic's key; it never scatters a `milestone-M<N>` label. {#AC-STE-375.1}
- AC-STE-375.2: **Idempotent attach.** Re-running the epic attach on an FR whose `parent` already equals the milestone Epic's key is a no-op — no second Epic is created and the `parent` is not rewritten. Re-running when the named Epic already exists reuses it (find-before-create). {#AC-STE-375.2}
- AC-STE-375.3: **Epic-enumeration listing.** The Jira adapter's `listMilestones()` (`adapters/jira/src/list_milestones.ts`) enumerates milestone Epics via `issuetype = Epic` (JQL, paginated, client-side name filter) and returns their `M_<epic-key>` names, unioned and de-duplicated with any grandfathered `milestone-M<N>` labels — it never performs a full labelled-task scan to list milestones. {#AC-STE-375.3}
- AC-STE-375.4: **Epic-absent fallback.** When the bound Jira project's issue-type metadata (`getJiraProjectIssueTypesMetadata`) lacks the Epic type or the project cannot set `parent`, the epic binding degrades to the `"label"` binding and surfaces a `milestone_epic_unsupported` capability row (informational) rather than raising — the FR still attaches via the legacy label path. {#AC-STE-375.4}
- AC-STE-375.5: **Verify + error shape.** After attach, a read-back (`getJiraIssue`) asserts the Task's `parent` equals the Epic key; a permanent failure (create/parent-set retries on the canonical `1s + 2s + 4s` backoff exhausted, or a non-transient mismatch) raises `MilestoneAttachmentError` (NFR-10 canonical shape) with `binding: "epic"` and a binding-aware remedy. {#AC-STE-375.5}

## Technical Design

**Scope: extend the shared milestone-attach helper + the Jira adapter's list/enumeration + the Jira adapter doc. No new subagent, no skill-flow change (that is STE-377).**

**Files touched:**
- `adapters/_shared/src/attach_project_milestone.ts` — extend `MilestoneOps.milestoneBinding` to `"object" | "label" | "epic"`; add the epic branch: find-or-create Epic (`createJiraIssue issuetype=Epic`, summary = canonical plan-heading name per STE-118), set `parent` (`editJiraIssue additional_fields.parent`), read-back verify `Task.parent`; `MilestoneAttachmentError` gains `binding: "epic"` remedy text. Reuses STE-329's adapter-branch structure.
- `adapters/jira/src/list_milestones.ts` — add the `issuetype = Epic` enumeration leg; union + dedupe with the existing `^milestone-(M\d+)$` label scan (grandfathered milestones); return `{ name }[]`.
- `adapters/jira.md` — document `milestone_binding: epic`, the Epic-absent fallback, and the `milestone_epic_unsupported` capability row (Schema M + § Project Milestone).
- `adapters/_shared/src/closing_summary_capability_keys.ts` — register `milestone_epic_unsupported`.

**Why Epic is the only choice.** Epic is the sole Jira issue-type that can *parent* Task-level issues (Story/Task are peers; nesting under a Story would demote FRs to Sub-tasks), and the Atlassian MCP surface exposes no version/`fixVersion` tool — the same reason milestones became labels originally (STE-329). See § Notes.

## Testing

- `attach_project_milestone.test.ts` — epic path: find-or-create; idempotent no-op when `parent` already set; parent-set; read-back verify; `binding:"epic"` `MilestoneAttachmentError` on permanent failure.
- `list_milestones.test.ts` — Epic-enumeration returns `M_<key>` names; union + dedupe with grandfathered `milestone-M<N>` labels; pagination cap honored.
- Epic-absent fallback test — issue-type metadata without Epic → degrades to `label` binding + `milestone_epic_unsupported` row.
- `adapters/jira.md` schema-conformance test — `milestone_binding` enum includes `epic`.
- Gate: `bun test` (full gate from `plugins/dev-process-toolkit`) + `/dev-process-toolkit:gate-check` GREEN.

## Notes

- **Origin.** `/brainstorm` 2026-07-09 (design approved) → `/spec-write`. Sibling FRs: STE-376 (`M_<epic-key>` union grammar), STE-377 (Epic-first allocation). Build order: STE-376 → STE-375 → STE-377.
- **Why Epic, not Story/Version.** A Story sits at the same hierarchy level as a Task and cannot parent it; the Atlassian MCP has no version/release tool. Epic is the only reachable first-class container.
- **Self-describing membership.** Because STE-377 names the milestone `M_<epic-key>`, an FR's `milestone:` frontmatter encodes its own parent Epic key — the milestone id and `parent = <epic-key>` are the same key.
- **Grandfathering.** Existing `milestone-M<N>` label milestones are not migrated (zero installs); `listMilestones()` unions both representations so old and new coexist.
62 changes: 62 additions & 0 deletions specs/frs/STE-376.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
title: M_<epic-key> union grammar — shared milestone-token parser accepts M_<key> alongside M<N>
milestone: M101
status: active
archived_at: null
tracker:
linear: STE-376
created_at: 2026-07-09T17:12:29Z
---

# STE-376: M_<epic-key> union grammar for milestone tokens {#STE-376}

## Requirement

The shared milestone-token grammar assumes a milestone id is `M` followed by digits
(`M<N>`). Introducing Jira Epic-keyed milestones (`M_<epic-key>`, e.g. `M_PROJ_500` — see
STE-375/STE-377) breaks every parser that greps `M\d+`: plan-heading parsing,
`nextFreeMilestoneNumber`, the branch scan, Jira `listMilestones`, `/ship-milestone`, and
the milestone `/gate-check` probes.

This FR teaches a single, centralized milestone-token matcher to accept **both** `M<N>` and
`M_<epic-key>` (union grammar) and refactors every private regex copy to consume it (the
STE-335 AC-7 pattern). `M_<key>` ids are opaque and non-sequential, so they are excluded
from the `max+1` computation — they never collide (their key is unique) and never
participate in numbering. Linear and `mode:none` continue to emit `M<N>`; the grammar is
purely additive.

## Acceptance Criteria

- AC-STE-376.1: **Centralized union matcher.** A single exported milestone-token matcher recognizes both `M<N>` (`M101`) and `M_<epic-key>` (`M_PROJ_500`, `M_PROJ-500`) and rejects malformed tokens (`M`, `M_`, `Mx`, `milestone-M5`, `M5-extra`). It lives in one module (`plan_heading.ts` or a colocated `milestone_token.ts`); private ad-hoc `M\d+` copies across the codebase are removed in favor of it (STE-335 AC-7 audit). {#AC-STE-376.1}
- AC-STE-376.2: **Plan-heading parse.** `parsePlanHeading` (`adapters/_shared/src/plan_heading.ts`) parses headings whose milestone token is `M_<epic-key>`, across both `#`/`##` levels and `—`/`:` separators (STE-335), normalizing to the canonical `M_<epic-key> — <title>` form. {#AC-STE-376.2}
- AC-STE-376.3: **Sequential-scan tolerance.** `nextFreeMilestoneNumber`, `scan_branch_milestones`, and the Jira `listMilestones` token filter accept `M_<epic-key>` tokens without error and **exclude** them from the `max(M<N>) + 1` computation — an opaque `M_<key>` id is never parsed as an integer and never bumps the sequential counter. {#AC-STE-376.3}
- AC-STE-376.4: **Ship + CHANGELOG.** `/ship-milestone` (`plan_ship_stamp.ts` + `skills/ship-milestone/SKILL.md`) reads a `specs/plan/M_<epic-key>.md` milestone and stamps it exactly as it does an `M<N>` plan; the CHANGELOG milestone-ref convention accepts an `M_<epic-key>` token. {#AC-STE-376.4}
- AC-STE-376.5: **Gate-probe tolerance.** The milestone-parsing `/gate-check` probes (plan-file single-milestone, plan-filename, milestone-attach/heading) accept `M_<epic-key>` filenames and headings without emitting false-positive violations, and continue to flag genuinely malformed tokens. {#AC-STE-376.5}

## Technical Design

**Scope: one centralized matcher + a repo-wide audit refactoring every consumer to it. No tracker calls, no new subagent.**

**Files touched:**
- `adapters/_shared/src/plan_heading.ts` (or a new colocated `milestone_token.ts`) — add the `MILESTONE_TOKEN` union regex + `isMilestoneToken`/`parseMilestoneToken` exports; `parsePlanHeading` consumes it.
- `adapters/_shared/src/next_free_milestone_number.ts` — filter `M_<key>` out of the sequential union (excluded from max+1); still surface them for existence checks.
- `adapters/_shared/src/branch_milestone_scan.ts` — token regex uses the shared matcher; `M_<key>` excluded from the numeric set.
- `adapters/jira/src/list_milestones.ts` — token filter accepts both shapes (consumes STE-375's Epic-enumeration output).
- `adapters/_shared/src/plan_ship_stamp.ts` + `skills/ship-milestone/SKILL.md` — accept `M_<key>` headings/filenames.
- Milestone gate probes (`plan_file_single_milestone.ts`, the plan-filename probe, `tracker_project_milestone_attached.ts`) + `skills/gate-check/SKILL.md` — consume the shared matcher.

**Rationale.** One matcher, many consumers (STE-335 AC-7). `M_<key>` is intentionally outside the sequential-numbering domain: numbering governs only `M<N>`, and Jira collision-freedom comes from the unique Epic key (STE-377), not from a sequential counter.

## Testing

- `milestone_token.test.ts` (or extend `plan_heading.test.ts`) — accepts `M101`, `M_PROJ_500`, `M_PROJ-500`; rejects `M`, `M_`, `Mx`, `milestone-M5`, `M5-extra`.
- `plan_heading.test.ts` — `M_<key>` heading across `#`/`##` + `—`/`:`, normalized to canonical.
- `next_free_milestone_number.test.ts` — a mixed set `{M100, M_PROJ_500}` yields `next = 101` (the `M_<key>` excluded from max).
- Gate-probe tests — `M_<key>` filenames/headings pass; malformed tokens still fail.
- Gate: `bun test` (full gate) + `/dev-process-toolkit:gate-check` GREEN.

## Notes

- **Origin.** `/brainstorm` 2026-07-09. FR-A (STE-375) and FR-C (STE-377) depend on this grammar; build order: STE-376 → STE-375 → STE-377.
- **Union, not migration.** `M<N>` remains fully valid (Linear + mode:none + grandfathered Jira labels + this repo's own M1–M101). `M_<key>` is additive, Jira-only.
- **Non-sequential by design.** Excluding `M_<key>` from max+1 is correct precisely because its uniqueness/collision-freedom comes from the tracker key (STE-377), not a sequential counter.
60 changes: 60 additions & 0 deletions specs/frs/STE-377.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
title: Epic-first milestone allocation in /spec-write — collision-free M_<epic-key> via claim-on-create
milestone: M101
status: active
archived_at: null
tracker:
linear: STE-377
created_at: 2026-07-09T17:12:29Z
---

# STE-377: Epic-first milestone allocation in /spec-write {#STE-377}

## Requirement

`/spec-write` allocates a milestone number by computing `max(existing) + 1` from a five-way
scan (STE-284/338/339) — which both requires pulling milestone data to derive the next
number and *races* when two `/spec-write` runs allocate concurrently (each sees the same max
and picks the same `M<N>`).

In Jira mode this FR replaces derive-then-name with **claim-on-create**: `/spec-write`
creates the milestone Epic first (STE-375) and mints the milestone id `M_<epic-key>` directly
from the Epic's unique, tracker-allocated key — collision-free by construction (two
concurrent runs get two distinct Epic keys → two distinct ids, no lock, no reconcile) and
with no scan to allocate. The Epic is created before the FR tickets so each FR Task can set
`parent`. Linear and `mode:none` are unchanged: they keep the sequential
`nextFreeMilestoneNumber` `M<N>` path.

## Acceptance Criteria

- AC-STE-377.1: **Epic-first, key-derived id.** In Jira mode, when `/spec-write` needs a new milestone, it creates the milestone Epic first and derives the milestone id as `M_<epic-key>` — the Epic key sanitized to a filesystem/label-safe token (`PROJ-500` → `M_PROJ_500`) — **before** any plan or FR file is written and before `nextFreeMilestoneNumber` would run. {#AC-STE-377.1}
- AC-STE-377.2: **Collision-free by construction.** Two concurrent Jira `/spec-write` milestone allocations never collide: each Epic receives a distinct tracker-assigned key, so the derived `M_<epic-key>` ids are distinct with no lock, reconcile, or retry. (Test simulates two allocations against distinct Epic keys and asserts distinct ids + that `nextFreeMilestoneNumber` is not called on the Jira path.) {#AC-STE-377.2}
- AC-STE-377.3: **FR binding + self-describing membership.** Each FR authored under a Jira Epic milestone gets `milestone: M_<epic-key>` in frontmatter and its Task bound to the Epic via `parent` (STE-375); membership is queryable as `parent = <epic-key>`, and the milestone id encodes its own parent key. {#AC-STE-377.3}
- AC-STE-377.4: **Linear + mode:none unchanged.** In Linear mode and `mode:none`, milestone allocation is byte-unchanged: `nextFreeMilestoneNumber`'s five-way scan yields the next sequential `M<N>` (any `M_<key>` tokens present are excluded per STE-376), and no Epic is created off the Jira path. {#AC-STE-377.4}
- AC-STE-377.5: **Plan file + ship-ready.** A Jira milestone's plan file is written at `specs/plan/M_<epic-key>.md` with a canonical `# M_<epic-key> — <title>` heading (parsed by STE-376) that `/ship-milestone` can later stamp. {#AC-STE-377.5}

## Technical Design

**Scope: a Jira-mode branch in `/spec-write`'s milestone-allocation step + a small id sanitizer. Reuses STE-375's Epic create/attach and STE-376's grammar.**

**Files touched:**
- `skills/spec-write/SKILL.md` — the milestone-allocation step (§ plan / § 0b) gains a Jira-mode branch: create the Epic (via STE-375's attach helper) → derive `M_<epic-key>` → use it as `spec.milestone`; bypass `nextFreeMilestoneNumber` for the Jira-new-milestone case. Order: Epic before FR tickets so Tasks can `parent` to it.
- `adapters/_shared/src/attach_project_milestone.ts` — surface the Epic key from the epic-binding create so `/spec-write` can derive the id (STE-375 provides the create; this wires the returned key into id derivation).
- A small `milestoneIdFromEpicKey(key)` sanitizer (colocated with STE-376's `milestone_token.ts`) — `PROJ-500` → `M_PROJ_500`; round-trips with the STE-376 matcher.
- `adapters/_shared/src/next_free_milestone_number.ts` — the Jira-new-milestone path is not routed through the sequential scan (Linear/mode:none unchanged).

**Rationale.** The Epic key is the collision-free identity (the `/brainstorm` decision). Claim-on-create means the tracker's unique-key allocation replaces derive-then-name, eliminating both the scan cost and the concurrent-allocation race in one move.

## Testing

- `/spec-write` allocation contract test (Jira mode): Epic created first; `M_<epic-key>` derived from the key; no `nextFreeMilestoneNumber` call; plan file at `specs/plan/M_<key>.md`.
- Collision test: two allocations with Epic keys `PROJ-500`/`PROJ-501` → `M_PROJ_500`/`M_PROJ_501`, distinct, no lock.
- Linear + mode:none regression: allocation still sequential `M<N>` via the five-way scan; no Epic created.
- `milestoneIdFromEpicKey` round-trip with the STE-376 matcher.
- Gate: `bun test` (full gate) + `/dev-process-toolkit:gate-check` GREEN.

## Notes

- **Origin.** `/brainstorm` 2026-07-09 — the user chose `M_<epic-key>` (self-describing, no `M93 ↔ key` mapping) over key-rank-`M93` after weighing the parser-rewrite cost. Depends on STE-375 (Epic create/attach) + STE-376 (grammar).
- **The one accepted cost.** The sequential human milestone number is dropped for Jira milestones (they read by creation order, not a tidy sequence); Linear + mode:none keep `M<N>`.
- **`mode:none`.** No key → keeps `max+1` (single-repo). The race is a multi-dev *tracker* problem, gone on the Jira path.
Loading