diff --git a/specs/frs/STE-375.md b/specs/frs/STE-375.md new file mode 100644 index 0000000..0196b70 --- /dev/null +++ b/specs/frs/STE-375.md @@ -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` 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 = ` 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` 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_` names, unioned and de-duplicated with any grandfathered `milestone-M` 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_` names; union + dedupe with grandfathered `milestone-M` 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_` 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_`, an FR's `milestone:` frontmatter encodes its own parent Epic key — the milestone id and `parent = ` are the same key. +- **Grandfathering.** Existing `milestone-M` label milestones are not migrated (zero installs); `listMilestones()` unions both representations so old and new coexist. diff --git a/specs/frs/STE-376.md b/specs/frs/STE-376.md new file mode 100644 index 0000000..b2929d1 --- /dev/null +++ b/specs/frs/STE-376.md @@ -0,0 +1,62 @@ +--- +title: M_ union grammar — shared milestone-token parser accepts M_ alongside M +milestone: M101 +status: active +archived_at: null +tracker: + linear: STE-376 +created_at: 2026-07-09T17:12:29Z +--- + +# STE-376: M_ union grammar for milestone tokens {#STE-376} + +## Requirement + +The shared milestone-token grammar assumes a milestone id is `M` followed by digits +(`M`). Introducing Jira Epic-keyed milestones (`M_`, 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` and +`M_` (union grammar) and refactors every private regex copy to consume it (the +STE-335 AC-7 pattern). `M_` 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`; the grammar is +purely additive. + +## Acceptance Criteria + +- AC-STE-376.1: **Centralized union matcher.** A single exported milestone-token matcher recognizes both `M` (`M101`) and `M_` (`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_`, across both `#`/`##` levels and `—`/`:` separators (STE-335), normalizing to the canonical `M_` 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. diff --git a/specs/frs/STE-377.md b/specs/frs/STE-377.md new file mode 100644 index 0000000..cf977ea --- /dev/null +++ b/specs/frs/STE-377.md @@ -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. diff --git a/specs/plan/M101.md b/specs/plan/M101.md new file mode 100644 index 0000000..10c235c --- /dev/null +++ b/specs/plan/M101.md @@ -0,0 +1,57 @@ +--- +milestone: M101 +status: active +archived_at: null +kickoff_branch: feat/ste-375-jira-milestone-as-epic +frozen_at: null +--- + +# Implementation Plan + +## Milestone Order + +<!-- Each milestone gets its own `specs/plan/M<N>.md` file; don't proceed to M<N+1> until M<N> gates pass. --> + +## M101: Lineage — Jira milestone-as-Epic identity {#M101} + +**Goal:** Give Jira milestones a first-class identity instead of scattered +`milestone-M<N>` labels. Each milestone becomes a Jira **Epic** that owns its FR Tasks via +`parent` (membership = a `parent = <epic-key>` query, not a label scan); the milestone is +identified by `M_<epic-key>` — collision-free by construction (the Epic's unique tracker key) +with no scan to allocate; and the shared milestone-token grammar learns to accept `M_<key>` +alongside legacy `M<N>`. This closes the two pains from the `/brainstorm`: concurrent +`/spec-write` runs allocating duplicate milestone numbers, and the full-task scan required to +derive the next number. **Jira-only**: Linear keeps its native milestone objects and +`mode:none` keeps sequential `M<N>`. + +**Prerequisites:** v2.43.0 shipped (main @ PR #35). Load-bearing existing machinery: +STE-329 (`milestone_binding` adapter-branch: `object`/`label` — extended here with `epic`), +STE-118 (canonical name from plan heading + `MilestoneAttachmentError` NFR-10 shape), +STE-335 (centralized `plan_heading.ts` parser + the "kill private regex copies" audit +pattern), STE-339 (Jira `listMilestones` JQL-enumeration pattern — retargeted to +`issuetype = Epic`), STE-284/338 (`nextFreeMilestoneNumber` five-way scan — Linear/mode:none +path preserved). No new external dependencies. **Architectural constraints:** the Atlassian +MCP surface exposes no version/`fixVersion` tool, and a Jira Story cannot parent a Task — so +**Epic** is the only reachable first-class milestone object. The local plan file remains the +source of truth (specs stay tracker-agnostic); `mode:none` is vacuous on every Epic path. + +**Release target:** v2.44.0 "Lineage". + +**FR list:** + +| FR | Title | Tracker | +|----|-------|---------| +| STE-375 | Jira Epic milestone object — first-class Epic owning FR Tasks via parent | linear:`STE-375` | +| STE-376 | `M_<epic-key>` union grammar — shared milestone-token parser accepts `M_<key>` alongside `M<N>` | linear:`STE-376` | +| STE-377 | Epic-first milestone allocation in /spec-write — collision-free `M_<epic-key>` via claim-on-create | linear:`STE-377` | + +**Dependency order:** STE-376 → STE-375 → STE-377. The union grammar (STE-376) is +foundational — the Epic object (STE-375) emits `M_<key>` names and the allocation path +(STE-377) mints them, and both must flow through a grammar that already accepts the token. +STE-375 supplies the Epic create/attach that STE-377's claim-on-create consumes. + +**Definition of Done:** +- STE-375: `attach_project_milestone` `epic` binding (find-or-create Epic + `parent` set + read-back verify + `binding:"epic"` `MilestoneAttachmentError`); Jira `listMilestones` Epic-enumeration + grandfather-union; Epic-absent fallback → `label` + `milestone_epic_unsupported`; `adapters/jira.md` documents `milestone_binding: epic`. +- STE-376: centralized union matcher (accepts `M<N>` + `M_<key>`, rejects malformed) with private-regex-copies removed; `parsePlanHeading` + `nextFreeMilestoneNumber` + `scan_branch_milestones` + `/ship-milestone` + milestone gate probes consume it; `M_<key>` excluded from max+1. +- STE-377: Jira-mode Epic-first allocation in `/spec-write` (Epic before FR tickets, `M_<epic-key>` derived from key, `nextFreeMilestoneNumber` bypassed on the Jira path); `milestoneIdFromEpicKey` sanitizer; Linear + mode:none allocation byte-unchanged. +- Gate: `bun test` (full gate from `plugins/dev-process-toolkit`) + `/dev-process-toolkit:gate-check` GREEN on this repo; all 15 ACs traced to file:line + test:line by `/spec-review` (or `/tdd` AUDIT).