diff --git a/design/SKILL.md b/design/SKILL.md index 3171ea12..3c28fcb0 100644 --- a/design/SKILL.md +++ b/design/SKILL.md @@ -1,6 +1,6 @@ --- name: design -version: 0.14.0 +version: 0.15.0 description: >- Design-and-decompose workflow that takes a PRD, researches the problem space, drafts a technical design document with a requirement-anchored testplan, diff --git a/design/decomposition-review.md b/design/decomposition-review.md index f69db566..cd2546c6 100644 --- a/design/decomposition-review.md +++ b/design/decomposition-review.md @@ -63,19 +63,33 @@ expected and should not be flagged as a clarity problem. ## Evaluation Criteria Evaluate the decomposition against these categories, prioritized by -impact: +impact. Coverage and completeness checks (criteria 1–8) apply to +effectively active artifacts only. A story is effectively active +when it has no `status: removed` marker and its parent epic is also +active. An epic is effectively active when it has no `status: +removed` marker. Epic-level checks (criteria 2, 4) evaluate active +epics; story-level checks evaluate effectively active stories. +Retained removed files are available for identity and traceability +audit (criterion 9) but do not satisfy or violate active coverage +requirements. 1. **Requirement Coverage** — Does the coverage matrix (`07-coverage.md`) - map every FR and NFR from the PRD? Do story acceptance criteria + map every FR and NFR from the PRD to effectively active stories? A + story is effectively active when it has no `status: removed` marker + and its parent epic is also active; stories with `status: removed` + and unmarked children of removed epics are retained for identity + audit but do not satisfy coverage. Do story acceptance criteria collectively cover the PRD's acceptance criteria, not just the FR/NFR descriptions? Are there stories without PRD traceability — and if so, are they justified (e.g., infrastructure prerequisites)? Has any requirement been silently dropped, deferred to "v2", or reduced in scope? -2. **Epic Structure** — Is each epic organized around user value, not - technical layers? Does each epic deliver complete functionality - independently? Are T-shirt sizes assigned and consistent with the sizing rubric +2. **Epic Structure** — Evaluate active epics only (those with no + `status: removed` marker). Is each active epic organized around + user value, not technical layers? Does each active epic deliver + complete functionality independently? Are T-shirt sizes assigned + and consistent with the sizing rubric (`../_shared/sizing-rubric.md`)? Does each epic's size match its scope — story count, component breadth, integration surface? Are any epics suspiciously uniform in size when their scope clearly varies? No epic @@ -99,10 +113,10 @@ impact: functionality AND testing (or, for a justified behavior-preserving prerequisite, preserved-behavior tests)? Do `[QE]` stories define concrete e2e test scenarios that validate user-facing behavior, not duplicate - unit/integration tests from `[DEV]` stories? Does each epic with - e2e-testable surface include at least one `[QE]` story? Does any - story fall back to "manual validation" or "document procedure for QE" - where an automated test could be written? + unit/integration tests from `[DEV]` stories? Does each active epic + with e2e-testable surface include at least one `[QE]` story? + Does any story fall back to "manual validation" or "document + procedure for QE" where an automated test could be written? 5. **Implementation Traceability** — Do stories correctly reference the interface changes they implement and the test cases that validate @@ -144,15 +158,39 @@ impact: as gaps. Do not reject a legitimate prerequisite for adding no new feature behavior — that is its nature. -8. **Documentation** — Do `[DOCS]` stories use the `[DOCS]` template - (Documentation Scope and Documentation Inputs instead of - Implementation Guidance and Testing Approach)? Does the Documentation - Scope describe what the reader needs to understand without prescribing - how to write or organize the documentation? Does the Documentation - Inputs section inventory all user-facing artifacts from the - implementation stories it references? Is the inventory detailed enough - for a documentation author to know what to look for in the - implementation? +8. **Documentation** — Do `[DOCS]` stories satisfy the `[DOCS]` story + quality criteria from the decompose skill that the reviewer can + evaluate from its available inputs (PRD and decomposition artifacts)? + Specifically: does the title include searchable product/feature/ + command terms (not just `[DOCS] Feature documentation`)? Does the + persona/goal/benefit name the actual reader and a concrete task? Are + acceptance criteria reader-verifiable documentation outcomes (not + implementation artifacts)? Does the Documentation Scope describe what + the reader needs to understand without prescribing how to write or + organize the documentation? Does the Documentation Inputs section + inventory user-facing artifacts from the contributing `[DEV]`/`[UI]` + stories? Are `[Assumption]` and `[Open: §9.N]` markers present on + items that appear speculative or unresolved? Does the Design + Reference include a `Contributing stories` line consistent with the + Documentation Inputs headers? Are the contributing story references + complete? Note: the reviewer cannot verify whether unmarked + Documentation Inputs items are truly derived from the design + document or PRD — that provenance check belongs to the decomposer + (see the provenance verification in `/decompose`). Flag items that + appear speculative or unresolved from the PRD and artifact context + alone. + +9. **Identity consistency** — Are story and epic IDs, filenames, + and directory paths self-consistent across the decomposition + artifacts? Do Dependencies, Contributing stories, Documentation + Inputs, and the coverage matrix reference story IDs that exist in + the actual artifact files? Do any instructions or comments + explicitly propose renaming, renumbering, moving, or reusing a + story/epic identity? Flag proven inconsistencies (e.g., a + referenced Story 2.03 with no corresponding file) or explicit + rename/reuse proposals. Absence of historical baseline does not + prove identity violation — do not invent allocation history or + require the sync manifest. ## Finding Format @@ -179,9 +217,9 @@ Reference section. | Severity | Meaning | Action | |----------|---------|--------| -| CRITICAL | Would cause incorrect implementation — missing requirements, contradictory acceptance criteria, broken dependency chain, scope reduction, stories referencing non-existent ICs or TCs, ICs with no implementing story, prerequisite story without justification or dependency declaration | Must fix before presenting | -| HIGH | Would cause rework — story too large to review in a single PR, missing testing commitment, integration gap between stories, `[QE]` story duplicating `[DEV]` test scope, stories missing Interface Changes or Validated by references, prerequisite story missing preserved-behavior tests | Should fix before presenting | -| MEDIUM | Would reduce clarity — vague acceptance criteria, imprecise implementation guidance or documentation scope, sizing concerns, minor coverage matrix inconsistencies, minor IC or TC reference inconsistencies | Fix if it adds value | +| CRITICAL | Would cause incorrect implementation — missing requirements, contradictory acceptance criteria, broken dependency chain, scope reduction, stories referencing non-existent ICs or TCs, ICs with no implementing story, prerequisite story without justification or dependency declaration, `[DOCS]` story missing contributing stories or with Documentation Inputs that omit user-facing artifacts from referenced implementation stories | Must fix before presenting | +| HIGH | Would cause rework — story too large to review in a single PR, missing testing commitment, integration gap between stories, `[QE]` story duplicating `[DEV]` test scope, stories missing Interface Changes or Validated by references, prerequisite story missing preserved-behavior tests, `[DOCS]` story with generic title or unidentified reader/task, explicit proposal to rename, renumber, move, or reuse a story/epic identity that would break sync idempotency | Should fix before presenting | +| MEDIUM | Would reduce clarity — vague acceptance criteria, imprecise implementation guidance or documentation scope, sizing concerns, minor coverage matrix inconsistencies, minor IC or TC reference inconsistencies, `[DOCS]` story with Documentation Inputs items that appear speculative or unresolved from PRD and artifact context but lack `[Assumption]` or `[Open: §9.N]` markers (items verifiable against the PRD need no marker; the decomposer verifies design-derived provenance separately in the provenance sub-step of Step 9) | Fix if it adds value | | LOW | Structural improvements — naming consistency, ordering suggestions, documentation clarity | Fix only if clearly valuable | ## Validation Rules diff --git a/design/guidelines.md b/design/guidelines.md index ac284374..cac5683b 100644 --- a/design/guidelines.md +++ b/design/guidelines.md @@ -30,6 +30,8 @@ artifacts and published output from this workflow. - No Jira modifications without explicit user approval and a dry-run preview first. - **No scope reduction.** Never silently simplify, defer to "v2", use "placeholder", or say "future enhancement" to reduce scope. If scope won't fit, propose a split — don't reduce. - Locked decisions from PRD clarification are binding. No phase may contradict a locked decision without explicit user override. +- **Identity stability.** Once an epic or story file is created, its number, filename, and containing path are fixed. Do not rename, renumber, or move artifact files. New stories append above the highest existing number in their epic (including removed files); new epics append above the highest existing epic number. Gaps are allowed; retired numbers must not be reused. Title and content may change without renaming the file. +- **Retirement encoding.** Retirement is encoded as `status: removed` in YAML frontmatter — the `---`-delimited block at the very start of the file. The sync helper's `read_frontmatter` only recognizes this format; a plain Markdown body line `status: removed` is not detected. Merge into existing frontmatter without clobbering other fields; add a valid `---` block before the first heading if none exists. - No committing to `main` directly. Use feature branches for `/publish`. ## Safety diff --git a/design/references/sync-helper-usage.md b/design/references/sync-helper-usage.md index a1c5b7e9..a2679d12 100644 --- a/design/references/sync-helper-usage.md +++ b/design/references/sync-helper-usage.md @@ -42,6 +42,9 @@ your responsibility. | `render-description --type epic\|story [options]` | Render Jira-ready description with resolved refs | | `update-manifest --operation create\|update\|close --file --jira-key --content-hash [--item-type epic\|story] [--epic-file ] [options]` | Record one successful Jira operation (`--epic-file` required for stories) | | `compute-hash ` | Print SHA-256 of file content | +| `plan-creation-order ` | Plan dependency-first creation order for new stories; output JSON with `ordered`, `blocked`, `errors`, `satisfied_keys`. Exit 0 if plan is valid; exit 1 if `errors` is non-empty (JSON is still printed). Fatal I/O or manifest errors also exit 1 but print only to stderr with no JSON. | +| `check-ref-availability ...` | Check whether stories' referenced stories/epics have Jira keys; output JSON with `ready` and `blocked` lists. Exit 0 if all stories are ready; exit 1 if any story is blocked (JSON is still printed). Fatal manifest/I/O errors also exit 1 with stderr only and no JSON. | +| `compute-cascade-closures [--close-orphans]` | Compute cascade child closures for parent epic closures (Step 3 preview). Output JSON with `cascades` list: manifest-active children under closing parents not already individually categorized as removed. Add `--close-orphans` when user selected option (a) for orphans in Step 1. | | `migrate-manifest ` | Upgrade v1 manifest to v2 in place | The `prepare` subcommand is preferred over separate `compute-hash` + @@ -60,10 +63,12 @@ Jira keys from the manifest: - `Stories 1.01, 2.01, 3.01` → resolve each → `EDM-XXXX, EDM-AAAA, EDM-BBBB` The manifest is built incrementally: each issue's key is recorded -immediately after creation. Since epics are created first and stories -are created in epic order (Epic 1 stories before Epic 2 stories), -dependencies — which reference earlier stories — are always resolvable -by the time they're needed. +immediately after creation. Epics are created first (Step 5), then +all new stories are created in a feature-wide dependency-first order +planned by `plan-creation-order` (Step 6a) — each story's required +keys are available from the manifest by the time it is created. The +local numeric identifiers (Epic 1, Story 1.01) are +stable artifact identities, not creation readiness indicators. If a reference cannot be resolved (the referenced item hasn't been created yet or failed to create), the helper leaves the local @@ -213,3 +218,59 @@ select the terminal/done-category transition. If multiple terminal transitions are available, prefer "Done" over "Closed." If no terminal transition is available from the current status, report the error and let the user handle it in Jira. + +## Reference-Availability Guard + +Before calling `prepare` for a story in Steps 6a or 6b, verify that +every required reference has a Jira key. Run `check-ref-availability` +with the story's qualified ID: + +```bash +REF_ERR=$(mktemp) || { echo "FATAL: cannot create temp file" >&2; exit 1; } +REF_EXIT=0 +REF_CHECK=$(python3 "$SYNC_HELPER" check-ref-availability \ + .artifacts/design/{issue-key}/ \ + "story-{N}.{NN}" 2>"$REF_ERR") || REF_EXIT=$? +REF_ERR_TEXT=$(cat "$REF_ERR") +rm -f "$REF_ERR" +``` + +The `|| REF_EXIT=$?` idiom captures a nonzero exit without aborting +under `set -e`. The two lines after the helper call read stderr text +and remove the temp file immediately — before branch interpretation +— so cleanup cannot be forgotten on any exit path. Apply the +three-branch interpretation from `../skills/sync.md` Step 6a (ready → proceed; +blocked → skip; catch-all → stop and report `REF_ERR_TEXT`). + +The subcommand accepts one or more qualified IDs. For each story it: + +1. Reloads the manifest fresh from `sync-manifest.json` +2. Reads the story's artifact file and extracts Dependencies (Story and + Epic refs) and Contributing stories (for `[DOCS]` stories only) +3. Checks that native Jira keys are treated as available (external) +4. Verifies the owning epic (`epic-{N}`) has a Jira key in the manifest +5. Returns JSON with `ready` and `blocked` lists + +**Exit code:** 0 when all stories are ready; 1 when any story is +blocked. + +**JSON output:** + +```json +{ + "ready": [ + {"qid": "story-1.01", "refs": {"story-2.01": "EDM-201"}} + ], + "blocked": [ + {"qid": "story-1.02", "missing": ["epic-3"], "reason": "..."} + ] +} +``` + +**Invariants:** + +- Never mutates the manifest or source files +- Contributing stories affect creation ORDER but do NOT add native Jira + blocking relationships +- Keys recorded by `update-manifest` earlier in this run are immediately + visible (manifest is reloaded from disk on each call) diff --git a/design/scripts/sync_helper.py b/design/scripts/sync_helper.py index 49aa4810..836c0ba4 100644 --- a/design/scripts/sync_helper.py +++ b/design/scripts/sync_helper.py @@ -19,6 +19,15 @@ migrate-manifest Upgrade a v1 manifest to v2 schema in place. prepare Compute hash and render description in one call, returning both from a single file read. + plan-creation-order Plan dependency-first creation order for new + stories; output JSON with ``ordered``, ``blocked``, + ``errors``, ``satisfied_keys``. + check-ref-availability Check whether stories' referenced + stories/epics have Jira keys; output JSON + with ``ready`` and ``blocked`` lists. + compute-cascade-closures Compute cascade child closures that + parent epic closures will perform; + output JSON with ``cascades`` list. Environment variables: None required. All inputs come from command-line arguments and local @@ -26,7 +35,12 @@ Exit codes: 0 -- success - 1 -- runtime error (missing file, invalid manifest, I/O failure) + 1 -- runtime error (missing file, invalid manifest, I/O failure); + also used by plan-creation-order and check-ref-availability + for structured invalid/blocked results (JSON is still printed + to stdout in that case). Callers must check whether stdout + contains valid JSON to distinguish structured errors from + fatal failures (which print only to stderr). 2 -- invalid arguments (from argparse) """ @@ -524,6 +538,7 @@ def detect_changes(artifacts_dir: str | Path) -> dict[str, Any]: "content_hash": compute_file_hash(ef), "old_hash": None, "synced_status": None, + "source_active": True, }) for sf in story_files: @@ -546,6 +561,7 @@ def detect_changes(artifacts_dir: str | Path) -> dict[str, Any]: "content_hash": compute_file_hash(sf), "old_hash": None, "synced_status": None, + "source_active": epic_idx is not None, }) summary = _compute_summary(items) @@ -599,6 +615,7 @@ def detect_changes(artifacts_dir: str | Path) -> dict[str, Any]: "content_hash": current_hash, "old_hash": m_hash, "synced_status": m_status, + "source_active": not removed, }) else: if removed: @@ -629,6 +646,7 @@ def detect_changes(artifacts_dir: str | Path) -> dict[str, Any]: "content_hash": current_hash, "old_hash": None, "synced_status": None, + "source_active": True, }) # Build set of epic indices whose epics are effectively removed/closed. @@ -762,6 +780,8 @@ def detect_changes(artifacts_dir: str | Path) -> dict[str, Any]: "content_hash": current_hash, "old_hash": m_hash, "synced_status": m_status, + "source_active": (not removed and not parent_removed + and epic_idx is not None), }) else: if removed or parent_removed: @@ -777,6 +797,7 @@ def detect_changes(artifacts_dir: str | Path) -> dict[str, Any]: "content_hash": compute_file_hash(sf), "old_hash": None, "synced_status": None, + "source_active": True, }) # Orphaned manifest entries: active entries with no file on disk @@ -794,6 +815,7 @@ def detect_changes(artifacts_dir: str | Path) -> dict[str, Any]: "content_hash": None, "old_hash": epic_entry.get("content_hash"), "synced_status": m_status, + "source_active": False, }) for epic_file, stories_map in story_lookup.items(): @@ -815,6 +837,7 @@ def detect_changes(artifacts_dir: str | Path) -> dict[str, Any]: "content_hash": None, "old_hash": story_entry.get("content_hash"), "synced_status": m_status, + "source_active": False, }) summary = _compute_summary(items) @@ -863,6 +886,826 @@ def _compute_summary(items: list[dict[str, Any]]) -> dict[str, int]: return counts +# --------------------------------------------------------------------------- +# Cascade closure preview +# --------------------------------------------------------------------------- + + +def compute_cascade_closures( + items: list[dict[str, Any]], + manifest: dict[str, Any] | None, + close_orphans: bool = False, +) -> dict[str, Any]: + """Compute child closures that parent epic closures will cascade. + + For each epic being closed (category ``"removed"``, or ``"orphaned"`` + when *close_orphans* is True), finds manifest-active child stories + that are not already individually categorized as closing in *items*. + These are the cascade closures that Step 5c will perform but that + the detect-changes plan alone does not surface. + + Returns a dict with: + + - ``cascades``: list of cascade closure dicts. Each entry has + ``file``, ``jira_key``, ``title``, ``parent_epic_file``, + ``parent_jira_key``, and ``parent_reason``. + + The caller uses this to include cascade closures in the Step 3 + preview, adjust To Close and Unchanged counts, and ensure the + approval covers all operations Step 5c will perform. + """ + if manifest is None: + return {"cascades": []} + + # Identify closing parent epics + closing_parents: dict[str, str] = {} # epic_file -> reason + for item in items: + if item.get("type") != "epic": + continue + cat = item.get("category", "") + if cat == "removed": + closing_parents[item["file"]] = "removed" + elif cat == "orphaned" and close_orphans: + closing_parents[item["file"]] = "orphaned" + + if not closing_parents: + return {"cascades": []} + + # Build set of story files already individually closing + individually_closing: set[str] = set() + for item in items: + if item.get("type") != "story": + continue + cat = item.get("category", "") + if cat == "removed": + individually_closing.add(item["file"]) + elif cat == "orphaned" and close_orphans: + individually_closing.add(item["file"]) + + # Build reverse lookup: (epic_index, story_filename) → actual + # qualified file from items (matches detect_changes identity). + _item_files: dict[tuple[int | None, str], str] = {} + for item in items: + if item.get("type") != "story": + continue + eidx = item.get("epic_index") + parts = item["file"].rsplit("/", 1) + story_fname = parts[-1] if len(parts) > 1 else item["file"] + _item_files[(eidx, story_fname)] = item["file"] + + # Find cascade closures from manifest + cascades: list[dict[str, Any]] = [] + errors: list[str] = [] + for epic in manifest.get("epics", []): + epic_file = epic.get("file", "") + if epic_file not in closing_parents: + continue + epic_idx = _parse_epic_index(epic_file) + parent_jira_key = epic.get("jira_key") or "" + if not parent_jira_key: + errors.append( + f"closing parent epic '{epic_file}' has no Jira key " + f"in manifest — cannot identify cascade operations") + continue + for story in epic.get("stories", []): + # Use same default as detect_changes (.get with "active") + if story.get("synced_status", "active") != "active": + continue # Already closed in manifest + story_file = story.get("file", "") + child_jira_key = story.get("jira_key") or "" + if not child_jira_key: + errors.append( + f"manifest child '{story_file}' under " + f"'{epic_file}' has no Jira key — cannot " + f"identify cascade close operation") + continue + # Use actual detect_changes path when available; + # fall back to reconstruction for orphaned children + qualified_file = _item_files.get( + (epic_idx, story_file), + f"epic-{epic_idx}/{story_file}" + if epic_idx is not None + else f"{epic_file.replace('.md', '')}/{story_file}") + + if qualified_file in individually_closing: + continue # Already counted as to-close + + cascades.append({ + "file": qualified_file, + "jira_key": child_jira_key, + "title": story.get("title", ""), + "parent_epic_file": epic_file, + "parent_jira_key": parent_jira_key, + "parent_reason": closing_parents[epic_file], + }) + + result: dict[str, Any] = {"cascades": cascades} + if errors: + result["errors"] = errors + return result + + +# --------------------------------------------------------------------------- +# Creation-order planning +# --------------------------------------------------------------------------- + + +def _extract_story_refs(text: str) -> list[str]: + """Extract qualified story references (e.g. '1.01') from text. + + Matches the same ``Story N.NN`` / ``Stories N.NN, M.MM`` syntax + used by ``resolve_refs``. + """ + refs: list[str] = [] + for m in _STORY_REF_RE.finditer(text): + for part in m.group(1).split(","): + part = part.strip() + if part: + refs.append(part) + return refs + + +def _extract_epic_refs(text: str) -> list[str]: + """Extract epic index references (e.g. '1', '2') from text. + + Matches the same ``Epic N`` / ``Epics N, M`` syntax used by + ``resolve_refs``. Returns integer strings like ``['1', '2']``. + """ + refs: list[str] = [] + for m in _EPIC_REF_RE.finditer(text): + for part in m.group(1).split(","): + part = part.strip() + if part: + refs.append(part) + return refs + + +# Pattern: native Jira issue keys (e.g. "PROJ-123", "EDM-456") +_JIRA_KEY_RE = re.compile(r"\b([A-Z][A-Z0-9]+-\d+)\b") + + +def extract_required_refs( + filepath: Path, + story_title: str = "", +) -> dict[str, Any]: + """Extract required refs from a story artifact file. + + Reads the file at *filepath*, extracts Dependencies (Story and Epic + refs) and Contributing stories (DOCS only), and identifies native + Jira keys. + + Returns a dict with: + - ``refs``: list of qualified ref IDs (``story-N.NN``, ``epic-N``) + - ``native_keys``: list of native Jira keys found (e.g. ``PROJ-123``) + - ``error``: str or None — set on file-not-found or read failure + + The *story_title* is used to detect ``[DOCS]`` type for Contributing + stories extraction. If empty, the title is read from the file's + ``# ...`` heading. + """ + result: dict[str, Any] = {"refs": [], "native_keys": [], "error": None} + try: + content = filepath.read_text(encoding="utf-8") + except OSError as exc: + result["error"] = f"cannot read artifact {filepath}: {exc}" + return result + + sections = _extract_sections_from_text(content) + refs: list[str] = [] + native_keys: list[str] = [] + + deps_text = sections.get("Dependencies", "") + if deps_text: + for sref in _extract_story_refs(deps_text): + refs.append(f"story-{sref}") + for eref in _extract_epic_refs(deps_text): + refs.append(f"epic-{eref}") + for jm in _JIRA_KEY_RE.finditer(deps_text): + native_keys.append(jm.group(1)) + + # Detect story type for Contributing-stories extraction + if not story_title: + for cline in content.splitlines(): + cs = cline.strip() + if cs.startswith("# "): + story_title = cs[2:].strip() + break + m = re.search(r"\[(\w+)\]", story_title) + story_type = (m.group(1) if m else "").upper() + if story_type == "DOCS": + design_ref = sections.get("Design Reference", "") + for line in design_ref.splitlines(): + stripped = line.strip() + # Normalize supported Markdown list-item prefixes + # (``- ``, ``* ``, ``+ ``) so a line like + # ``- Contributing stories: Story 1.02`` is not ignored. + for list_prefix in ("- ", "* ", "+ "): + if stripped.startswith(list_prefix): + stripped = stripped[len(list_prefix):].strip() + break + if stripped.startswith("Contributing stories:"): + contrib_text = stripped.split(":", 1)[1] + for sref in _extract_story_refs(contrib_text): + refs.append(f"story-{sref}") + for eref in _extract_epic_refs(contrib_text): + refs.append(f"epic-{eref}") + for jm in _JIRA_KEY_RE.finditer(contrib_text): + native_keys.append(jm.group(1)) + + result["refs"] = refs + result["native_keys"] = native_keys + return result + + +def _detect_self_refs( + own_qid: str, + refs: list[str], + native_keys: list[str], + own_jira_key: str, + available: dict[str, str], +) -> list[str]: + """Detect self-references among a story's extracted refs. + + A self-reference is any ref that resolves to the story itself: + + - Direct match: a qualified ref equal to *own_qid*. + - Native key match: a native Jira key equal to *own_jira_key*. + - Resolved alias: a qualified ref whose manifest-mapped Jira key + equals *own_jira_key*. + + Must be called BEFORE available-key satisfaction shortcuts so that + mapped self-refs are not silently treated as satisfied dependencies. + + Returns a deduplicated list of self-referencing identifiers (empty + if no self-references detected). + """ + seen: set[str] = set() + self_refs: list[str] = [] + + for ref in refs: + if ref == own_qid: + if ref not in seen: + seen.add(ref) + self_refs.append(ref) + elif (own_jira_key + and ref in available + and available[ref] == own_jira_key): + if ref not in seen: + seen.add(ref) + self_refs.append(ref) + + if own_jira_key: + for nk in native_keys: + if nk == own_jira_key and nk not in seen: + seen.add(nk) + self_refs.append(nk) + + return self_refs + + +def plan_creation_order( + items: list[dict[str, Any]], + artifacts_dir: str | Path, + manifest: dict[str, Any] | None, +) -> dict[str, Any]: + """Plan a dependency-first creation order for new stories. + + Reads Dependencies and Contributing stories from each story that + needs a Jira key. Produces a topologically sorted creation plan so + prerequisites are created before their dependents in a single + ``/sync`` invocation. + + Returns a dict with: + + - ``ordered``: list of story items in prerequisite-first order. + - ``blocked``: list of ``{"item": , "missing": [, ...], + "reason": }`` for stories that cannot proceed. + - ``errors``: list of human-readable diagnostic strings (cycles, + missing local targets, unreadable sources). + - ``satisfied_keys``: dict of ``story-N.NN`` / ``epic-N`` → Jira + key already available (from manifest or item jira_key). + + Exit semantics: if ``errors`` is non-empty the plan is invalid and + no Jira writes should proceed until resolved. + """ + artifacts_dir = Path(artifacts_dir) + stories_dir = artifacts_dir / "06-stories" + + # ── Available keys (already-synced or in-item) ────────────── + available: dict[str, str] = {} + if manifest: + available = _build_ref_lookup(manifest) + + # Index items by qualified story ID; record existing keys. + # Actionable items (new/changed/reopened) with unparseable parent + # identity are structural errors — report them rather than + # silently dropping planned work. + item_by_id: dict[str, dict[str, Any]] = {} + identity_errors: list[str] = [] + for item in items: + if item.get("type") != "story": + continue + eidx = item.get("epic_index") + sidx = item.get("story_index") + if eidx is None or sidx is None: + cat = item.get("category", "") + if cat in ("new", "changed", "reopened"): + identity_errors.append( + f"story '{item.get('file', '?')}' has no valid " + f"qualified ID (epic_index={eidx}, " + f"story_index={sidx}) — cannot plan Jira " + f"operations for this item") + continue + qid = f"story-{eidx}.{sidx:02d}" + item_by_id[qid] = item + jira_key = item.get("jira_key") + if jira_key: + available[qid] = jira_key + + # ── Locate story artifact file from change-plan item ──────── + def _find_story_path(item: dict[str, Any]) -> Path | None: + """Resolve story file using the item's ``file`` field + (same identity used by detect_changes).""" + rel = item.get("file", "") + if not rel: + return None + candidate = stories_dir / rel + return candidate if candidate.is_file() else None + + # ── Extract required refs from a story artifact ───────────── + errors: list[str] = list(identity_errors) + # Track stories with unreadable/missing source files + unreadable_sources: set[str] = set() + # Track native Jira keys per story for self-reference detection + native_keys_by_qid: dict[str, list[str]] = {} + # Track stories with invalid self-references (for accurate diagnostics) + has_self_ref: dict[str, list[str]] = {} + + def _get_required_refs( + item: dict[str, Any], qid: str, + ) -> list[str]: + """Return qualified ref strings using the shared extractor.""" + path = _find_story_path(item) + if path is None: + errors.append( + f"{qid}: artifact file not found at " + f"{stories_dir / item.get('file', '?')}") + unreadable_sources.add(qid) + return [] + extraction = extract_required_refs( + path, story_title=item.get("title", "")) + if extraction["error"]: + errors.append(f"{qid}: {extraction['error']}") + unreadable_sources.add(qid) + return [] + # Record native Jira keys as available + for jkey in extraction["native_keys"]: + available.setdefault(jkey, jkey) + native_keys_by_qid[qid] = extraction["native_keys"] + return extraction["refs"] + + # ── Classify stories ──────────────────────────────────────── + needs_creation: dict[str, dict[str, Any]] = {} + for qid, item in item_by_id.items(): + if item.get("category") == "new" and qid not in available: + needs_creation[qid] = item + + needs_write: dict[str, dict[str, Any]] = {} + for qid, item in item_by_id.items(): + cat = item.get("category", "") + if cat in ("changed", "reopened"): + needs_write[qid] = item + + # ── Index epic items for epic-ref availability ─────────────── + epic_items_by_idx: dict[int, dict[str, Any]] = {} + for item in items: + if item.get("type") == "epic": + eidx = item.get("epic_index") + if eidx is not None: + epic_items_by_idx[eidx] = item + + # ── Build dependency graph ────────────────────────────────── + deps_graph: dict[str, list[str]] = {} + # Track stories with hard-unavailable refs (must be excluded) + has_unavailable_ref: set[str] = set() + # Track actual unavailable ref IDs per story (for blocked[].missing) + unavailable_ref_ids: dict[str, list[str]] = {} + + # ── Owning-parent preflight ──────────────────────────────── + # Every new story needs an owning epic: either already keyed in + # the manifest (available), or a new epic that will be created + # in Step 5 (epic_items_by_idx). Stories whose owning epic has + # neither are structural errors — they cannot be created. + for qid, item in needs_creation.items(): + eidx = item.get("epic_index") + if eidx is None: + continue # Already caught by identity_errors above + epic_ref = f"epic-{eidx}" + if epic_ref in available: + continue # Epic already has a Jira key + if epic_items_by_idx.get(eidx) is not None: + continue # Epic will be created in Step 5 + errors.append( + f"{qid} requires owning {epic_ref} which has no " + f"source artifact and no manifest key — " + f"cannot create story without a parent epic") + has_unavailable_ref.add(qid) + unavailable_ref_ids.setdefault(qid, []).append(epic_ref) + + for qid, item in needs_creation.items(): + raw_refs = _get_required_refs(item, qid) + # Self-reference detection (before available-key shortcuts) + own_key = item.get("jira_key", "") or available.get(qid, "") + self_refs = _detect_self_refs( + qid, raw_refs, native_keys_by_qid.get(qid, []), + own_key, available) + if self_refs: + errors.append( + f"{qid} contains invalid self-reference: " + f"{', '.join(self_refs)}") + has_self_ref[qid] = self_refs + has_unavailable_ref.add(qid) + deps_graph[qid] = [] + continue + unsatisfied: list[str] = [] + for ref_qid in raw_refs: + if ref_qid in available: + continue # Already satisfied + if ref_qid.startswith("story-"): + if ref_qid in needs_creation: + unsatisfied.append(ref_qid) + elif ref_qid in item_by_id: + cat = item_by_id[ref_qid].get("category", "") + errors.append( + f"{qid} requires {ref_qid} which has no " + f"usable Jira key (category: {cat})") + has_unavailable_ref.add(qid) + unavailable_ref_ids.setdefault(qid, []).append( + ref_qid) + else: + errors.append( + f"{qid} requires {ref_qid} which does not " + f"exist in artifacts or manifest") + has_unavailable_ref.add(qid) + unavailable_ref_ids.setdefault(qid, []).append( + ref_qid) + elif ref_qid.startswith("epic-"): + # Epic ref: check if this epic index has a mapped key + # or is a new epic that will be created in Step 5 + epic_idx_str = ref_qid.split("-", 1)[1] + try: + epic_idx = int(epic_idx_str) + except ValueError: + errors.append( + f"{qid} references invalid epic: {ref_qid}") + has_unavailable_ref.add(qid) + unavailable_ref_ids.setdefault(qid, []).append( + ref_qid) + continue + epic_item = epic_items_by_idx.get(epic_idx) + if epic_item is not None: + ecat = epic_item.get("category", "") + if ecat == "new": + # Will be created in Step 5; treat as planned + pass + elif epic_item.get("jira_key"): + available[ref_qid] = epic_item["jira_key"] + else: + errors.append( + f"{qid} requires {ref_qid} which has no " + f"usable Jira key (category: {ecat})") + has_unavailable_ref.add(qid) + unavailable_ref_ids.setdefault(qid, []).append( + ref_qid) + elif ref_qid not in available: + errors.append( + f"{qid} requires {ref_qid} which does not " + f"exist in artifacts or manifest") + has_unavailable_ref.add(qid) + unavailable_ref_ids.setdefault(qid, []).append( + ref_qid) + # else: native Jira key already handled in _get_required_refs + deps_graph[qid] = unsatisfied + + # Check changed/reopened stories for unavailable refs + blocked_writes: list[dict[str, Any]] = [] + for qid, item in needs_write.items(): + raw_refs = _get_required_refs(item, qid) + # Self-reference detection (before available-key shortcuts) + own_key = item.get("jira_key", "") or available.get(qid, "") + self_refs = _detect_self_refs( + qid, raw_refs, native_keys_by_qid.get(qid, []), + own_key, available) + if self_refs: + blocked_writes.append({ + "item": item, "missing": [], + "invalid_refs": self_refs, + "reason": f"invalid self-reference: " + f"{', '.join(self_refs)}"}) + errors.append( + f"{qid} (category: {item.get('category', '?')}) " + f"contains invalid self-reference: " + f"{', '.join(self_refs)}") + continue + missing: list[str] = [] + for ref_qid in raw_refs: + if ref_qid in available: + continue + if ref_qid.startswith("story-"): + if ref_qid not in needs_creation: + missing.append(ref_qid) + elif ref_qid.startswith("epic-"): + epic_idx_str = ref_qid.split("-", 1)[1] + try: + epic_idx = int(epic_idx_str) + except ValueError: + missing.append(ref_qid) + continue + epic_item = epic_items_by_idx.get(epic_idx) + if epic_item is None or ( + epic_item.get("category") != "new" + and not epic_item.get("jira_key")): + missing.append(ref_qid) + if missing: + blocked_writes.append({ + "item": item, "missing": missing, + "reason": "required reference has no usable key " + "and is not in the approved creation plan"}) + for mref in missing: + errors.append( + f"{qid} (category: {item.get('category', '?')}) " + f"requires {mref} which has no usable key and " + f"is not in the approved creation plan") + + # ── Exclude stories with unavailable refs and their dependents ─ + # Stories with hard-unavailable refs or unreadable sources cannot + # proceed; transitively block any story that depends on them. + preflight_blocked: set[str] = set(has_unavailable_ref) | unreadable_sources + if preflight_blocked: + # Build forward adjacency from needs_creation deps_graph + fwd: dict[str, list[str]] = {qid: [] for qid in needs_creation} + for qid, dep_list in deps_graph.items(): + for dep_qid in dep_list: + if dep_qid in needs_creation: + fwd[dep_qid].append(qid) + # BFS from preflight_blocked to find all transitively blocked + expand_queue = sorted(preflight_blocked) + while expand_queue: + node = expand_queue.pop(0) + for dependent in fwd.get(node, []): + if dependent not in preflight_blocked: + preflight_blocked.add(dependent) + expand_queue.append(dependent) + + # Eligible set for topological sort: needs_creation minus blocked + eligible: dict[str, dict[str, Any]] = { + qid: item for qid, item in needs_creation.items() + if qid not in preflight_blocked + } + + # ── Topological sort (Kahn's algorithm) ───────────────────── + in_degree: dict[str, int] = {qid: 0 for qid in eligible} + adj: dict[str, list[str]] = {qid: [] for qid in eligible} + for qid in eligible: + for dep_qid in deps_graph.get(qid, []): + if dep_qid in eligible: + adj[dep_qid].append(qid) + in_degree[qid] = in_degree.get(qid, 0) + 1 + + queue: list[str] = sorted( + qid for qid, deg in in_degree.items() if deg == 0) + ordered_ids: list[str] = [] + while queue: + node = queue.pop(0) + ordered_ids.append(node) + for dependent in sorted(adj.get(node, [])): + in_degree[dependent] -= 1 + if in_degree[dependent] == 0: + queue.append(dependent) + queue.sort() + + # ── Distinguish real cycles from downstream-blocked ───────── + ordered_set = set(ordered_ids) + not_ordered = {qid for qid in eligible if qid not in ordered_set} + if not_ordered: + # Find actual cycle participants: a node is in a cycle if it + # can reach itself via deps_graph edges within not_ordered. + cycle_members: set[str] = set() + for start in sorted(not_ordered): + # BFS/DFS from start's dependencies to see if start is + # reachable (= cycle) + visited: set[str] = set() + stack = [] + for dep in deps_graph.get(start, []): + if dep in not_ordered: + stack.append(dep) + while stack: + node = stack.pop() + if node in visited: + continue + visited.add(node) + if node == start: + break # Found cycle back to start + for dep in deps_graph.get(node, []): + if dep in not_ordered: + stack.append(dep) + if start in visited: + cycle_members.add(start) + + if cycle_members: + errors.append( + f"Creation cycle detected among: " + f"{', '.join(sorted(cycle_members))}") + + downstream_only = not_ordered - cycle_members + if downstream_only: + for qid in sorted(downstream_only): + errors.append( + f"{qid} blocked: depends on items that cannot " + f"be created (cycle or unavailable target)") + + # ── Build results ─────────────────────────────────────────── + ordered_items = [eligible[qid] for qid in ordered_ids] + + blocked: list[dict[str, Any]] = [] + # Preflight-blocked stories (unavailable refs and their dependents) + for qid in sorted(preflight_blocked): + if qid in needs_creation: + # Self-ref stories: use accurate reason and invalid IDs + if qid in has_self_ref: + blocked.append({ + "item": needs_creation[qid], + "missing": [], + "invalid_refs": has_self_ref[qid], + "reason": f"invalid self-reference: " + f"{', '.join(has_self_ref[qid])}"}) + continue + missing_refs = [d for d in deps_graph.get(qid, []) + if d not in available and d not in ordered_set] + # Include actual unavailable ref IDs (external targets not + # in the graph) alongside blocked graph prerequisites. + if qid in unavailable_ref_ids: + for uid in unavailable_ref_ids[qid]: + if uid not in missing_refs: + missing_refs.append(uid) + # Source-read failures: reason without fake ID in missing + if qid in unreadable_sources: + blocked.append({ + "item": needs_creation[qid], + "missing": missing_refs, + "reason": "source file unreadable or missing"}) + continue + blocked.append({ + "item": needs_creation[qid], + "missing": missing_refs, + "reason": "unavailable prerequisite" + if qid in has_unavailable_ref + else "depends on story with unavailable prerequisite"}) + # Cycle-blocked stories + all_blocked_ids = preflight_blocked | not_ordered + for qid in sorted(not_ordered): + missing = [d for d in deps_graph.get(qid, []) + if d not in available and d not in ordered_set] + blocked.append({ + "item": eligible[qid], + "missing": missing, + "reason": "cycle or unavailable prerequisite"}) + blocked.extend(blocked_writes) + + return { + "ordered": ordered_items, + "blocked": blocked, + "errors": errors, + "satisfied_keys": dict(available), + } + + +# --------------------------------------------------------------------------- +# Reference-availability check (runtime guard for Steps 6a/6b) +# --------------------------------------------------------------------------- + + +def check_ref_availability( + story_qids: list[str], + artifacts_dir: str | Path, + manifest_path: str | Path, +) -> dict[str, Any]: + """Check whether referenced stories/epics have Jira keys. + + Reloads the manifest fresh from *manifest_path* (no cached copy). + For each story in *story_qids* (qualified IDs like ``story-1.01``), + reads its artifact file, extracts required refs (Dependencies and + Contributing stories for DOCS), and checks each against the + current manifest. Also verifies that the owning epic has a Jira + key (required for story creation). + + Returns a dict with: + + - ``ready``: list of ``{"qid": ..., "refs": {...}}`` — stories + whose refs and owning epic are all available. + - ``blocked``: list of ``{"qid": ..., "missing": [...], "reason": + ...}`` — stories with unavailable refs or owning epic. + + **Invariants**: never mutates the manifest or source files; native + Jira keys in Dependencies are always treated as available. + """ + artifacts_dir = Path(artifacts_dir) + stories_dir = artifacts_dir / "06-stories" + manifest = load_manifest(Path(manifest_path)) + available: dict[str, str] = {} + if manifest: + available = _build_ref_lookup(manifest) + + ready: list[dict[str, Any]] = [] + blocked: list[dict[str, Any]] = [] + + for qid in story_qids: + # Parse epic_index and story_index from qualified ID + m = re.fullmatch(r"story-(\d+)\.(\d+)", qid) + if not m: + blocked.append({ + "qid": qid, "missing": [], + "reason": f"invalid qualified ID: {qid}"}) + continue + epic_idx = int(m.group(1)) + story_idx = int(m.group(2)) + + # Find the story artifact file using item file identity + epic_dir = stories_dir / f"epic-{epic_idx}" + if not epic_dir.is_dir(): + blocked.append({ + "qid": qid, "missing": [], + "reason": f"epic directory not found: epic-{epic_idx}"}) + continue + story_pattern = f"story-{story_idx:02d}-*.md" + candidates = sorted(epic_dir.glob(story_pattern)) + if not candidates: + blocked.append({ + "qid": qid, "missing": [], + "reason": f"artifact file not found for {qid}"}) + continue + if len(candidates) > 1: + blocked.append({ + "qid": qid, "missing": [], + "reason": f"ambiguous: multiple files match {qid}: " + f"{', '.join(c.name for c in candidates)}"}) + continue + path = candidates[0] + + # Use shared extractor + extraction = extract_required_refs(path, story_title="") + if extraction["error"]: + blocked.append({ + "qid": qid, "missing": [], + "reason": extraction["error"]}) + continue + refs = extraction["refs"] + native_keys = extraction["native_keys"] + + # Self-reference detection (before available-key shortcuts) + # Use canonical qid for own-key lookup (input qid may be + # non-canonical, e.g. "story-1.1" vs canonical "story-1.01") + canonical_qid = f"story-{epic_idx}.{story_idx:02d}" + own_key = available.get(canonical_qid, "") + self_refs = _detect_self_refs( + canonical_qid, refs, native_keys, own_key, available) + if self_refs: + blocked.append({ + "qid": qid, "missing": [], + "invalid_refs": self_refs, + "reason": f"invalid self-reference: " + f"{', '.join(self_refs)}"}) + continue + + # Native Jira keys are always available + for jkey in native_keys: + available.setdefault(jkey, jkey) + + # Check owning epic has a key (for creation) + epic_key_ref = f"epic-{epic_idx}" + missing: list[str] = [] + if epic_key_ref not in available: + missing.append(epic_key_ref) + + # Check all refs + for ref_qid in refs: + if ref_qid not in available: + missing.append(ref_qid) + + if missing: + blocked.append({ + "qid": qid, "missing": missing, + "reason": "required reference or owning epic has no " + "usable Jira key"}) + else: + ready.append({ + "qid": qid, + "refs": {r: available.get(r, "") for r in refs}, + }) + + return {"ready": ready, "blocked": blocked} + + # --------------------------------------------------------------------------- # Description rendering # --------------------------------------------------------------------------- @@ -1506,6 +2349,44 @@ def build_parser() -> argparse.ArgumentParser: ) p_hash.add_argument("file_path", help="Path to the file.") + # plan-creation-order + p_plan = sub.add_parser( + "plan-creation-order", + help="Plan dependency-first creation order for new stories.", + ) + p_plan.add_argument( + "artifacts_dir", + help="Path to .artifacts/design/{issue-key}/ directory.", + ) + + # check-ref-availability + p_check = sub.add_parser( + "check-ref-availability", + help="Check whether referenced stories/epics have Jira keys.", + ) + p_check.add_argument( + "artifacts_dir", + help="Path to .artifacts/design/{issue-key}/ directory.", + ) + p_check.add_argument( + "story_qids", nargs="+", + help="Qualified story IDs (e.g. story-1.01 story-2.03).", + ) + + # compute-cascade-closures + p_cascade = sub.add_parser( + "compute-cascade-closures", + help="Compute cascade child closures for parent epic closures.", + ) + p_cascade.add_argument( + "artifacts_dir", + help="Path to .artifacts/design/{issue-key}/ directory.", + ) + p_cascade.add_argument( + "--close-orphans", action="store_true", + help="Treat all orphaned epics as closing parents.", + ) + # migrate-manifest p_migrate = sub.add_parser( "migrate-manifest", @@ -1592,6 +2473,40 @@ def main(argv: list[str] | None = None) -> int: print(h) return EXIT_SUCCESS + elif args.command == "plan-creation-order": + change_plan = detect_changes(args.artifacts_dir) + manifest_path = Path(args.artifacts_dir) / "sync-manifest.json" + manifest = load_manifest(manifest_path) + plan = plan_creation_order( + change_plan["items"], args.artifacts_dir, manifest) + json.dump(plan, sys.stdout, indent=2, default=str) + print() + if plan.get("errors"): + return EXIT_ERROR + return EXIT_SUCCESS + + elif args.command == "check-ref-availability": + manifest_path = Path(args.artifacts_dir) / "sync-manifest.json" + result = check_ref_availability( + args.story_qids, args.artifacts_dir, manifest_path) + json.dump(result, sys.stdout, indent=2) + print() + if result.get("blocked"): + return EXIT_ERROR + return EXIT_SUCCESS + + elif args.command == "compute-cascade-closures": + change_plan = detect_changes(args.artifacts_dir) + manifest_path = Path(args.artifacts_dir) / "sync-manifest.json" + manifest = load_manifest(manifest_path) + result = compute_cascade_closures( + change_plan["items"], manifest, + close_orphans=args.close_orphans, + ) + json.dump(result, sys.stdout, indent=2) + print() + return EXIT_SUCCESS + elif args.command == "migrate-manifest": migrate_manifest(args.manifest_path) return EXIT_SUCCESS diff --git a/design/scripts/test_sync_helper.py b/design/scripts/test_sync_helper.py index 5967524a..4930353d 100644 --- a/design/scripts/test_sync_helper.py +++ b/design/scripts/test_sync_helper.py @@ -138,6 +138,7 @@ def _write(path: Path, content: str) -> None: Epic: Epic {epic_idx} — {epic_title} PRD Requirements: {prd_reqs} Design section: {design_section} + Contributing stories: {contributing_stories} """) # Story with code fences containing headings (issue #4) @@ -240,6 +241,7 @@ def _make_docs_story( doc_inputs: str = "**Story 1.01 — Scaffold:** output.", epic_title: str = "Building", prd_reqs: str = "FR-1", design_section: str = "§4.1", + contributing_stories: str = "Story 1.01", ) -> Path: extra_fm = "status: removed" if removed else "" content = _DOCS_STORY_TEMPLATE.format( @@ -248,6 +250,7 @@ def _make_docs_story( ac=ac, doc_scope=doc_scope, doc_inputs=doc_inputs, deps=deps, epic_title=epic_title, prd_reqs=prd_reqs, design_section=design_section, + contributing_stories=contributing_stories, ) epic_dir = stories_dir / f"epic-{epic_idx}" fp = epic_dir / f"story-{story_idx:02d}-{slug}.md" @@ -846,6 +849,7 @@ def test_docs_story_preserves_docs_sections(self) -> None: doc_scope="API ref.", doc_inputs="Story 1.01 output.", deps="Story 1.01", epic_title="Building", prd_reqs="FR-1", design_section="§4.1", + contributing_stories="Story 1.01", )) desc = sync_helper.render_story_description( sf, self.manifest, story_type="DOCS") @@ -855,6 +859,27 @@ def test_docs_story_preserves_docs_sections(self) -> None: # Ref resolved self.assertIn("EDM-201", desc) + def test_docs_story_contributing_stories_resolved(self) -> None: + """Contributing stories line in Design Reference is resolved to Jira keys.""" + sf = Path(self.tmpdir) / "story-02-docs.md" + _write(sf, _DOCS_STORY_TEMPLATE.format( + extra_fm="", epic_idx=1, story_idx=2, + title="Device rollback command reference", + role="fleet administrator", + want="find rollback command documentation", + so_that="I can safely revert device firmware", + ac="Reader can follow rollback procedure", + doc_scope="CLI rollback command reference.", + doc_inputs="**Story 1.01 — Scaffold:** rollback subcommand.", + deps="Story 1.01", epic_title="Building", + prd_reqs="FR-1", design_section="§4.1", + contributing_stories="Story 1.01", + )) + desc = sync_helper.render_story_description( + sf, self.manifest, story_type="DOCS") + # Contributing stories line should have resolved ref + self.assertIn("Contributing stories: Story EDM-201", desc) + def test_code_fences_do_not_break_sections(self) -> None: """Issue #4: headings inside code fences are not treated as sections.""" sf = Path(self.tmpdir) / "story-01-api.md" @@ -1258,6 +1283,7 @@ def test_docs_inferred_from_canonical_title_via_prepare(self) -> None: doc_scope="API reference.", doc_inputs="From Story 1.01.", deps="Story 1.01", epic_title="Building", prd_reqs="FR-1", design_section="§4.1", + contributing_stories="Story 1.01", )) # No --story-type — must infer from title out = StringIO() @@ -2238,7 +2264,7 @@ class TestOrphanClosureStateful(_TmpDirMixin, unittest.TestCase): The helper never contacts Jira; tests simulate the effect of successful Jira operations by calling update-manifest with the correct arguments. This mirrors the workflow described in - Steps 4c/5c of sync.md. + Steps 5c/6c of sync.md. """ def setUp(self) -> None: super().setUp() @@ -2596,7 +2622,7 @@ def test_genuinely_new_story_different_index_ok(self) -> None: class TestCascadeChildClosurePermissions(_TmpDirMixin, unittest.TestCase): """Verify that detect-changes marks active children of a removed epic as 'removed' (cascade), which is the prerequisite for the approved - parent-close cascade in Step 4c closing those children in Jira.""" + parent-close cascade in Step 5c closing those children in Jira.""" def setUp(self) -> None: super().setUp() @@ -2825,8 +2851,8 @@ def test_metacharacter_paths_no_execution(self) -> None: # --------------------------------------------------------------------------- class TestCascadeGateContract(unittest.TestCase): - """Assert sync.md Steps 4c/5c document hash-before-transition ordering - and that Step 4c gates the parent close on all child successes. + """Assert sync.md Steps 5c/6c document hash-before-transition ordering + and that Step 5c gates the parent close on all child successes. These are contract tests against the ACTUAL Markdown sections/order, not a test-only invented executor. They verify the documented @@ -2834,143 +2860,143 @@ class TestCascadeGateContract(unittest.TestCase): """ _sync_md: str - _step4c: str _step5c: str - _step5_refresh: str + _step6c: str + _step6_refresh: str @classmethod def setUpClass(cls) -> None: import re sync_md_path = Path(__file__).resolve().parent.parent / "skills" / "sync.md" cls._sync_md = sync_md_path.read_text(encoding="utf-8") - m4c = re.search(r"#### 4c:.*?(?=####|### )", cls._sync_md, re.DOTALL) - assert m4c, "Step 4c section not found" - cls._step4c = m4c.group() m5c = re.search(r"#### 5c:.*?(?=####|### )", cls._sync_md, re.DOTALL) assert m5c, "Step 5c section not found" cls._step5c = m5c.group() - m5 = re.search(r"### Step 5:.*?(?=#### 5a:)", cls._sync_md, re.DOTALL) - assert m5, "Step 5 plan refresh not found" - cls._step5_refresh = m5.group() - - # -- Step 4c: child hash before child transition --- - - def test_step4c_child_hash_before_transition(self) -> None: - """4c child closure: hash computed before Jira transition.""" - h = self._step4c.find("Compute the hash first") - t = self._step4c.find("Transition the child story") + m6c = re.search(r"#### 6c:.*?(?=####|### )", cls._sync_md, re.DOTALL) + assert m6c, "Step 6c section not found" + cls._step6c = m6c.group() + m6 = re.search(r"### Step 6:.*?(?=#### 6a:)", cls._sync_md, re.DOTALL) + assert m6, "Step 6 plan refresh not found" + cls._step6_refresh = m6.group() + + # -- Step 5c: child hash before child transition --- + + def test_step5c_child_hash_before_transition(self) -> None: + """5c child closure: hash computed before Jira transition.""" + h = self._step5c.find("Compute the hash first") + t = self._step5c.find("Transition the child story") self.assertGreater(h, -1, "must mention computing child hash") self.assertGreater(t, -1, "must mention child transition") self.assertLess(h, t, "child hash must precede child transition") - def test_step4c_epic_hash_before_transition(self) -> None: - """4c epic closure: hash computed before Jira transition.""" - h = self._step4c.find("compute the epic hash") - t = self._step4c.find("Transition the epic to Done") + def test_step5c_epic_hash_before_transition(self) -> None: + """5c epic closure: hash computed before Jira transition.""" + h = self._step5c.find("compute the epic hash") + t = self._step5c.find("Transition the epic to Done") self.assertGreater(h, -1, "must mention computing epic hash") self.assertGreater(t, -1, "must mention epic transition") self.assertLess(h, t, "epic hash must precede epic transition") - # -- Step 4c: parent gated on all children --- + # -- Step 5c: parent gated on all children --- - def test_step4c_parent_gated_on_all_children(self) -> None: - """4c: parent close requires ALL child closures and checkpoints.""" + def test_step5c_parent_gated_on_all_children(self) -> None: + """5c: parent close requires ALL child closures and checkpoints.""" self.assertIn("child closures and their checkpoints have succeeded", - self._step4c) + self._step5c) - def test_step4c_stop_cascade_on_failure(self) -> None: - """4c: cascade stops immediately on any child failure.""" + def test_step5c_stop_cascade_on_failure(self) -> None: + """5c: cascade stops immediately on any child failure.""" import re self.assertTrue( - re.search(r"stop the cascade\s+immediately", self._step4c), + re.search(r"stop the cascade\s+immediately", self._step5c), "must stop cascade immediately on child failure") - def test_step4c_no_skip_child_then_close_parent(self) -> None: - """4c: explicitly forbids skipping a child then closing parent.""" + def test_step5c_no_skip_child_then_close_parent(self) -> None: + """5c: explicitly forbids skipping a child then closing parent.""" import re self.assertTrue( - re.search(r"Never\s+skip\s+a failing child", self._step4c), + re.search(r"Never\s+skip\s+a failing child", self._step5c), "must forbid skip-child-close-parent") - def test_step4c_offers_retry_or_defer(self) -> None: - """4c: pre-transition failure offers retry or defer.""" + def test_step5c_offers_retry_or_defer(self) -> None: + """5c: pre-transition failure offers retry or defer.""" import re self.assertTrue( - re.search(r"retry after\s+remediation", self._step4c), + re.search(r"retry after\s+remediation", self._step5c), "must offer retry after remediation") - self.assertIn("defer the entire parent", self._step4c) + self.assertIn("defer the entire parent", self._step5c) - def test_step4c_preserves_successful_checkpoints(self) -> None: - """4c: already-successful child checkpoints preserved on failure.""" + def test_step5c_preserves_successful_checkpoints(self) -> None: + """5c: already-successful child checkpoints preserved on failure.""" import re self.assertTrue( re.search(r"Preserve all\s+already-successful\s+child checkpoints", - self._step4c), + self._step5c), "must preserve completed child checkpoints") - def test_step4c_distinguishes_pre_and_post_failure(self) -> None: - """4c: distinguishes pre-transition from post-success checkpoint + def test_step5c_distinguishes_pre_and_post_failure(self) -> None: + """5c: distinguishes pre-transition from post-success checkpoint failure, referencing the shared close-checkpoint recovery rule.""" - self.assertIn("Pre-transition failure", self._step4c) - self.assertIn("Post-success checkpoint failure", self._step4c) - self.assertIn("close-checkpoint", self._step4c) + self.assertIn("Pre-transition failure", self._step5c) + self.assertIn("Post-success checkpoint failure", self._step5c) + self.assertIn("close-checkpoint", self._step5c) - def test_step4c_uncertain_outcome_requires_verification(self) -> None: - """4c: uncertain Jira outcome (timeout/5xx) must be verified + def test_step5c_uncertain_outcome_requires_verification(self) -> None: + """5c: uncertain Jira outcome (timeout/5xx) must be verified before retrying, not assumed non-mutating.""" import re self.assertTrue( re.search(r"uncertain.*verify.*current status", - self._step4c, re.DOTALL), + self._step5c, re.DOTALL), "must require verification for uncertain Jira outcomes") - def test_step4c_resume_manual_reconciliation(self) -> None: - """4c: resume stops for manual reconciliation when captured + def test_step5c_resume_manual_reconciliation(self) -> None: + """5c: resume stops for manual reconciliation when captured state is lost (forced abort/new run).""" - self.assertIn("stop for manual reconciliation", self._step4c) + self.assertIn("stop for manual reconciliation", self._step5c) - # -- Step 5c: hash before transition --- + # -- Step 6c: hash before transition --- - def test_step5c_hash_before_transition(self) -> None: - """5c story close: hash computed before transition.""" - h = self._step5c.find("Compute the hash before the transition") - t = self._step5c.find("Transition the story to Done") + def test_step6c_hash_before_transition(self) -> None: + """6c story close: hash computed before transition.""" + h = self._step6c.find("Compute the hash before the transition") + t = self._step6c.find("Transition the story to Done") self.assertGreater(h, -1, "must mention computing hash first") self.assertGreater(t, -1, "must mention story transition") self.assertLess(h, t, "hash must precede transition") - def test_step5c_checkpoint_uses_saved_hash(self) -> None: - """5c: checkpoint references saved hash from step 1.""" - self.assertIn("hash from step 1", self._step5c) + def test_step6c_checkpoint_uses_saved_hash(self) -> None: + """6c: checkpoint references saved hash from step 1.""" + self.assertIn("hash from step 1", self._step6c) - # -- Step 4c: reporting distinguishes child states --- + # -- Step 5c: reporting distinguishes child states --- - def test_step4c_report_distinguishes_child_states(self) -> None: - """4c: failure report distinguishes complete, uncheckpointed, + def test_step5c_report_distinguishes_child_states(self) -> None: + """5c: failure report distinguishes complete, uncheckpointed, and unattempted children.""" import re - self.assertIn("closed and checkpointed (complete)", self._step4c) + self.assertIn("closed and checkpointed (complete)", self._step5c) self.assertTrue( - re.search(r"Jira-closed but\s+uncheckpointed", self._step4c), + re.search(r"Jira-closed but\s+uncheckpointed", self._step5c), "must mention Jira-closed but uncheckpointed") - self.assertIn("unattempted", self._step4c) + self.assertIn("unattempted", self._step5c) - def test_step4c_no_rollback_implication(self) -> None: - """4c: does not imply already-closed children are rolled back.""" + def test_step5c_no_rollback_implication(self) -> None: + """5c: does not imply already-closed children are rolled back.""" import re self.assertTrue( re.search(r"not imply that already-closed children\s+will be rolled back", - self._step4c), + self._step5c), "must not imply rollback of closed children") - # -- Step 4c: child checkpoint references helper reference --- + # -- Step 5c: child checkpoint references helper reference --- - def test_step4c_child_checkpoint_references_helper(self) -> None: - """4c child checkpoint step references the helper reference.""" - self.assertIn("helper reference", self._step4c) + def test_step5c_child_checkpoint_references_helper(self) -> None: + """5c child checkpoint step references the helper reference.""" + self.assertIn("helper reference", self._step5c) # The step must mention the required story-specific args - self.assertIn("--item-type story", self._step4c) - self.assertIn("--epic-file", self._step4c) + self.assertIn("--item-type story", self._step5c) + self.assertIn("--epic-file", self._step5c) def test_helper_reference_child_close_has_all_args(self) -> None: """Helper reference child-close recipe has all required args.""" @@ -3112,26 +3138,26 @@ def test_recovery_rule_uncertain_outcome_verification(self) -> None: def test_all_close_paths_reference_recovery_rule(self) -> None: """All three close paths (child, epic, 5c) reference the shared close-checkpoint recovery rule.""" - self.assertIn("close-checkpoint", self._step4c) + self.assertIn("close-checkpoint", self._step5c) # Epic close failure handler - epic_fail_pos = self._step4c.find("If epic close fails") + epic_fail_pos = self._step5c.find("If epic close fails") self.assertGreater(epic_fail_pos, -1) - epic_fail_text = self._step4c[epic_fail_pos:] + epic_fail_text = self._step5c[epic_fail_pos:] self.assertIn("close-checkpoint", epic_fail_text) # Step 5c - self.assertIn("close-checkpoint", self._step5c) + self.assertIn("close-checkpoint", self._step6c) - # -- Step 5 plan refresh: deferred cascades --- + # -- Step 6 plan refresh: deferred cascades --- def test_step5_deferred_cascade_not_retried(self) -> None: - """Step 5 refresh: deferred cascades are not silently retried.""" - self.assertIn("deferred in Step 4c", self._step5_refresh) - self.assertIn("do not retry closing", self._step5_refresh) + """Step 6 refresh: deferred cascades are not silently retried.""" + self.assertIn("deferred in Step 5c", self._step6_refresh) + self.assertIn("do not retry closing", self._step6_refresh) def test_step5_deferral_covers_checkpoint_failure(self) -> None: - """Step 5 refresh: deferral clause covers Jira-closed-but- + """Step 6 refresh: deferral clause covers Jira-closed-but- uncheckpointed state, not just open children.""" - self.assertIn("Jira-closed but uncheckpointed", self._step5_refresh) + self.assertIn("Jira-closed but uncheckpointed", self._step6_refresh) # --------------------------------------------------------------------------- @@ -3287,7 +3313,7 @@ def test_orphan_empty_hash_not_fabricated(self) -> None: with self.assertRaises(FileNotFoundError): sync_helper.compute_file_hash(self.sf2) - # Orphan close uses empty hash per Step 4c/5c + # Orphan close uses empty hash per Step 5c/6c sync_helper.update_manifest( self.mf, "close", "story-02-pipe.md", "EDM-202", "", item_type="story", @@ -4028,5 +4054,6992 @@ def test_story_prepare_no_size_requirement(self) -> None: self.assertIn("description", result) +# --------------------------------------------------------------------------- +# Post-sync DOCS placement: synced DOCS preceding later contributor +# --------------------------------------------------------------------------- + +class TestSyncedDocsPlacement(_TmpDirMixin, unittest.TestCase): + """Verify that a synced DOCS story (immutable filename) can reference + a later same-epic contributor after that contributor is created and + recorded in the manifest. Also verify cross-cutting last-epic case + and the missing-key guard.""" + + def setUp(self) -> None: + super().setUp() + self.stories_dir = Path(self.tmpdir) / "06-stories" + self.stories_dir.mkdir() + # Epic 1: DOCS story-03 precedes contributor story-04 + _make_epic(self.stories_dir, 1, "rollback") + _make_dev_story(self.stories_dir, 1, 1, "rollback-cmd", title="Rollback cmd") + _make_docs_story(self.stories_dir, 1, 3, "rollback-docs", + title="Rollback CLI reference", + deps="Story 1.01, Story 1.04", + doc_inputs="**Story 1.01 — Rollback cmd:** subcommand.\n" + "**Story 1.04 — Dry-run flag:** --dry-run flag.", + contributing_stories="Story 1.01, Story 1.04") + _make_dev_story(self.stories_dir, 1, 4, "dry-run-flag", title="Dry-run flag") + + def test_same_epic_contributor_after_docs_resolves(self) -> None: + """After story-04 is created and recorded, story-03 DOCS refs resolve.""" + manifest = { + "epics": [{ + "file": "epic-1-rollback.md", + "jira_key": "EDM-100", + "stories": [ + {"file": "story-01-rollback-cmd.md", "jira_key": "EDM-101"}, + {"file": "story-03-rollback-docs.md", "jira_key": "EDM-103"}, + {"file": "story-04-dry-run-flag.md", "jira_key": "EDM-104"}, + ] + }] + } + docs_path = self.stories_dir / "epic-1" / "story-03-rollback-docs.md" + desc = sync_helper.render_story_description( + docs_path, manifest, story_type="DOCS") + # Contributing stories resolved — no [unresolved] + self.assertIn("Contributing stories: Story EDM-101, Story EDM-104", desc) + self.assertNotIn("[unresolved]", desc) + # Dependencies also resolved + self.assertIn("EDM-101", desc) + self.assertIn("EDM-104", desc) + + def test_missing_contributor_key_leaves_unresolved(self) -> None: + """If contributor story-04 is not yet in manifest, ref stays unresolved.""" + manifest = { + "epics": [{ + "file": "epic-1-rollback.md", + "jira_key": "EDM-100", + "stories": [ + {"file": "story-01-rollback-cmd.md", "jira_key": "EDM-101"}, + {"file": "story-03-rollback-docs.md", "jira_key": "EDM-103"}, + # story-04 NOT in manifest — creation pending/failed + ] + }] + } + docs_path = self.stories_dir / "epic-1" / "story-03-rollback-docs.md" + desc = sync_helper.render_story_description( + docs_path, manifest, story_type="DOCS") + # Story 1.04 should be unresolved + self.assertIn("[unresolved]", desc) + # Story 1.01 still resolved + self.assertIn("EDM-101", desc) + + def test_cross_epic_contributor_resolves_after_creation(self) -> None: + """Cross-cutting DOCS in epic 1 referencing a contributor in epic 2.""" + # Add epic 2 with a contributor + _make_epic(self.stories_dir, 2, "monitoring") + _make_dev_story(self.stories_dir, 2, 1, "metrics", title="Add metrics") + # Update DOCS story to reference cross-epic contributor + docs_path = self.stories_dir / "epic-1" / "story-03-rollback-docs.md" + content = docs_path.read_text(encoding="utf-8") + content = content.replace( + "Contributing stories: Story 1.01, Story 1.04", + "Contributing stories: Story 1.01, Story 1.04, Story 2.01") + docs_path.write_text(content, encoding="utf-8") + + manifest = { + "epics": [ + { + "file": "epic-1-rollback.md", + "jira_key": "EDM-100", + "stories": [ + {"file": "story-01-rollback-cmd.md", "jira_key": "EDM-101"}, + {"file": "story-03-rollback-docs.md", "jira_key": "EDM-103"}, + {"file": "story-04-dry-run-flag.md", "jira_key": "EDM-104"}, + ] + }, + { + "file": "epic-2-monitoring.md", + "jira_key": "EDM-200", + "stories": [ + {"file": "story-01-metrics.md", "jira_key": "EDM-201"}, + ] + } + ] + } + desc = sync_helper.render_story_description( + docs_path, manifest, story_type="DOCS") + # All three contributor refs resolved + self.assertIn("EDM-101", desc) + self.assertIn("EDM-104", desc) + self.assertIn("EDM-201", desc) + self.assertNotIn("[unresolved]", desc) + + def test_synced_docs_identity_stable_with_late_contributor(self) -> None: + """Synced DOCS story-03 retains its manifest identity when + story-04 is a later contributor — detect_changes classifies + it as unchanged, not new or removed.""" + docs_path = self.stories_dir / "epic-1" / "story-03-rollback-docs.md" + content_hash = sync_helper.compute_file_hash(str(docs_path)) + artifacts_dir = Path(self.tmpdir) + _make_manifest(artifacts_dir, [{ + "file": "epic-1-rollback.md", + "jira_key": "EDM-100", + "title": "Rollback", "size": "M", + "content_hash": "x", "synced_status": "active", + "stories": [ + {"file": "story-01-rollback-cmd.md", "jira_key": "EDM-101", + "title": "[DEV] Rollback cmd", "content_hash": "y", + "synced_status": "active"}, + {"file": "story-03-rollback-docs.md", "jira_key": "EDM-103", + "title": "[DOCS] Rollback CLI reference", + "content_hash": content_hash, + "synced_status": "active"}, + {"file": "story-04-dry-run-flag.md", "jira_key": "EDM-104", + "title": "[DEV] Dry-run flag", "content_hash": "z", + "synced_status": "active"}, + ], + }]) + plan = sync_helper.detect_changes(str(artifacts_dir)) + docs_items = [i for i in plan["items"] + if i["file"].endswith("story-03-rollback-docs.md")] + self.assertEqual(len(docs_items), 1) + self.assertEqual(docs_items[0]["category"], "unchanged") + self.assertEqual(docs_items[0]["jira_key"], "EDM-103") + + +# --------------------------------------------------------------------------- +# Phase-count and ordering contract for decompose.md +# --------------------------------------------------------------------------- + +class TestDecomposePhaseContract(unittest.TestCase): + """Verify decompose.md has <=10 sequential main steps and preserves + all original actions, gates, and ordering dependencies.""" + + _decompose_md: str + + @classmethod + def setUpClass(cls) -> None: + import re + path = Path(__file__).resolve().parent.parent / "skills" / "decompose.md" + cls._decompose_md = path.read_text(encoding="utf-8") + cls._steps = re.findall(r"^### Step (\d+):", cls._decompose_md, re.MULTILINE) + + def test_at_most_10_main_steps(self) -> None: + self.assertLessEqual(len(self._steps), 10, + f"Expected <= 10 main steps, found {len(self._steps)}") + + def test_steps_sequential(self) -> None: + nums = [int(s) for s in self._steps] + self.assertEqual(nums, list(range(1, len(nums) + 1)), + f"Steps not sequential: {nums}") + + def _assert_marker_exists(self, marker: str, msg: str) -> int: + idx = self._decompose_md.find(marker) + self.assertGreaterEqual(idx, 0, msg) + return idx + + def test_source_loading_blocks_on_missing_design(self) -> None: + self._assert_marker_exists( + "tell the user that `/draft` should be", + "Source loading must block if design/testplan missing") + + def test_feature_identification_in_step1(self) -> None: + step1 = self._decompose_md.split("### Step 2:")[0] + self.assertIn("Feature issue key", step1, + "Step 1 must identify the Feature") + + def test_sizing_xxl_stop(self) -> None: + self._assert_marker_exists( + "stop and require a split", + "Must stop on XXL epics") + + def test_ic_tc_mapping_present(self) -> None: + self._assert_marker_exists( + "Map stories to Interface Changes", + "IC mapping must be present") + self._assert_marker_exists( + "Map stories to test cases", + "TC mapping must be present") + + def test_structure_gate_exists(self) -> None: + idx = self._assert_marker_exists( + "Verify artifact structure", + "Structure gate must exist in Step 7") + self.assertIn("05-epics.md", self._decompose_md[idx:idx+500]) + + def test_step3b_active_only_epic_authoring(self) -> None: + """Step 3b writes only active epics; does not rewrite retired files.""" + step3 = self._decompose_md.split("### Step 3:")[1].split("### Step 4:")[0] + self.assertIn("each active epic", step3.lower()) + self.assertIn("Do not rewrite retired epic files", step3) + + def test_step3a_repeat_run_active_index(self) -> None: + """Step 3a index reflects active plan only on repeat runs.""" + step3 = self._decompose_md.split("### Step 3:")[1].split("### Step 4:")[0] + self.assertIn("list active epics only", step3.lower()) + + def test_step4_active_only_story_authoring(self) -> None: + """Step 4 creates directories and stories for active epics only.""" + step4 = self._decompose_md.split("### Step 4:")[1].split("### Step 5:")[0] + self.assertIn("each active epic", step4.lower()) + self.assertIn("Do not fabricate directories", step4) + + def test_step5_active_only_sizing(self) -> None: + """Step 5 sizes active epics only.""" + step5 = self._decompose_md.split("### Step 5:")[1].split("### Step 6:")[0] + self.assertIn("all active epics", step5.lower()) + + def test_step6_active_only_references(self) -> None: + """Step 6 populates IC/TC references for active stories only.""" + step6 = self._decompose_md.split("### Step 6:")[1].split("### Step 7:")[0] + self.assertIn("each active non-", step6.lower()) + + def test_step7_check1_active_index(self) -> None: + """Step 7 check 1 verifies 05-epics.md against active epics.""" + step7 = self._decompose_md.split("### Step 7:")[1].split("### Step 8:")[0] + self.assertIn("all active epic files", step7.lower()) + + def test_review_after_structure_gate(self) -> None: + structure_idx = self._assert_marker_exists( + "Verify artifact structure", + "Structure gate must exist for ordering check") + review_idx = self._assert_marker_exists( + "### Step 8: Review", + "Review step must exist for ordering check") + self.assertGreater(review_idx, structure_idx) + + def test_review_independence(self) -> None: + review_section = self._decompose_md.split("### Step 8:")[1].split("### Step 9:")[0] + self.assertIn("NOT the design document", review_section, + "Review must not receive the design document") + + def test_step9_two_numbered_main_actions(self) -> None: + """Step 9 has two explicit numbered main actions: 1. Assess + findings, 2. Verify provenance.""" + step9_start = self._decompose_md.find("### Step 9:") + step10_start = self._decompose_md.find("### Step 10:") + self.assertGreaterEqual(step9_start, 0, "Step 9 must exist") + self.assertGreater(step10_start, step9_start, "Step 10 must follow") + step9 = self._decompose_md[step9_start:step10_start] + action1 = step9.find("1. **Assess review findings.**") + action2 = step9.find("2. **Verify `[DOCS]` input provenance.**") + self.assertGreaterEqual(action1, 0, "Action 1 must exist") + self.assertGreaterEqual(action2, 0, "Action 2 must exist") + self.assertLess(action1, action2, + "Assessment must precede provenance") + + def test_step9_provenance_before_conditional_rereview(self) -> None: + """Provenance (action 2) precedes the conditional #### Re-review.""" + step9_start = self._decompose_md.find("### Step 9:") + step10_start = self._decompose_md.find("### Step 10:") + self.assertGreaterEqual(step9_start, 0, "Step 9 must exist") + self.assertGreater(step10_start, step9_start) + step9 = self._decompose_md[step9_start:step10_start] + prov_pos = step9.find("Verify `[DOCS]` input provenance") + rereview_heading = step9.find("#### Re-review") + # Actual consumer: "Resume the same reviewer agent" + rereview_consumer = step9.find("Resume the same reviewer agent") + self.assertGreaterEqual(prov_pos, 0, "Provenance must exist") + self.assertGreaterEqual(rereview_heading, 0, "Re-review heading must exist") + self.assertGreaterEqual(rereview_consumer, 0, "Re-review consumer must exist") + self.assertLess(prov_pos, rereview_heading, + "Provenance must precede #### Re-review") + self.assertLess(prov_pos, rereview_consumer, + "Provenance must precede re-review consumer") + + def test_step9_provenance_unconditional(self) -> None: + """Provenance runs unconditionally, even with zero findings.""" + step9_start = self._decompose_md.find("### Step 9:") + step10_start = self._decompose_md.find("### Step 10:") + step9 = self._decompose_md[step9_start:step10_start] + self.assertIn("runs unconditionally", step9) + self.assertIn("zero findings", step9) + + def test_step9_provenance_corrections_trigger_rereview(self) -> None: + """Provenance-only corrections trigger conditional re-review.""" + step9_start = self._decompose_md.find("### Step 9:") + step10_start = self._decompose_md.find("### Step 10:") + step9 = self._decompose_md[step9_start:step10_start] + rereview_section = step9[step9.find("#### Re-review"):] + self.assertIn("provenance corrections", rereview_section) + + def test_step9_no_early_pass(self) -> None: + """Pass declaration only after both mandatory actions complete.""" + step9_start = self._decompose_md.find("### Step 9:") + step10_start = self._decompose_md.find("### Step 10:") + step9 = self._decompose_md[step9_start:step10_start] + pass_pos = step9.find("validation passes") + prov_pos = step9.find("Verify `[DOCS]` input provenance") + self.assertGreaterEqual(pass_pos, 0, "Pass text must exist") + self.assertGreater(pass_pos, prov_pos, + "Pass must come AFTER provenance") + + def test_step9_cap_is_2_rounds(self) -> None: + """Cap at 2 review-fix rounds applies across entire Step 9.""" + step9_start = self._decompose_md.find("### Step 9:") + step10_start = self._decompose_md.find("### Step 10:") + step9 = " ".join(self._decompose_md[step9_start:step10_start].split()) + self.assertIn("Cap at 2 review-fix rounds total", step9) + self.assertIn("the 2-round cap has not been reached", step9, + "Cap must gate re-review dispatch") + + def test_step9_no_extra_phase(self) -> None: + """No Step 9a, Step 11, or unconditional #### Verify heading.""" + self.assertNotIn("### Step 9a:", self._decompose_md) + self.assertNotIn("### Step 11:", self._decompose_md) + # Old unconditional #### Verify heading must be gone + step9_start = self._decompose_md.find("### Step 9:") + step10_start = self._decompose_md.find("### Step 10:") + step9 = self._decompose_md[step9_start:step10_start] + self.assertNotIn("#### Verify `[DOCS]` input provenance", step9, + "Provenance must be a numbered action, not a #### heading") + + def test_reviewer_still_lacks_design_document(self) -> None: + """Independent reviewer in Step 8 still does not receive the + design document; provenance is author-only in Step 9.""" + review_section = self._decompose_md.split("### Step 8:")[1].split("### Step 9:")[0] + self.assertIn("NOT the design document", review_section) + + def test_flag_gate_for_critical_high(self) -> None: + self._assert_marker_exists( + "**Gate:** PASS | FLAG", + "FLAG gate must exist") + self._assert_marker_exists( + "CRITICAL or HIGH findings remain unfixed", + "FLAG criteria must reference CRITICAL/HIGH") + + def test_present_is_last_step(self) -> None: + self.assertEqual(self._steps[-1], "10") + last = self._decompose_md.split("### Step 10:")[1][:100] + self.assertIn("Summarize and Present", last) + + def test_open_question_design_references_preserved(self) -> None: + """[Open: §9.N] marker format references design document sections, + not decompose step numbers.""" + self.assertIn("[Open: §9.N]", self._decompose_md) + + # -- Identity stability contracts -- + + def test_criterion7_is_stable_identity(self) -> None: + """Criterion 7 requires stable identity, not numeric placement.""" + flat = " ".join(self._decompose_md.split()) + self.assertIn("Stable identity", flat, + "Criterion 7 must be titled 'Stable identity'") + self.assertIn("is fixed", flat) + self.assertIn("must not be renamed, renumbered, or moved", flat) + self.assertIn("retired numbers must not be reused", flat) + + def test_criterion7_key_availability_not_position(self) -> None: + """Criterion 7 defers on missing keys, never rejects based on + numeric position.""" + flat = " ".join(self._decompose_md.split()) + self.assertIn("Contributing stories AND Dependencies", flat) + self.assertIn("deferred to the next explicit", flat) + self.assertNotIn("numerically last", flat, + "Must not require numeric-last positioning") + + def test_step9_no_renumber_instruction(self) -> None: + """Step 9 finding fixes must not rename/renumber story files.""" + step9 = " ".join(self._decompose_md.split("### Step 9:")[1] + .split("### Step 10:")[0].split()) + self.assertIn("do not rename, renumber, or move story files", step9) + + def test_identity_stability_block_exists(self) -> None: + """Identity stability block exists in decompose.md.""" + self._assert_marker_exists( + "**Identity stability:**", + "Identity stability block must exist") + + # -- Producer-order contracts for consolidated steps -- + + def test_sizing_is_main_step_5(self) -> None: + """Sizing Consistency Check is a main step, not a sub-heading.""" + self._assert_marker_exists( + "### Step 5: Sizing Consistency Check", + "Sizing must be main Step 5") + + def test_impl_refs_is_main_step_6(self) -> None: + """Add Implementation References is a main step, not a sub-heading.""" + self._assert_marker_exists( + "### Step 6: Add Implementation References", + "Impl refs must be main Step 6") + + def test_coverage_before_structure_in_step7(self) -> None: + """Step 7: coverage matrix is written before structure validation.""" + step7_start = self._decompose_md.find("### Step 7:") + step8_start = self._decompose_md.find("### Step 8:") + self.assertGreaterEqual(step7_start, 0, "Step 7 must exist") + self.assertGreater(step8_start, step7_start, "Step 8 must follow 7") + step7 = self._decompose_md[step7_start:step8_start] + coverage_pos = step7.find("07-coverage.md") + structure_pos = step7.find("Verify artifact structure") + self.assertGreaterEqual(coverage_pos, 0, "Coverage producer must exist") + self.assertGreaterEqual(structure_pos, 0, "Structure gate must exist") + self.assertLess(coverage_pos, structure_pos, + "Coverage must precede structure validation") + + def test_summary_before_presentation_in_step10(self) -> None: + """Step 10: review summary is produced before user presentation.""" + step10 = self._decompose_md.split("### Step 10:")[1] + summary_pos = step10.find("## Decomposition Review Summary") + present_pos = step10.find("Present to the user") + self.assertGreaterEqual(summary_pos, 0, "Summary template must exist") + self.assertGreaterEqual(present_pos, 0, "Presentation must exist") + self.assertLess(summary_pos, present_pos, + "Summary must precede presentation") + + def test_all_steps_before_review(self) -> None: + """Steps 4-7 (stories/sizing/refs/artifacts) all precede Step 8 review.""" + flat = self._decompose_md + step4 = flat.find("### Step 4:") + step5 = flat.find("### Step 5:") + step6 = flat.find("### Step 6:") + step7 = flat.find("### Step 7:") + step8 = flat.find("### Step 8:") + for pos, name in [(step4, "4"), (step5, "5"), (step6, "6"), + (step7, "7"), (step8, "8")]: + self.assertGreaterEqual(pos, 0, f"Step {name} must exist") + self.assertLess(step4, step5) + self.assertLess(step5, step6) + self.assertLess(step6, step7) + self.assertLess(step7, step8, "All artifact steps must precede review") + + +# --------------------------------------------------------------------------- +# Sync: reference-availability guard for DOCS creation and update +# --------------------------------------------------------------------------- + +class TestSyncDocsReferenceGuard(_TmpDirMixin, unittest.TestCase): + """Verify the reference-availability guard: render_story_description + produces [unresolved] when a contributor key is missing, enabling the + sync agent to detect and defer the DOCS write. Tests model valid + story ordering (contributors before DOCS) and simulate a contributor + whose creation was skipped/failed.""" + + def setUp(self) -> None: + super().setUp() + self.stories_dir = Path(self.tmpdir) / "06-stories" + self.stories_dir.mkdir() + self.artifacts_dir = Path(self.tmpdir) + _make_epic(self.stories_dir, 1, "feature") + # story-01 and story-02 are DEV contributors; story-03 is DOCS + _make_dev_story(self.stories_dir, 1, 1, "api", title="API endpoint") + _make_dev_story(self.stories_dir, 1, 2, "webhook", title="Webhook") + _make_docs_story(self.stories_dir, 1, 3, "docs", + title="API reference", + deps="Story 1.01, Story 1.02", + contributing_stories="Story 1.01, Story 1.02", + doc_inputs="**Story 1.01:** endpoint.\n" + "**Story 1.02:** webhook.") + + def _manifest_with_keys(self, story_keys: list[dict]) -> dict: + """Build a manifest with the given story entries.""" + return {"epics": [{ + "file": "epic-1-feature.md", + "jira_key": "T-100", + "stories": story_keys, + }]} + + # -- Guard for new DOCS creation (Step 6a) -- + + def test_new_docs_missing_contributor_shows_unresolved(self) -> None: + """New DOCS: contributor story-02 creation skipped → [unresolved]. + The sync agent must not create this DOCS story or checkpoint it.""" + manifest = self._manifest_with_keys([ + {"file": "story-01-api.md", "jira_key": "T-101"}, + # story-02 creation failed/skipped — no key + ]) + docs_path = self.stories_dir / "epic-1" / "story-03-docs.md" + desc = sync_helper.render_story_description( + docs_path, manifest, story_type="DOCS") + self.assertIn("[unresolved]", desc, + "Missing contributor key must produce [unresolved]") + self.assertIn("T-101", desc, + "Available contributor must still resolve") + + def test_new_docs_all_keys_resolves(self) -> None: + """New DOCS: all contributors created → clean resolution, safe to create.""" + manifest = self._manifest_with_keys([ + {"file": "story-01-api.md", "jira_key": "T-101"}, + {"file": "story-02-webhook.md", "jira_key": "T-102"}, + ]) + docs_path = self.stories_dir / "epic-1" / "story-03-docs.md" + desc = sync_helper.render_story_description( + docs_path, manifest, story_type="DOCS") + self.assertNotIn("[unresolved]", desc) + self.assertIn("T-101", desc) + self.assertIn("T-102", desc) + + # -- Guard for existing DOCS update (Step 6b) -- + + def test_existing_docs_update_missing_new_contributor(self) -> None: + """Update: a new contributor was added but its creation skipped.""" + # DOCS already synced, now references a new story-02 + manifest = self._manifest_with_keys([ + {"file": "story-01-api.md", "jira_key": "T-101"}, + {"file": "story-03-docs.md", "jira_key": "T-103", + "content_hash": "old", "synced_status": "active"}, + # story-02 creation failed — no key + ]) + docs_path = self.stories_dir / "epic-1" / "story-03-docs.md" + desc = sync_helper.render_story_description( + docs_path, manifest, story_type="DOCS") + self.assertIn("[unresolved]", desc, + "Update with missing contributor must show [unresolved]") + + # -- Retry lifecycle: deferred DOCS stays NEW in next detect-changes -- + + def test_deferred_docs_remains_new_for_retry(self) -> None: + """A DOCS story deferred (not checkpointed) remains NEW on next run.""" + # First sync: story-02 skipped, so DOCS was deferred (no checkpoint) + manifest_path = _make_manifest(self.artifacts_dir, [{ + "file": "epic-1-feature.md", + "jira_key": "T-100", + "title": "Feature", + "size": "M", + "content_hash": "abc", + "synced_status": "active", + "stories": [ + {"file": "story-01-api.md", "jira_key": "T-101", + "title": "[DEV] API endpoint", "content_hash": "def", + "synced_status": "active"}, + # story-02 and story-03 (DOCS) NOT in manifest = deferred + ], + }]) + plan = sync_helper.detect_changes(str(self.artifacts_dir)) + docs_items = [i for i in plan["items"] + if i["file"].endswith("story-03-docs.md")] + self.assertEqual(len(docs_items), 1) + self.assertEqual(docs_items[0]["category"], "new", + "Deferred DOCS (no checkpoint) must be NEW on next run") + + def test_retry_after_contributor_created_resolves(self) -> None: + """After the missing contributor is created and checkpointed, + the DOCS story's references resolve on the retry run.""" + # Simulate: both contributors now in manifest (retry scenario) + manifest = self._manifest_with_keys([ + {"file": "story-01-api.md", "jira_key": "T-101"}, + {"file": "story-02-webhook.md", "jira_key": "T-102"}, + # DOCS still not in manifest — it is being created now + ]) + docs_path = self.stories_dir / "epic-1" / "story-03-docs.md" + desc = sync_helper.render_story_description( + docs_path, manifest, story_type="DOCS") + self.assertNotIn("[unresolved]", desc, + "After contributor creation, refs must resolve") + self.assertIn("T-102", desc) + + def test_successful_checkpoint_makes_docs_unchanged(self) -> None: + """After DOCS creation succeeds and is checkpointed, the next + detect-changes run reports it as unchanged.""" + docs_path = self.stories_dir / "epic-1" / "story-03-docs.md" + content_hash = sync_helper.compute_file_hash(str(docs_path)) + manifest_path = _make_manifest(self.artifacts_dir, [{ + "file": "epic-1-feature.md", + "jira_key": "T-100", + "title": "Feature", + "size": "M", + "content_hash": "abc", + "synced_status": "active", + "stories": [ + {"file": "story-01-api.md", "jira_key": "T-101", + "title": "[DEV] API", "content_hash": "d1", + "synced_status": "active"}, + {"file": "story-02-webhook.md", "jira_key": "T-102", + "title": "[DEV] Webhook", "content_hash": "d2", + "synced_status": "active"}, + {"file": "story-03-docs.md", "jira_key": "T-103", + "title": "[DOCS] API reference", "content_hash": content_hash, + "synced_status": "active"}, + ], + }]) + plan = sync_helper.detect_changes(str(self.artifacts_dir)) + docs_items = [i for i in plan["items"] + if i["file"].endswith("story-03-docs.md")] + self.assertEqual(len(docs_items), 1) + self.assertEqual(docs_items[0]["category"], "unchanged", + "Checkpointed DOCS with matching hash must be unchanged") + + # -- Instruction contracts: sync.md guard and callsite text -- + + @classmethod + def _read_sync_md(cls) -> str: + sync_path = Path(__file__).resolve().parent.parent / "skills" / "sync.md" + return sync_path.read_text(encoding="utf-8") + + def test_6a_guard_precedes_prepare(self) -> None: + """Step 6a: reference-availability guard appears before the + story prepare recipe instruction.""" + sync_md = self._read_sync_md() + start = sync_md.find("#### 6a:") + end = sync_md.find("#### 6b:") + self.assertGreaterEqual(start, 0, "Step 6a must exist") + self.assertGreater(end, start, "Step 6b must follow 6a") + section = sync_md[start:end] + guard_pos = section.find("Reference-availability guard") + prepare_pos = section.find("`prepare`") + self.assertGreaterEqual(guard_pos, 0, + "6a must contain reference-availability guard") + self.assertGreaterEqual(prepare_pos, 0, + "6a must contain prepare instruction") + self.assertLess(guard_pos, prepare_pos, + "Guard must precede prepare in 6a") + + def test_6b_guard_precedes_prepare(self) -> None: + """Step 6b: reference-availability guard appears before the + story prepare recipe instruction.""" + sync_md = self._read_sync_md() + start = sync_md.find("#### 6b:") + end = sync_md.find("#### 6c:") + self.assertGreaterEqual(start, 0, "Step 6b must exist") + self.assertGreater(end, start, "Step 6c must follow 6b") + section = sync_md[start:end] + guard_pos = section.find("Reference-availability guard") + prepare_pos = section.find("`prepare`") + self.assertGreaterEqual(guard_pos, 0, + "6b must contain reference-availability guard") + self.assertGreaterEqual(prepare_pos, 0, + "6b must contain prepare instruction") + self.assertLess(guard_pos, prepare_pos, + "Guard must precede prepare in 6b") + + def test_step8_reports_blocked(self) -> None: + """Step 8 reports blocked stories with missing keys.""" + sync_md = self._read_sync_md() + start = sync_md.find("### Step 8:") + self.assertGreaterEqual(start, 0, "Step 8 must exist") + section = sync_md[start:] + self.assertIn("blocked", section) + self.assertIn("Do not claim complete synchronization", section) + + # -- Manifest identity: synced DOCS stable through detect_changes -- + + def test_synced_docs_with_late_contributor_stays_unchanged(self) -> None: + """A synced DOCS story retains its manifest identity when a + higher-numbered contributor is added. detect_changes does not + classify it as new, removed, or renamed.""" + # story-04 is a late contributor added after DOCS story-03 was synced + _make_dev_story(self.stories_dir, 1, 4, "metrics", title="Add metrics") + docs_path = self.stories_dir / "epic-1" / "story-03-docs.md" + content_hash = sync_helper.compute_file_hash(str(docs_path)) + _make_manifest(self.artifacts_dir, [{ + "file": "epic-1-feature.md", + "jira_key": "T-100", + "title": "Feature", "size": "M", + "content_hash": "abc", "synced_status": "active", + "stories": [ + {"file": "story-01-api.md", "jira_key": "T-101", + "title": "[DEV] API", "content_hash": "d1", + "synced_status": "active"}, + {"file": "story-02-webhook.md", "jira_key": "T-102", + "title": "[DEV] Webhook", "content_hash": "d2", + "synced_status": "active"}, + {"file": "story-03-docs.md", "jira_key": "T-103", + "title": "[DOCS] API reference", + "content_hash": content_hash, + "synced_status": "active"}, + # story-04 is new (not yet in manifest) + ], + }]) + plan = sync_helper.detect_changes(str(self.artifacts_dir)) + docs_items = [i for i in plan["items"] + if i["file"].endswith("story-03-docs.md")] + self.assertEqual(len(docs_items), 1) + self.assertEqual(docs_items[0]["category"], "unchanged", + "Synced DOCS must stay unchanged when late contributor added") + self.assertEqual(docs_items[0]["jira_key"], "T-103", + "Synced DOCS must retain its Jira key") + # story-04 should be new + new_items = [i for i in plan["items"] + if i["file"].endswith("story-04-metrics.md")] + self.assertEqual(len(new_items), 1) + self.assertEqual(new_items[0]["category"], "new") + + def test_synced_docs_changed_when_inputs_updated(self) -> None: + """A synced DOCS story whose content changed (e.g., updated + Documentation Inputs) is correctly classified as changed.""" + docs_path = self.stories_dir / "epic-1" / "story-03-docs.md" + old_hash = sync_helper.compute_file_hash(str(docs_path)) + _make_manifest(self.artifacts_dir, [{ + "file": "epic-1-feature.md", + "jira_key": "T-100", + "title": "Feature", "size": "M", + "content_hash": "abc", "synced_status": "active", + "stories": [ + {"file": "story-01-api.md", "jira_key": "T-101", + "title": "[DEV] API", "content_hash": "d1", + "synced_status": "active"}, + {"file": "story-02-webhook.md", "jira_key": "T-102", + "title": "[DEV] Webhook", "content_hash": "d2", + "synced_status": "active"}, + {"file": "story-03-docs.md", "jira_key": "T-103", + "title": "[DOCS] API reference", + "content_hash": old_hash, + "synced_status": "active"}, + ], + }]) + # Modify the DOCS story content + content = docs_path.read_text(encoding="utf-8") + docs_path.write_text(content + "\nUpdated Documentation Inputs.\n", encoding="utf-8") + plan = sync_helper.detect_changes(str(self.artifacts_dir)) + docs_items = [i for i in plan["items"] + if i["file"].endswith("story-03-docs.md")] + self.assertEqual(len(docs_items), 1) + self.assertEqual(docs_items[0]["category"], "changed", + "Modified DOCS must be classified as changed") + self.assertEqual(docs_items[0]["jira_key"], "T-103") + + def test_noncontributing_dependency_resolves_when_key_available(self) -> None: + """A later-numbered prerequisite dependency (in Dependencies but + NOT in Contributing stories/Documentation Inputs) resolves when + its manifest key exists. Renderer test only — does not test + live deferral/checkpoint behavior.""" + stories_dir = Path(self.tmpdir) / "06-stories" + _make_dev_story(stories_dir, 1, 4, "infra", title="Infra prereq") + # Add story-04 to Dependencies ONLY, not Contributing stories + docs_path = stories_dir / "epic-1" / "story-03-docs.md" + content = docs_path.read_text(encoding="utf-8") + # Replace only in the Dependencies section (after "## Dependencies") + dep_marker = "## Dependencies\n\n" + dep_start = content.find(dep_marker) + self.assertGreater(dep_start, 0, "Dependencies section must exist") + dep_val_start = dep_start + len(dep_marker) + dep_line_end = content.find("\n", dep_val_start) + old_deps = content[dep_val_start:dep_line_end] + content = (content[:dep_val_start] + + old_deps + ", Story 1.04" + + content[dep_line_end:]) + docs_path.write_text(content, encoding="utf-8") + # Verify Contributing stories was NOT changed + self.assertNotIn("Story 1.04", + content.split("Contributing stories:")[1].split("\n")[0] + if "Contributing stories:" in content else "", + "Story 1.04 must not be in Contributing stories") + + manifest = self._manifest_with_keys([ + {"file": "story-01-api.md", "jira_key": "T-101"}, + {"file": "story-02-webhook.md", "jira_key": "T-102"}, + {"file": "story-04-infra.md", "jira_key": "T-104"}, + ]) + desc = sync_helper.render_story_description( + docs_path, manifest, story_type="DOCS") + self.assertNotIn("[unresolved]", desc, + "Non-contributing dependency with key must resolve") + self.assertIn("T-104", desc) + + def test_noncontributing_dependency_missing_key_defers(self) -> None: + """A later-numbered prerequisite dependency without a manifest + key produces [unresolved] in the rendered description. The sync + agent uses this signal to defer the DOCS write (no Jira call or + manifest checkpoint). Renderer-only test — deferral/checkpoint + behavior is tested by state/contract tests.""" + stories_dir = Path(self.tmpdir) / "06-stories" + _make_dev_story(stories_dir, 1, 4, "infra", title="Infra prereq") + docs_path = stories_dir / "epic-1" / "story-03-docs.md" + content = docs_path.read_text(encoding="utf-8") + dep_marker = "## Dependencies\n\n" + dep_start = content.find(dep_marker) + dep_val_start = dep_start + len(dep_marker) + dep_line_end = content.find("\n", dep_val_start) + old_deps = content[dep_val_start:dep_line_end] + content = (content[:dep_val_start] + + old_deps + ", Story 1.04" + + content[dep_line_end:]) + docs_path.write_text(content, encoding="utf-8") + + manifest = self._manifest_with_keys([ + {"file": "story-01-api.md", "jira_key": "T-101"}, + {"file": "story-02-webhook.md", "jira_key": "T-102"}, + # story-04 NOT in manifest — creation pending + ]) + desc = sync_helper.render_story_description( + docs_path, manifest, story_type="DOCS") + self.assertIn("[unresolved]", desc, + "Missing dependency key must produce [unresolved]") + + # -- Instruction contract: guard in 6a/6b -- + + def test_sync_6a_guard_covers_deps_and_contributing(self) -> None: + """Step 6a guard paragraph states coverage of Dependencies and + Contributing stories directly, and delegates to the helper.""" + sync_md = self._read_sync_md() + start = sync_md.find("#### 6a:") + end = sync_md.find("#### 6b:") + self.assertGreaterEqual(start, 0) + self.assertGreater(end, start) + section_6a = sync_md[start:end] + guard_start = section_6a.find("**Reference-availability guard.**") + guard_end = section_6a.find("For each new story") + self.assertGreater(guard_start, -1, "guard paragraph must exist") + self.assertGreater(guard_end, guard_start, "prepare recipe must follow guard") + guard = section_6a[guard_start:guard_end] + guard_flat = " ".join(guard.split()) + self.assertIn("check-ref-availability", guard_flat) + # Guard itself states what the helper checks + self.assertIn("Dependencies", guard_flat) + self.assertIn("Contributing stories", guard_flat) + + +# --------------------------------------------------------------------------- +# Identity stability: allocation and retirement +# --------------------------------------------------------------------------- + +class TestIdentityStability(_TmpDirMixin, unittest.TestCase): + """Helper-level and instruction-contract tests for story identity + stability. These verify detect_changes classification and + render_story_description reference resolution behavior — not live + Jira operations or an authoring allocator.""" + + def test_append_and_retirement_classification(self) -> None: + """Helper: story-03 (above retired story-02) is classified as + new; retired story-02 (status:removed with manifest entry) is + classified as removed. Verifies gap/retirement handling.""" + stories_dir = Path(self.tmpdir) / "06-stories" + stories_dir.mkdir() + _make_epic(stories_dir, 1, "feature") + _make_dev_story(stories_dir, 1, 1, "api", title="API") + _make_dev_story(stories_dir, 1, 2, "old", title="Old", removed=True) + _make_dev_story(stories_dir, 1, 3, "webhook", title="Webhook") + _make_manifest(Path(self.tmpdir), [{ + "file": "epic-1-feature.md", + "jira_key": "T-100", "title": "Feature", "size": "M", + "content_hash": "x", "synced_status": "active", + "stories": [ + {"file": "story-01-api.md", "jira_key": "T-101", + "title": "[DEV] API", + "content_hash": sync_helper.compute_file_hash( + str(stories_dir / "epic-1" / "story-01-api.md")), + "synced_status": "active"}, + {"file": "story-02-old.md", "jira_key": "T-102", + "title": "[DEV] Old", "content_hash": "y", + "synced_status": "active"}, + ], + }]) + plan = sync_helper.detect_changes(str(Path(self.tmpdir))) + story3 = [i for i in plan["items"] + if i["file"].endswith("story-03-webhook.md")] + self.assertEqual(len(story3), 1) + self.assertEqual(story3[0]["category"], "new") + story2 = [i for i in plan["items"] + if i["file"].endswith("story-02-old.md")] + self.assertEqual(len(story2), 1) + self.assertEqual(story2[0]["category"], "removed") + + def test_identity_stability_instruction_contract(self) -> None: + """Instruction: identity stability rule requires append above + highest including retired, no gap reuse, no rename/renumber.""" + decompose_path = Path(__file__).resolve().parent.parent / "skills" / "decompose.md" + flat = " ".join(decompose_path.read_text(encoding="utf-8").split()) + self.assertIn("retired numbers must not be reused", flat) + self.assertIn("must not be renamed, renumbered, or moved", flat) + # Guidelines canonical rule + guidelines_path = Path(__file__).resolve().parent.parent / "guidelines.md" + guidelines = " ".join(guidelines_path.read_text(encoding="utf-8").split()) + self.assertIn("Identity stability", guidelines) + self.assertIn("above the highest existing", guidelines) + + def test_content_edit_retains_identity_and_key(self) -> None: + """Helper: editing content changes hash but preserves manifest + identity (filename → Jira key mapping).""" + stories_dir = Path(self.tmpdir) / "06-stories" + stories_dir.mkdir() + _make_epic(stories_dir, 1, "feature") + story_path = _make_dev_story(stories_dir, 1, 1, "api", title="API") + old_hash = sync_helper.compute_file_hash(str(story_path)) + _make_manifest(Path(self.tmpdir), [{ + "file": "epic-1-feature.md", + "jira_key": "T-100", "title": "Feature", "size": "M", + "content_hash": "x", "synced_status": "active", + "stories": [ + {"file": "story-01-api.md", "jira_key": "T-101", + "title": "[DEV] API", "content_hash": old_hash, + "synced_status": "active"}, + ], + }]) + story_path.write_text(story_path.read_text(encoding="utf-8") + "\nUpdated.\n", encoding="utf-8") + plan = sync_helper.detect_changes(str(Path(self.tmpdir))) + items = [i for i in plan["items"] + if i["file"].endswith("story-01-api.md")] + self.assertEqual(len(items), 1) + self.assertEqual(items[0]["category"], "changed") + self.assertEqual(items[0]["jira_key"], "T-101", + "Jira key must be preserved on content edit") + + def test_never_synced_removed_skipped_and_file_retained(self) -> None: + """Helper: a never-synced removed file is absent from the change + plan (not created in Jira), and the file remains on disk + reserving its number.""" + stories_dir = Path(self.tmpdir) / "06-stories" + stories_dir.mkdir() + _make_epic(stories_dir, 1, "feature") + _make_dev_story(stories_dir, 1, 1, "api", title="API") + removed_path = _make_dev_story( + stories_dir, 1, 2, "old", title="Old", removed=True) + # No manifest — fresh sync + plan = sync_helper.detect_changes(str(Path(self.tmpdir))) + story2 = [i for i in plan["items"] + if i["file"].endswith("story-02-old.md")] + self.assertEqual(len(story2), 0, + "Never-synced removed story must be absent from plan") + self.assertTrue(removed_path.exists(), + "Removed file must remain on disk") + + def test_lower_id_docs_unresolved_then_resolves(self) -> None: + """Renderer: DOCS story-02 references contributor story-03. + Without story-03 key: [unresolved] in rendered description + (sync agent uses this signal to defer). With key: resolves. + This tests render_story_description, not live deferral.""" + stories_dir = Path(self.tmpdir) / "06-stories" + stories_dir.mkdir() + _make_epic(stories_dir, 1, "feature") + _make_dev_story(stories_dir, 1, 1, "api", title="API") + _make_docs_story(stories_dir, 1, 2, "docs", + title="API reference", + deps="Story 1.01, Story 1.03", + contributing_stories="Story 1.01, Story 1.03", + doc_inputs="**Story 1.01:** endpoint.\n" + "**Story 1.03:** webhook.") + _make_dev_story(stories_dir, 1, 3, "webhook", title="Webhook") + docs_path = stories_dir / "epic-1" / "story-02-docs.md" + + # Without story-03 key + manifest_no_key = {"epics": [{"file": "epic-1-feature.md", + "jira_key": "T-100", "stories": [ + {"file": "story-01-api.md", "jira_key": "T-101"}]}]} + desc1 = sync_helper.render_story_description( + docs_path, manifest_no_key, story_type="DOCS") + self.assertIn("[unresolved]", desc1, + "Missing key must produce [unresolved]") + + # With story-03 key + manifest_with_key = {"epics": [{"file": "epic-1-feature.md", + "jira_key": "T-100", "stories": [ + {"file": "story-01-api.md", "jira_key": "T-101"}, + {"file": "story-03-webhook.md", "jira_key": "T-103"}]}]} + desc2 = sync_helper.render_story_description( + docs_path, manifest_with_key, story_type="DOCS") + self.assertNotIn("[unresolved]", desc2) + self.assertIn("T-103", desc2) + + +# --------------------------------------------------------------------------- +# Epic combine: parent removal cascades children +# --------------------------------------------------------------------------- + +class TestEpicCombineCascade(_TmpDirMixin, unittest.TestCase): + """Verify that marking a parent epic status:removed causes + detect_changes to reclassify its active synced children as removed, + and that keeping the parent active preserves child classification.""" + + def _setup_epic_with_child(self, parent_removed: bool) -> dict: + """Create epic-1 with one synced story. Return change plan.""" + stories_dir = Path(self.tmpdir) / "06-stories" + stories_dir.mkdir() + ep_content = "---\nstatus: removed\n---\n" if parent_removed else "---\n---\n" + ep_content += "# Epic 1: Feature\n\n## Summary\n\nBuild.\n\n## Acceptance Criteria\n\n- AC\n" + (stories_dir / "epic-1-feature.md").write_text(ep_content, encoding="utf-8") + ep1 = stories_dir / "epic-1" + ep1.mkdir() + story = ep1 / "story-01-api.md" + story.write_text( + "---\n---\n# Story 1.01: [DEV] API\n\n**As a** dev,\n" + "**I want to** build,\n**So that** done.\n\n" + "## Acceptance Criteria\n\n- AC\n\n" + "## Implementation Guidance\n\nDo.\n\n" + "## Testing Approach\n\nTest.\n\n" + "## Dependencies\n\nNone\n\n" + "## Design Reference\n\nEpic: Epic 1 — Feature\n", + encoding="utf-8") + # Manifest: both synced, child content changed (different hash) + _make_manifest(Path(self.tmpdir), [{ + "file": "epic-1-feature.md", "jira_key": "T-100", + "title": "Feature", "size": "M", + "content_hash": "old_epic_hash", "synced_status": "active", + "stories": [{ + "file": "story-01-api.md", "jira_key": "T-101", + "title": "[DEV] API", "content_hash": "DIFFERENT", + "synced_status": "active" + }] + }]) + return sync_helper.detect_changes(str(Path(self.tmpdir))) + + def test_removed_parent_cascades_changed_child_to_removed(self) -> None: + """When parent epic is status:removed and synced child's content + changed, detect_changes reclassifies the child as removed.""" + plan = self._setup_epic_with_child(parent_removed=True) + child = [i for i in plan["items"] + if i["file"].endswith("story-01-api.md")] + self.assertEqual(len(child), 1) + self.assertEqual(child[0]["category"], "removed", + "Changed child under removed parent must be 'removed'") + + def test_active_parent_preserves_changed_child(self) -> None: + """When parent epic is active (not removed), changed child + stays classified as 'changed' with qualified identity intact.""" + plan = self._setup_epic_with_child(parent_removed=False) + child = [i for i in plan["items"] + if i["file"].endswith("story-01-api.md")] + self.assertEqual(len(child), 1) + self.assertEqual(child[0]["category"], "changed", + "Changed child under active parent must stay 'changed'") + self.assertEqual(child[0]["jira_key"], "T-101", + "Jira key preserved when parent stays active") + # Verify qualified identity from actual helper output + self.assertEqual(child[0]["file"], "epic-1/story-01-api.md", + "Qualified file path must include parent epic dir") + self.assertEqual(child[0]["epic_index"], 1) + self.assertEqual(child[0]["story_index"], 1) + + def test_revise_retain_work_guard(self) -> None: + """Revise combining-epics instruction guards against marking + parent removed while children should remain active.""" + revise_path = Path(__file__).resolve().parent.parent / "skills" / "revise.md" + flat = " ".join(revise_path.read_text(encoding="utf-8").split()) + guard_pos = flat.find("do **not** mark the absorbed epic") + retire_pos = flat.find("Only mark the epic removed when all") + unsupported_pos = flat.find("not supported by the current file-authoritative model") + self.assertGreaterEqual(guard_pos, 0, + "Must guard against marking active-child parent removed") + self.assertGreaterEqual(retire_pos, 0, + "Must explain when removal is safe") + self.assertGreater(retire_pos, guard_pos, + "Retirement instruction must follow guard") + self.assertGreaterEqual(unsupported_pos, 0, + "Must flag physical reparent as unsupported") + + +# --------------------------------------------------------------------------- +# Instruction contracts: review identity criterion + coverage pointer +# --------------------------------------------------------------------------- + +class TestInstructionContracts(unittest.TestCase): + """Instruction-level contracts for review rubric and template refs.""" + + def test_review_rubric_identity_criterion(self) -> None: + """Decomposition review criterion 9 checks identity consistency.""" + review_path = Path(__file__).resolve().parent.parent / "decomposition-review.md" + review_md = review_path.read_text(encoding="utf-8") + crit9_start = review_md.find("9. **Identity consistency**") + finding_format = review_md.find("## Finding Format") + self.assertGreaterEqual(crit9_start, 0, "Criterion 9 must exist") + self.assertGreater(finding_format, crit9_start) + crit9 = review_md[crit9_start:finding_format] + self.assertIn("renaming, renumbering, moving, or reusing", crit9) + self.assertIn("do not invent allocation history", crit9) + + def test_coverage_pointer_is_step7(self) -> None: + """05-epics template coverage pointer references Step 7.""" + decompose_path = Path(__file__).resolve().parent.parent / "skills" / "decompose.md" + content = decompose_path.read_text(encoding="utf-8") + idx = content.find("is the authoritative source for requirement-to-story") + self.assertGreaterEqual(idx, 0, "Coverage pointer must exist") + line = content[max(0, idx - 20):idx + 80] + self.assertIn("(Step 7)", line, + "Coverage pointer must reference Step 7") + + +# --------------------------------------------------------------------------- +# Creation-order planning +# --------------------------------------------------------------------------- + +class TestPlanCreationOrder(_TmpDirMixin, unittest.TestCase): + """Tests for plan_creation_order using actual fixture file identities. + + Helper-level tests verify the planner's dependency graph, topological + sort, cycle/missing-target detection, and key reuse. Instruction + contracts verify sync.md consumer references. NOT live Jira/LLM E2E.""" + + def _setup(self, specs): + """Create artifacts. specs: [(epic, story, slug, type, deps, contrib, removed)].""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + paths = {} + for eidx, sidx, slug, stype, deps, contrib, removed in specs: + _make_epic(sd, eidx, f"epic{eidx}") + if stype == "DOCS": + p = _make_docs_story(sd, eidx, sidx, slug, + title=f"{slug} docs", deps=deps, + contributing_stories=contrib or "None", removed=removed) + else: + p = _make_dev_story(sd, eidx, sidx, slug, + title=slug, deps=deps, removed=removed) + paths[(eidx, sidx)] = p + return sd, paths + + def _item(self, eidx, sidx, slug, cat="new", key=None, title=""): + return {"type": "story", "category": cat, + "epic_index": eidx, "story_index": sidx, + "jira_key": key, "title": title, + "file": f"epic-{eidx}/story-{sidx:02d}-{slug}.md"} + + def _epic_item(self, eidx, cat="new", key=None): + """Epic item matching the file _setup creates.""" + return {"type": "epic", "category": cat, + "epic_index": eidx, "jira_key": key, + "file": f"epic-{eidx}-epic{eidx}.md"} + + def _qids(self, result): + return [f"story-{it['epic_index']}.{it['story_index']:02d}" + for it in result["ordered"]] + + # ── Core ordering ────────────────────────────────────────── + + def test_linear_chain(self) -> None: + """A→B→C multi-hop: C after B after A.""" + self._setup([(1,1,"a","DEV","None",None,False), + (1,2,"b","DEV","Story 1.01",None,False), + (1,3,"c","DEV","Story 1.02",None,False)]) + items = [self._epic_item(1)] + [ + self._item(1,i,["a","b","c"][i-1]) for i in [1,2,3]] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertEqual(self._qids(r), + ["story-1.01","story-1.02","story-1.03"]) + self.assertEqual(r["errors"], []) + self.assertEqual(r["blocked"], []) + + def test_reversed_cross_epic(self) -> None: + """Story 1.01 depends on Story 2.01 (numerically earlier epic + needs later epic's story — must still sort correctly).""" + self._setup([(1,1,"client","DEV","Story 2.01",None,False), + (2,1,"api","DEV","None",None,False)]) + items = [self._epic_item(1), self._epic_item(2), + self._item(1,1,"client"), self._item(2,1,"api")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertEqual(self._qids(r), ["story-2.01","story-1.01"]) + self.assertEqual(r["errors"], []) + + def test_existing_key_satisfies(self) -> None: + """Already-synced story satisfies dependency without recreation.""" + self._setup([(1,1,"api","DEV","None",None,False), + (1,2,"client","DEV","Story 1.01",None,False)]) + manifest = {"epics": [{"file": "epic-1-epic1.md", "jira_key": "T-100", + "stories": [{"file": "story-01-api.md","jira_key":"T-101"}]}]} + items = [self._epic_item(1, key="T-100"), + self._item(1,1,"api","unchanged","T-101"), + self._item(1,2,"client")] + r = sync_helper.plan_creation_order(items, self.tmpdir, manifest) + self.assertEqual(len(r["ordered"]), 1) + self.assertEqual(r["ordered"][0]["story_index"], 2) + self.assertIn("story-1.01", r["satisfied_keys"]) + + # ── DOCS Contributing-only ordering ──────────────────────── + + def test_docs_contrib_ordering_not_jira_dep(self) -> None: + """DOCS Contributing stories affect ORDER but do NOT appear in + the Dependencies section of the DOCS PREP output.""" + self._setup([(1,1,"api","DEV","None",None,False), + (1,2,"docs","DOCS","None","Story 1.01",False)]) + items = [self._epic_item(1), self._item(1,1,"api"), + self._item(1,2,"docs",title="[DOCS] docs docs")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertEqual(self._qids(r)[0], "story-1.01", + "Contributor must be created first") + # Verify PREP.dependencies stays "None" (no contrib dep) + docs_path = Path(self.tmpdir) / "06-stories/epic-1/story-02-docs.md" + prep = sync_helper.prepare( + str(docs_path), str(Path(self.tmpdir) / "sync-manifest.json"), + "story", story_type="DOCS") + self.assertNotIn("T-", prep.get("dependencies", ""), + "Contributing stories must not create Jira dep links") + + # ── Cycle detection ──────────────────────────────────────── + + def test_self_cycle(self) -> None: + """Story depends on itself → self-reference (not merely a cycle).""" + self._setup([(1,1,"a","DEV","Story 1.01",None,False)]) + items = [self._epic_item(1), self._item(1,1,"a")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertTrue( + any("self-reference" in e for e in r["errors"]), + f"Expected self-reference error, got: {r['errors']}") + self.assertEqual(len(r["ordered"]), 0) + + def test_two_node_cycle(self) -> None: + """A↔B mutual dependency → cycle, both blocked.""" + self._setup([(1,1,"a","DEV","Story 1.02",None,False), + (1,2,"b","DEV","Story 1.01",None,False)]) + items = [self._epic_item(1), self._item(1,1,"a"), self._item(1,2,"b")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertTrue(any("cycle" in e.lower() for e in r["errors"])) + self.assertEqual(len(r["blocked"]), 2) + + def test_cycle_downstream_distinction(self) -> None: + """A↔B cycle blocks downstream C; C is not a cycle participant.""" + self._setup([(1,1,"a","DEV","Story 1.02",None,False), + (1,2,"b","DEV","Story 1.01",None,False), + (1,3,"c","DEV","Story 1.01",None,False)]) + items = [self._epic_item(1)] + [ + self._item(1,i,["a","b","c"][i-1]) for i in [1,2,3]] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + cycle_errors = [e for e in r["errors"] if "cycle" in e.lower()] + downstream_errors = [e for e in r["errors"] if "blocked" in e.lower()] + self.assertGreater(len(cycle_errors), 0) + self.assertGreater(len(downstream_errors), 0) + # C should be in downstream, not cycle + self.assertTrue(any("story-1.03" in e for e in downstream_errors)) + + def test_mapped_key_no_false_cycle(self) -> None: + """Mutual refs between already-keyed stories → no cycle.""" + self._setup([(1,1,"a","DEV","Story 1.02",None,False), + (1,2,"b","DEV","Story 1.01",None,False)]) + manifest = {"epics": [{"file":"epic-1-epic1.md","jira_key":"T-100", + "stories":[{"file":"story-01-a.md","jira_key":"T-101"}, + {"file":"story-02-b.md","jira_key":"T-102"}]}]} + items = [self._item(1,1,"a","unchanged","T-101"), + self._item(1,2,"b","unchanged","T-102")] + r = sync_helper.plan_creation_order(items, self.tmpdir, manifest) + self.assertEqual(r["errors"], []) + self.assertEqual(len(r["ordered"]), 0) + + # ── Missing/unavailable targets ──────────────────────────── + + def test_missing_nonexistent_target(self) -> None: + """Dependency on nonexistent Story 9.99 → error.""" + self._setup([(1,1,"a","DEV","Story 9.99",None,False)]) + items = [self._item(1,1,"a")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertTrue(any("9.99" in e for e in r["errors"])) + + def test_removed_no_key_target_unavailable(self) -> None: + """Dependency on a removed story with no manifest key → error.""" + self._setup([(1,1,"a","DEV","Story 1.02",None,False), + (1,2,"old","DEV","None",None,True)]) + items = [self._item(1,1,"a"), + {"type":"story","category":"removed","epic_index":1, + "story_index":2,"jira_key":None, + "file":"epic-1/story-02-old.md","title":"old"}] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertTrue(any("no usable" in e.lower() for e in r["errors"])) + + # ── Changed/reopened write checks ────────────────────────── + + def test_changed_docs_satisfiable_by_creation_plan(self) -> None: + """Changed DOCS references new story-03 that IS in creation plan + → not blocked at plan time (runtime guard checks after creation).""" + self._setup([(1,1,"api","DEV","None",None,False), + (1,2,"docs","DOCS","Story 1.03","Story 1.03",False), + (1,3,"wh","DEV","None",None,False)]) + manifest = {"epics":[{"file":"epic-1-epic1.md","jira_key":"T-100", + "stories":[{"file":"story-01-api.md","jira_key":"T-101"}, + {"file":"story-02-docs.md","jira_key":"T-102", + "content_hash":"old","synced_status":"active"}]}]} + items = [self._item(1,1,"api","unchanged","T-101"), + self._item(1,2,"docs","changed","T-102", + title="[DOCS] docs docs"), + self._item(1,3,"wh")] + r = sync_helper.plan_creation_order(items, self.tmpdir, manifest) + # story-03 in creation plan satisfies the changed DOCS ref + self.assertEqual(len(r["ordered"]), 1) + self.assertEqual(r["ordered"][0]["story_index"], 3) + self.assertEqual(len(r["blocked"]), 0) + + def test_changed_docs_blocked_unsatisfiable_ref(self) -> None: + """Changed DOCS references a story that has no key and is NOT + in the creation plan → blocked.""" + self._setup([(1,1,"api","DEV","None",None,False), + (1,2,"docs","DOCS","Story 1.03","Story 1.03",False)]) + # story-03 doesn't exist at all + manifest = {"epics":[{"file":"epic-1-epic1.md","jira_key":"T-100", + "stories":[{"file":"story-01-api.md","jira_key":"T-101"}, + {"file":"story-02-docs.md","jira_key":"T-102", + "content_hash":"old","synced_status":"active"}]}]} + items = [self._item(1,1,"api","unchanged","T-101"), + self._item(1,2,"docs","changed","T-102", + title="[DOCS] docs docs")] + r = sync_helper.plan_creation_order(items, self.tmpdir, manifest) + blocked_changed = [b for b in r["blocked"] + if b["item"].get("category") == "changed"] + self.assertGreater(len(blocked_changed), 0, + "Changed DOCS with unsatisfiable ref must be blocked") + + # ── Multi-ref and deterministic ──────────────────────────── + + def test_multi_ref_list(self) -> None: + """'Story 1.01, Story 2.01' both prerequisites for 2.02.""" + self._setup([(1,1,"a","DEV","None",None,False), + (2,1,"b","DEV","None",None,False), + (2,2,"c","DEV","Story 1.01, Story 2.01",None,False)]) + items = [self._epic_item(1), self._epic_item(2), + self._item(1,1,"a"), self._item(2,1,"b"), + self._item(2,2,"c")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + ids = self._qids(r) + self.assertGreater(ids.index("story-2.02"), ids.index("story-1.01")) + self.assertGreater(ids.index("story-2.02"), ids.index("story-2.01")) + + def test_deterministic_shuffled_input(self) -> None: + """Same result regardless of input item order.""" + self._setup([(1,1,"a","DEV","None",None,False), + (1,2,"b","DEV","None",None,False), + (2,1,"c","DEV","None",None,False)]) + epics = [self._epic_item(1), self._epic_item(2)] + items_fwd = epics + [self._item(1,1,"a"),self._item(1,2,"b"),self._item(2,1,"c")] + items_rev = epics + list(reversed([self._item(1,1,"a"),self._item(1,2,"b"),self._item(2,1,"c")])) + r1 = sync_helper.plan_creation_order(items_fwd, self.tmpdir, None) + r2 = sync_helper.plan_creation_order(items_rev, self.tmpdir, None) + self.assertEqual(self._qids(r1), self._qids(r2)) + + # ── CLI ──────────────────────────────────────────────────── + + def test_cli_success(self) -> None: + """CLI plan-creation-order returns JSON with expected keys.""" + self._setup([(1,1,"a","DEV","None",None,False)]) + import subprocess + r = subprocess.run( + [sys.executable, "-I", + str(Path(__file__).resolve().parent / "sync_helper.py"), + "plan-creation-order", self.tmpdir], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 0) + out = json.loads(r.stdout) + for k in ("ordered", "errors", "blocked", "satisfied_keys"): + self.assertIn(k, out) + + # ── Instruction contracts ────────────────────────────────── + + def test_sync_step3_calls_plan_and_preflight(self) -> None: + sync_path = Path(__file__).resolve().parent.parent / "skills" / "sync.md" + c = sync_path.read_text(encoding="utf-8") + s3 = c.find("### Step 3:"); s4 = c.find("### Step 4:") + self.assertGreaterEqual(s3, 0); self.assertGreater(s4, s3) + step3 = c[s3:s4] + self.assertIn("plan-creation-order", step3) + self.assertIn("Preflight check", step3) + self.assertIn("stop", step3.lower()) + + def test_sync_preflight_before_epic_writes(self) -> None: + c = Path(__file__).resolve().parent.parent / "skills" / "sync.md" + content = c.read_text(encoding="utf-8") + pf = content.find("Preflight check") + s5 = content.find("### Step 5:") + self.assertGreaterEqual(pf, 0) + self.assertGreater(s5, pf) + + def test_sync_6a_uses_planned_order(self) -> None: + c = Path(__file__).resolve().parent.parent / "skills" / "sync.md" + content = c.read_text(encoding="utf-8") + s6a = content.find("#### 6a:"); s6b = content.find("#### 6b:") + self.assertGreaterEqual(s6a, 0); self.assertGreater(s6b, s6a) + section = " ".join(content[s6a:s6b].split()) + self.assertIn("Step 3 creation plan", section) + self.assertIn("Reference-availability guard", section) + self.assertIn("manifest is reloaded fresh", section) + + +# --------------------------------------------------------------------------- +# Test: Epic ref extraction, native Jira keys, CLI exit codes, +# unavailable-ref exclusion, and check_ref_availability +# (fixes for proven remaining issues 1–4) +# --------------------------------------------------------------------------- + + +class TestEpicRefExtraction(unittest.TestCase): + """Verify _extract_epic_refs parses Epic N / Epics N, M syntax.""" + + def test_single_epic(self) -> None: + result = sync_helper._extract_epic_refs("Epic 1") + self.assertEqual(result, ["1"]) + + def test_multiple_epics(self) -> None: + result = sync_helper._extract_epic_refs("Epics 1, 2, 3") + self.assertEqual(result, ["1", "2", "3"]) + + def test_no_epic_refs(self) -> None: + result = sync_helper._extract_epic_refs("No references here.") + self.assertEqual(result, []) + + def test_mixed_with_story(self) -> None: + result = sync_helper._extract_epic_refs("Epic 2 and Story 1.01") + self.assertEqual(result, ["2"]) + + +class TestJiraKeyRegex(unittest.TestCase): + """Verify _JIRA_KEY_RE matches native Jira keys.""" + + def test_standard_key(self) -> None: + keys = sync_helper._JIRA_KEY_RE.findall("Depends on PROJ-123.") + self.assertEqual(keys, ["PROJ-123"]) + + def test_multiple_keys(self) -> None: + keys = sync_helper._JIRA_KEY_RE.findall("EDM-101, EDM-202") + self.assertEqual(keys, ["EDM-101", "EDM-202"]) + + def test_no_match(self) -> None: + keys = sync_helper._JIRA_KEY_RE.findall("Story 1.01, Epic 2") + self.assertEqual(keys, []) + + +class TestPlanCreationOrderEpicRefs(_TmpDirMixin, unittest.TestCase): + """Tests for plan_creation_order with epic references (Issue 1).""" + + def _setup(self, specs): + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + for eidx, sidx, slug, stype, deps, contrib, removed in specs: + _make_epic(sd, eidx, f"epic{eidx}") + if stype == "DOCS": + _make_docs_story(sd, eidx, sidx, slug, + title=f"{slug} docs", deps=deps, + contributing_stories=contrib or "None", removed=removed) + else: + _make_dev_story(sd, eidx, sidx, slug, + title=slug, deps=deps, removed=removed) + return sd + + def _item(self, eidx, sidx, slug, cat="new", key=None, title=""): + return {"type": "story", "category": cat, + "epic_index": eidx, "story_index": sidx, + "jira_key": key, "title": title, + "file": f"epic-{eidx}/story-{sidx:02d}-{slug}.md"} + + def _epic_item(self, eidx, slug, cat="new", key=None): + return {"type": "epic", "category": cat, + "epic_index": eidx, "jira_key": key, + "file": f"epic-{eidx}-{slug}.md"} + + def _qids(self, result): + return [f"story-{it['epic_index']}.{it['story_index']:02d}" + for it in result["ordered"]] + + def test_epic_ref_mapped_available(self) -> None: + """Dependencies: Epic 1 where Epic 1 is mapped → available.""" + self._setup([(1, 1, "a", "DEV", "Epic 1", None, False)]) + manifest = {"epics": [{"file": "epic-1-epic1.md", "jira_key": "T-100", + "stories": []}]} + items = [self._item(1, 1, "a"), + self._epic_item(1, "epic1", "unchanged", "T-100")] + r = sync_helper.plan_creation_order(items, self.tmpdir, manifest) + self.assertEqual(r["errors"], []) + self.assertEqual(len(r["ordered"]), 1) + + def test_epic_ref_nonexistent_fails(self) -> None: + """Dependencies: Epic 999 → error.""" + self._setup([(1, 1, "a", "DEV", "Epic 999", None, False)]) + items = [self._epic_item(1, "epic1"), + self._item(1, 1, "a")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertTrue(any("epic-999" in e for e in r["errors"])) + self.assertEqual(len(r["ordered"]), 0) + self.assertGreater(len(r["blocked"]), 0) + + def test_mixed_story_and_epic_refs(self) -> None: + """Dependencies: Story 1.01, Epic 2 with multiple ref types.""" + self._setup([ + (1, 1, "api", "DEV", "None", None, False), + (2, 1, "client", "DEV", "Story 1.01, Epic 2", None, False), + ]) + manifest = {"epics": [ + {"file": "epic-1-epic1.md", "jira_key": "T-100", "stories": []}, + {"file": "epic-2-epic2.md", "jira_key": "T-200", "stories": []}, + ]} + items = [ + self._item(1, 1, "api"), + self._item(2, 1, "client"), + self._epic_item(1, "epic1", "unchanged", "T-100"), + self._epic_item(2, "epic2", "unchanged", "T-200"), + ] + r = sync_helper.plan_creation_order(items, self.tmpdir, manifest) + self.assertEqual(r["errors"], []) + # story-1.01 must be before story-2.01 + qids = self._qids(r) + self.assertIn("story-1.01", qids) + self.assertIn("story-2.01", qids) + self.assertLess(qids.index("story-1.01"), qids.index("story-2.01")) + + def test_new_epic_ref_planned_step5(self) -> None: + """Dependencies: Epic 1 where Epic 1 is new → ok (will be created).""" + self._setup([(1, 1, "a", "DEV", "Epic 1", None, False)]) + items = [self._item(1, 1, "a"), + self._epic_item(1, "epic1", "new")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertEqual(r["errors"], []) + self.assertEqual(len(r["ordered"]), 1) + + def test_native_jira_key_in_deps_available(self) -> None: + """Dependencies: PROJ-123 → accepted as available, not local target.""" + self._setup([(1, 1, "a", "DEV", "PROJ-123", None, False)]) + items = [self._epic_item(1, "epic1"), + self._item(1, 1, "a")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertEqual(r["errors"], []) + self.assertEqual(len(r["ordered"]), 1) + self.assertIn("PROJ-123", r["satisfied_keys"]) + + def test_native_jira_key_with_story_refs(self) -> None: + """Dependencies: PROJ-123, Story 1.01 — both handled correctly.""" + self._setup([ + (1, 1, "api", "DEV", "None", None, False), + (1, 2, "client", "DEV", "PROJ-123, Story 1.01", None, False), + ]) + items = [self._epic_item(1, "epic1"), + self._item(1, 1, "api"), self._item(1, 2, "client")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertEqual(r["errors"], []) + qids = self._qids(r) + self.assertLess(qids.index("story-1.01"), qids.index("story-1.02")) + + +class TestDocsContribOrderingWithDetect(_TmpDirMixin, unittest.TestCase): + """DOCS Contributing ordering with real detect_changes items.""" + + def test_docs_contrib_ordering_real_detect(self) -> None: + """DOCS 1.01 with no Dependencies and Contributing ref to Story 2.01. + Plan must order 2.01 before 1.01.""" + arts = Path(self.tmpdir) / "art" + sd = arts / "06-stories" + sd.mkdir(parents=True) + _make_epic(sd, 1, "docs-epic") + _make_epic(sd, 2, "dev-epic") + # DOCS story with Contributing ref only (no Dependencies) + _make_docs_story(sd, 1, 1, "user-guide", + title="User guide docs", + deps="None", + contributing_stories="Story 2.01") + # DEV story that the DOCS story references + _make_dev_story(sd, 2, 1, "api-endpoint", + title="API endpoint", deps="None") + + # Use real detect_changes to get items + plan = sync_helper.detect_changes(arts) + items = plan["items"] + + r = sync_helper.plan_creation_order(items, str(arts), None) + self.assertEqual(r["errors"], []) + qids = [f"story-{it['epic_index']}.{it['story_index']:02d}" + for it in r["ordered"]] + self.assertIn("story-2.01", qids) + self.assertIn("story-1.01", qids) + self.assertLess(qids.index("story-2.01"), qids.index("story-1.01"), + "Contributing story 2.01 must be ordered before DOCS 1.01") + + # Verify PREP.dependencies stays "None" (no contrib dep) + docs_path = sd / "epic-1" / "story-01-user-guide.md" + prep = sync_helper.prepare( + str(docs_path), str(arts / "sync-manifest.json"), + "story", story_type="DOCS") + # Contributing ref should NOT appear in dependencies + self.assertEqual(prep["dependencies"].strip(), "None") + + +class TestCLIPlanCreationOrderExitCodes(_TmpDirMixin, unittest.TestCase): + """CLI plan-creation-order exit code tests (Issue 2).""" + + def setUp(self) -> None: + super().setUp() + import subprocess + self.subprocess = subprocess + self.arts = Path(self.tmpdir) / "art" + self.sd = self.arts / "06-stories" + self.sd.mkdir(parents=True) + + def test_valid_plan_returns_zero(self) -> None: + """Valid plan with no errors → exit 0.""" + _make_epic(self.sd, 1, "building") + _make_dev_story(self.sd, 1, 1, "scaffold", deps="None") + r = self.subprocess.run( + [sys.executable, "-I", str(_SCRIPT), + "plan-creation-order", str(self.arts)], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 0) + out = json.loads(r.stdout) + self.assertEqual(out["errors"], []) + + def test_cycle_returns_nonzero(self) -> None: + """Cycle in dependencies → exit 1.""" + _make_epic(self.sd, 1, "building") + _make_dev_story(self.sd, 1, 1, "a", deps="Story 1.02") + _make_dev_story(self.sd, 1, 2, "b", deps="Story 1.01") + r = self.subprocess.run( + [sys.executable, "-I", str(_SCRIPT), + "plan-creation-order", str(self.arts)], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 1) + out = json.loads(r.stdout) + self.assertTrue(any("cycle" in e.lower() for e in out["errors"])) + + def test_missing_target_returns_nonzero(self) -> None: + """Missing target → exit 1.""" + _make_epic(self.sd, 1, "building") + _make_dev_story(self.sd, 1, 1, "a", deps="Story 9.99") + r = self.subprocess.run( + [sys.executable, "-I", str(_SCRIPT), + "plan-creation-order", str(self.arts)], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 1) + out = json.loads(r.stdout) + self.assertTrue(any("9.99" in e for e in out["errors"])) + + def test_invalid_epic_ref_returns_nonzero(self) -> None: + """Epic 999 reference → exit 1.""" + _make_epic(self.sd, 1, "building") + _make_dev_story(self.sd, 1, 1, "a", deps="Epic 999") + r = self.subprocess.run( + [sys.executable, "-I", str(_SCRIPT), + "plan-creation-order", str(self.arts)], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 1) + out = json.loads(r.stdout) + self.assertTrue(any("epic-999" in e for e in out["errors"])) + + +class TestUnavailableRefExclusion(_TmpDirMixin, unittest.TestCase): + """Unavailable ref owners and downstream excluded from ordered (Issue 3).""" + + def _setup(self, specs): + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + for eidx, sidx, slug, deps in specs: + _make_epic(sd, eidx, f"epic{eidx}") + _make_dev_story(sd, eidx, sidx, slug, deps=deps) + return sd + + def _item(self, eidx, sidx, slug): + return {"type": "story", "category": "new", + "epic_index": eidx, "story_index": sidx, + "jira_key": None, "title": "", + "file": f"epic-{eidx}/story-{sidx:02d}-{slug}.md"} + + def _epic_item(self, eidx): + return {"type": "epic", "category": "new", + "epic_index": eidx, "jira_key": None, + "file": f"epic-{eidx}-epic{eidx}.md"} + + def _qids(self, result): + return [f"story-{it['epic_index']}.{it['story_index']:02d}" + for it in result["ordered"]] + + def test_unavailable_owner_excluded(self) -> None: + """Story with dep on nonexistent target → not in ordered.""" + self._setup([(1, 1, "a", "Story 9.99")]) + items = [self._epic_item(1), self._item(1, 1, "a")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertEqual(len(r["ordered"]), 0, + "Story with unavailable ref must not be in ordered") + self.assertGreater(len(r["blocked"]), 0) + self.assertTrue(any("9.99" in e for e in r["errors"])) + + def test_downstream_of_unavailable_excluded(self) -> None: + """Story depending on story with unavailable ref → also excluded.""" + self._setup([ + (1, 1, "a", "Story 9.99"), + (1, 2, "b", "Story 1.01"), + (1, 3, "c", "None"), + ]) + items = [self._epic_item(1), self._item(1, 1, "a"), + self._item(1, 2, "b"), self._item(1, 3, "c")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + qids = self._qids(r) + # Only story-1.03 (no deps) should be in ordered + self.assertIn("story-1.03", qids) + self.assertNotIn("story-1.01", qids, + "Owner of unavailable ref must not be in ordered") + self.assertNotIn("story-1.02", qids, + "Downstream of unavailable ref must not be in ordered") + # Both 1.01 and 1.02 should be blocked + blocked_qids = [f"story-{b['item']['epic_index']}.{b['item']['story_index']:02d}" + for b in r["blocked"]] + self.assertIn("story-1.01", blocked_qids) + self.assertIn("story-1.02", blocked_qids) + + def test_independent_stories_still_ordered(self) -> None: + """Independent stories unaffected by blocked chain.""" + self._setup([ + (1, 1, "a", "Story 9.99"), + (2, 1, "b", "None"), + (2, 2, "c", "Story 2.01"), + ]) + items = [self._epic_item(1), self._epic_item(2), + self._item(1, 1, "a"), self._item(2, 1, "b"), + self._item(2, 2, "c")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + qids = self._qids(r) + self.assertIn("story-2.01", qids) + self.assertIn("story-2.02", qids) + self.assertNotIn("story-1.01", qids) + + def test_unavailable_epic_ref_excludes_owner(self) -> None: + """Epic 999 ref → owner excluded from ordered.""" + self._setup([(1, 1, "a", "Epic 999")]) + items = [self._epic_item(1), self._item(1, 1, "a")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertEqual(len(r["ordered"]), 0) + self.assertTrue(any("epic-999" in e for e in r["errors"])) + + +class TestCheckRefAvailability(_TmpDirMixin, unittest.TestCase): + """Tests for check_ref_availability (Issue 4).""" + + def setUp(self) -> None: + super().setUp() + self.arts = Path(self.tmpdir) / "art" + self.sd = self.arts / "06-stories" + self.sd.mkdir(parents=True) + self.mf = self.arts / "sync-manifest.json" + + def test_all_refs_available(self) -> None: + """Story with all refs in manifest → ready.""" + _make_epic(self.sd, 1, "building") + _make_dev_story(self.sd, 1, 1, "scaffold", deps="Story 1.02") + _make_dev_story(self.sd, 1, 2, "other", deps="None") + _make_manifest(self.arts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [ + {"file": "story-01-scaffold.md", "jira_key": "EDM-201", + "content_hash": "h", "synced_status": "active"}, + {"file": "story-02-other.md", "jira_key": "EDM-202", + "content_hash": "h", "synced_status": "active"}, + ], + }]) + r = sync_helper.check_ref_availability( + ["story-1.01"], str(self.arts), str(self.mf)) + self.assertEqual(len(r["ready"]), 1) + self.assertEqual(len(r["blocked"]), 0) + + def test_missing_ref_blocked(self) -> None: + """Story with missing ref → blocked.""" + _make_epic(self.sd, 1, "building") + _make_dev_story(self.sd, 1, 1, "scaffold", deps="Story 9.99") + _make_manifest(self.arts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [ + {"file": "story-01-scaffold.md", "jira_key": "EDM-201", + "content_hash": "h", "synced_status": "active"}, + ], + }]) + r = sync_helper.check_ref_availability( + ["story-1.01"], str(self.arts), str(self.mf)) + self.assertEqual(len(r["ready"]), 0) + self.assertEqual(len(r["blocked"]), 1) + self.assertTrue(any("story-9.99" in m for m in r["blocked"][0]["missing"])) + + def test_after_recording_key_becomes_ready(self) -> None: + """After recording a key, story becomes ready.""" + _make_epic(self.sd, 1, "building") + _make_dev_story(self.sd, 1, 1, "scaffold", deps="Story 1.02") + _make_dev_story(self.sd, 1, 2, "other", deps="None") + + # Initially: story-02 has no key → story-01 blocked + _make_manifest(self.arts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [ + {"file": "story-01-scaffold.md", "jira_key": "EDM-201", + "content_hash": "h", "synced_status": "active"}, + ], + }]) + r1 = sync_helper.check_ref_availability( + ["story-1.01"], str(self.arts), str(self.mf)) + self.assertEqual(len(r1["blocked"]), 1) + + # Record key for story-02 + sync_helper.update_manifest( + str(self.mf), "create", "story-02-other.md", "EDM-202", + "h", item_type="story", epic_file="epic-1-building.md") + + # Now story-01 should be ready + r2 = sync_helper.check_ref_availability( + ["story-1.01"], str(self.arts), str(self.mf)) + self.assertEqual(len(r2["ready"]), 1) + self.assertEqual(len(r2["blocked"]), 0) + + def test_epic_key_required(self) -> None: + """Owning epic must have a key for story creation.""" + _make_epic(self.sd, 1, "building") + _make_dev_story(self.sd, 1, 1, "scaffold", deps="None") + # No manifest → epic has no key + r = sync_helper.check_ref_availability( + ["story-1.01"], str(self.arts), str(self.mf)) + self.assertEqual(len(r["blocked"]), 1) + self.assertTrue(any("epic-1" in m for m in r["blocked"][0]["missing"])) + + def test_no_manifest_mutation(self) -> None: + """check_ref_availability must not create or modify manifest.""" + _make_epic(self.sd, 1, "building") + _make_dev_story(self.sd, 1, 1, "scaffold", deps="None") + _make_manifest(self.arts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [], + }]) + original = self.mf.read_bytes() + sync_helper.check_ref_availability( + ["story-1.01"], str(self.arts), str(self.mf)) + self.assertEqual(self.mf.read_bytes(), original, + "Manifest must not be modified by availability check") + + def test_native_jira_key_always_available(self) -> None: + """Native Jira key in Dependencies → always available.""" + _make_epic(self.sd, 1, "building") + _make_dev_story(self.sd, 1, 1, "scaffold", deps="PROJ-123") + _make_manifest(self.arts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [], + }]) + r = sync_helper.check_ref_availability( + ["story-1.01"], str(self.arts), str(self.mf)) + self.assertEqual(len(r["ready"]), 1) + self.assertEqual(len(r["blocked"]), 0) + + def test_epic_ref_in_deps_checked(self) -> None: + """Epic ref in Dependencies must be available.""" + _make_epic(self.sd, 1, "building") + _make_dev_story(self.sd, 1, 1, "scaffold", deps="Epic 2") + _make_manifest(self.arts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [], + }]) + r = sync_helper.check_ref_availability( + ["story-1.01"], str(self.arts), str(self.mf)) + self.assertEqual(len(r["blocked"]), 1) + self.assertTrue(any("epic-2" in m for m in r["blocked"][0]["missing"])) + + def test_unreadable_source_blocked(self) -> None: + """Unreadable artifact file → blocked with useful message.""" + _make_epic(self.sd, 1, "building") + # Don't create the story file — it won't exist + _make_manifest(self.arts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [], + }]) + r = sync_helper.check_ref_availability( + ["story-1.01"], str(self.arts), str(self.mf)) + self.assertEqual(len(r["blocked"]), 1) + self.assertIn("not found", r["blocked"][0]["reason"]) + + def test_cli_check_ref_availability_ready(self) -> None: + """CLI check-ref-availability returns 0 when all ready.""" + import subprocess + _make_epic(self.sd, 1, "building") + _make_dev_story(self.sd, 1, 1, "scaffold", deps="None") + _make_manifest(self.arts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [], + }]) + r = subprocess.run( + [sys.executable, "-I", str(_SCRIPT), + "check-ref-availability", str(self.arts), "story-1.01"], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 0) + out = json.loads(r.stdout) + self.assertEqual(len(out["ready"]), 1) + + def test_cli_check_ref_availability_blocked(self) -> None: + """CLI check-ref-availability returns 1 when blocked.""" + import subprocess + _make_epic(self.sd, 1, "building") + _make_dev_story(self.sd, 1, 1, "scaffold", deps="Story 9.99") + _make_manifest(self.arts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [], + }]) + r = subprocess.run( + [sys.executable, "-I", str(_SCRIPT), + "check-ref-availability", str(self.arts), "story-1.01"], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 1) + out = json.loads(r.stdout) + self.assertEqual(len(out["blocked"]), 1) + + +class TestUnreadableSourceInPlanner(_TmpDirMixin, unittest.TestCase): + """Unreadable source file produces error with useful message.""" + + def test_missing_artifact_file_error(self) -> None: + """Story with missing file → error.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "epic1") + # Create item but DON'T create the actual file + items = [{"type": "story", "category": "new", + "epic_index": 1, "story_index": 1, + "jira_key": None, "title": "", + "file": "epic-1/story-01-missing.md"}] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertTrue(any("not found" in e for e in r["errors"])) + self.assertEqual(len(r["ordered"]), 0) + + def test_unreadable_source_mocked(self) -> None: + """Unreadable artifact → error with useful message (portable).""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "epic1") + _make_dev_story(sd, 1, 1, "a", deps="None") + items = [{"type": "story", "category": "new", + "epic_index": 1, "story_index": 1, + "jira_key": None, "title": "", + "file": "epic-1/story-01-a.md"}] + # Mock read_text to raise OSError for the story file + original_read_text = Path.read_text + + def _failing_read(self_path, *args, **kwargs): + if "story-01-a.md" in str(self_path): + raise OSError("Permission denied (mocked)") + return original_read_text(self_path, *args, **kwargs) + + with mock.patch.object(Path, "read_text", _failing_read): + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertTrue(any("cannot read" in e for e in r["errors"])) + self.assertEqual(len(r["ordered"]), 0) + + +# --------------------------------------------------------------------------- +# Test: changed/reopened invalid refs produce errors + CLI nonzero +# --------------------------------------------------------------------------- + +class TestChangedReopenedInvalidRefs(_TmpDirMixin, unittest.TestCase): + """Changed/reopened stories with invalid refs must fail structural + preflight (errors non-empty, CLI nonzero) BEFORE Step 5 writes.""" + + def _item(self, eidx, sidx, slug, cat="new", key=None, title=""): + return {"type": "story", "category": cat, + "epic_index": eidx, "story_index": sidx, + "jira_key": key, "title": title, + "file": f"epic-{eidx}/story-{sidx:02d}-{slug}.md"} + + def test_changed_story_invalid_ref_produces_error(self) -> None: + """Changed story with dep on Story 9.99 → error in plan.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="None") + _make_dev_story(sd, 1, 2, "client", deps="Story 9.99") + manifest = {"epics": [{"file": "epic-1-building.md", "jira_key": "T-100", + "stories": [{"file": "story-01-api.md", "jira_key": "T-101"}, + {"file": "story-02-client.md", "jira_key": "T-102", + "content_hash": "old", "synced_status": "active"}]}]} + items = [ + self._item(1, 1, "api", "unchanged", "T-101"), + self._item(1, 2, "client", "changed", "T-102"), + ] + r = sync_helper.plan_creation_order(items, self.tmpdir, manifest) + self.assertTrue(any("9.99" in e for e in r["errors"]), + f"Expected error about 9.99, got: {r['errors']}") + + def test_changed_story_invalid_epic_ref_produces_error(self) -> None: + """Changed story with dep on Epic 999 → error in plan.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "client", deps="Epic 999") + manifest = {"epics": [{"file": "epic-1-building.md", "jira_key": "T-100", + "stories": [{"file": "story-01-client.md", "jira_key": "T-101", + "content_hash": "old", "synced_status": "active"}]}]} + items = [self._item(1, 1, "client", "changed", "T-101")] + r = sync_helper.plan_creation_order(items, self.tmpdir, manifest) + self.assertTrue(any("epic-999" in e for e in r["errors"]), + f"Expected error about epic-999, got: {r['errors']}") + + def test_changed_valid_planned_target_no_error(self) -> None: + """Changed story refs a new story in creation plan → no error.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="None") + _make_dev_story(sd, 1, 2, "client", deps="Story 1.03") + _make_dev_story(sd, 1, 3, "new", deps="None") + manifest = {"epics": [{"file": "epic-1-building.md", "jira_key": "T-100", + "stories": [{"file": "story-01-api.md", "jira_key": "T-101"}, + {"file": "story-02-client.md", "jira_key": "T-102", + "content_hash": "old", "synced_status": "active"}]}]} + items = [ + self._item(1, 1, "api", "unchanged", "T-101"), + self._item(1, 2, "client", "changed", "T-102"), + self._item(1, 3, "new"), + ] + r = sync_helper.plan_creation_order(items, self.tmpdir, manifest) + self.assertEqual(r["errors"], [], + "Valid planned target should not produce errors") + + def test_cli_nonzero_on_changed_invalid_ref(self) -> None: + """CLI plan-creation-order returns nonzero when changed story has invalid ref.""" + import subprocess + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 9.99") + _make_manifest(Path(self.tmpdir), [{ + "file": "epic-1-building.md", "jira_key": "T-100", + "content_hash": "h", "synced_status": "active", + "stories": [{"file": "story-01-api.md", "jira_key": "T-101", + "content_hash": "old", "synced_status": "active"}], + }]) + r = subprocess.run( + [sys.executable, "-I", + str(Path(__file__).resolve().parent / "sync_helper.py"), + "plan-creation-order", self.tmpdir], + capture_output=True, text=True, timeout=10) + self.assertNotEqual(r.returncode, 0, + "CLI must return nonzero for changed story with invalid ref") + out = json.loads(r.stdout) + self.assertTrue(any("9.99" in e for e in out.get("errors", []))) + + +# --------------------------------------------------------------------------- +# Test: sync.md consumer contracts for reference-availability guard +# --------------------------------------------------------------------------- + +class TestSyncConsumerContracts(unittest.TestCase): + """Verify sync.md consumer instructions reference the executable guard + and pin behavioral contracts for blocked/fatal handling.""" + + @classmethod + def setUpClass(cls) -> None: + import re as _re + sync_path = Path(__file__).resolve().parent.parent / "skills" / "sync.md" + cls._sync_md = sync_path.read_text(encoding="utf-8") + m6a = _re.search(r"#### 6a:.*?(?=#### 6b:)", cls._sync_md, _re.DOTALL) + assert m6a, "6a section not found" + cls._section_6a = m6a.group() + m6b = _re.search(r"#### 6b:.*?(?=#### 6c:)", cls._sync_md, _re.DOTALL) + assert m6b, "6b section not found" + cls._section_6b = m6b.group() + # Extract the guard paragraph from 6a (between guard heading and + # the next consumer "For each new story") + g_start = cls._section_6a.find("**Reference-availability guard.**") + g_end = cls._section_6a.find("For each new story") + assert g_start >= 0 and g_end > g_start, "guard bounds missing" + cls._guard_6a = cls._section_6a[g_start:g_end] + # Extract the 6b guard paragraph (between guard heading and + # the next consumer "For each story categorized") + gb_start = cls._section_6b.find("**Reference-availability guard.**") + gb_end = cls._section_6b.find("For each story categorized") + assert gb_start >= 0 and gb_end > gb_start, "6b guard bounds missing" + cls._guard_6b = cls._section_6b[gb_start:gb_end] + + # -- Guard-before-prepare ordering --- + + def test_6a_calls_check_ref_availability(self) -> None: + """Step 6a guard calls check-ref-availability before prepare recipe.""" + check_pos = self._section_6a.find("check-ref-availability") + prepare_recipe_pos = self._section_6a.find("story `prepare` recipe") + self.assertGreater(check_pos, -1) + self.assertGreater(prepare_recipe_pos, -1) + self.assertLess(check_pos, prepare_recipe_pos) + + def test_6b_calls_check_ref_availability_before_prepare(self) -> None: + """Step 6b guard calls check-ref-availability before prepare recipe.""" + check_pos = self._section_6b.find("check-ref-availability") + prepare_pos = self._section_6b.find("story `prepare`") + self.assertGreater(check_pos, -1) + self.assertGreater(prepare_pos, -1) + self.assertLess(check_pos, prepare_pos) + + def test_helper_reference_documents_check_ref(self) -> None: + """Helper reference documents check-ref-availability.""" + ref_path = (Path(__file__).resolve().parent.parent + / "references" / "sync-helper-usage.md") + content = ref_path.read_text(encoding="utf-8") + self.assertIn("check-ref-availability", content) + self.assertIn("ready", content) + self.assertIn("blocked", content) + + def test_preflight_catches_changed_invalid_refs(self) -> None: + """Step 3 preflight covers changed/reopened invalid refs.""" + import re + m3 = re.search(r"### Step 3:.*?(?=### Step 4:)", self._sync_md, re.DOTALL) + self.assertIsNotNone(m3) + section = m3.group() + self.assertIn("changed", section.lower()) + self.assertIn("reopened", section.lower()) + + # -- 6a guard: blocked → no prepare/render/create/checkpoint --- + + def test_6a_blocked_no_prepare_no_render_no_create_no_checkpoint(self) -> None: + """6a blocked: no prepare, no render, no Jira create, no checkpoint.""" + self.assertIn("do not call `prepare`", self._guard_6a) + self.assertIn("do not render", self._guard_6a) + self.assertIn("do not create the Jira", self._guard_6a) + self.assertIn("manifest checkpoint", self._guard_6a) + + def test_6a_blocked_recorded_for_step8_with_missing(self) -> None: + """6a blocked: recorded for Step 8 with missing refs and reason.""" + self.assertIn("Step 8 report", self._guard_6a) + self.assertIn("`missing` refs", self._guard_6a) + self.assertIn("`reason`", self._guard_6a) + + # -- 6b guard: blocked → no prepare/render/update/reopen/checkpoint --- + + def test_6b_blocked_no_prepare_no_update_no_reopen_no_checkpoint(self) -> None: + """6b blocked/failed: no prepare, render, update, reopen, or checkpoint.""" + self.assertIn("`prepare`", self._guard_6b) + self.assertIn("render", self._guard_6b) + self.assertIn("update", self._guard_6b.lower()) + self.assertIn("reopen", self._guard_6b.lower()) + self.assertIn("manifest checkpoint", self._guard_6b) + + # -- 6a fatal: catch-all stops sync --- + + def test_6a_fatal_catch_all_stops_sync(self) -> None: + """6a catch-all: stops sync, reports captured text, no skip.""" + self.assertIn("Stop the sync", self._guard_6a) + self.assertIn("REF_ERR_TEXT", self._guard_6a) + # Check no-skip wording (may wrap across lines) + guard_flat = " ".join(self._guard_6a.split()) + self.assertIn("Do not skip the story", guard_flat) + + def test_6a_catch_all_covers_exit0_invalid(self) -> None: + """6a catch-all enumerates exit 0 with invalid JSON as failure.""" + guard_flat = " ".join(self._guard_6a.split()) + self.assertIn("exit 0 with invalid json", guard_flat.lower()) + + def test_6a_catch_all_covers_conflicting_qid(self) -> None: + """6a catch-all enumerates QID in both lists as failure.""" + self.assertIn("both lists", self._guard_6a.lower()) + + # -- Errexit-safe capture idiom --- + + def test_6a_errexit_safe_capture(self) -> None: + """6a guard uses || REF_EXIT=$? idiom for errexit safety.""" + self.assertIn("|| REF_EXIT=$?", self._guard_6a) + + def test_6b_references_same_capture_idiom(self) -> None: + """6b guard references the same capture idiom.""" + self.assertIn("|| REF_EXIT=$?", self._guard_6b) + + # -- Step 3 preflight invariant --- + + def test_preflight_blocked_implies_errors(self) -> None: + """Step 3: every blocked item has a root-cause error entry.""" + import re + m3 = re.search(r"### Step 3:.*?(?=### Step 4:)", self._sync_md, re.DOTALL) + section = m3.group() + self.assertIn("Every `blocked` item has at least one root-cause", section) + + def test_preflight_blocked_implies_errors_planner(self) -> None: + """Planner: a blocked item always has a corresponding error entry. + Verified via negative case: cycle produces both blocked and errors.""" + sd = Path(tempfile.mkdtemp()) / "06-stories" + sd.mkdir(parents=True) + _make_epic(sd, 1, "ep") + _make_dev_story(sd, 1, 1, "a", deps="Story 1.02") + _make_dev_story(sd, 1, 2, "b", deps="Story 1.01") + items = [ + {"type": "story", "category": "new", "epic_index": 1, + "story_index": 1, "jira_key": None, "title": "", + "file": "epic-1/story-01-a.md"}, + {"type": "story", "category": "new", "epic_index": 1, + "story_index": 2, "jira_key": None, "title": "", + "file": "epic-1/story-02-b.md"}, + ] + r = sync_helper.plan_creation_order(items, str(sd.parent), None) + self.assertGreater(len(r["blocked"]), 0, "cycle must produce blocked") + self.assertGreater(len(r["errors"]), 0, "cycle must produce errors") + import shutil + shutil.rmtree(str(sd.parent)) + + # -- Step 8 blocked reporting --- + + def test_step8_reports_blocked_with_missing_keys(self) -> None: + """Step 8 must report blocked stories with missing keys/reasons.""" + import re + m8 = re.search(r"### Step 8:.*", self._sync_md, re.DOTALL) + self.assertIsNotNone(m8) + step8 = m8.group() + self.assertIn("blocked", step8.lower()) + self.assertIn("missing", step8.lower()) + self.assertIn("reason", step8.lower()) + + # -- Step 3 no unreachable blocked-count wording --- + + def test_step3_no_unreachable_blocked_success_path(self) -> None: + """Step 3: no 'blocked is non-empty but errors is empty' wording.""" + import re + m3 = re.search(r"### Step 3:.*?(?=### Step 4:)", self._sync_md, re.DOTALL) + section = m3.group() + self.assertNotIn("blocked` is non-empty but `errors` is empty", section) + + +# --------------------------------------------------------------------------- +# Test: DOCS Contributing with mapped parents + runtime guard + PREP +# --------------------------------------------------------------------------- + +class TestDocsContribRuntimeReady(_TmpDirMixin, unittest.TestCase): + """DOCS Contributing with mapped parents: runtime guard ready, + PREP.description has Jira key, PREP.dependencies stays None.""" + + def test_docs_contrib_with_mapped_parents_runtime_ready(self) -> None: + """DOCS 1.01 Contributing Story 2.01: with mapped epic parents and + contributor key, runtime guard shows ready, PREP.description contains + Jira contributor key (not [unresolved]) and PREP.dependencies is None.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "docs-epic") + _make_docs_story(sd, 1, 1, "guide", + deps="None", + contributing_stories="Story 2.01", + doc_inputs="**Story 2.01 — API:** output.") + _make_epic(sd, 2, "dev-epic") + _make_dev_story(sd, 2, 1, "api", deps="None") + mf_path = Path(self.tmpdir) / "sync-manifest.json" + mf_data = { + "schema_version": 2, "feature_key": "FEAT-100", + "synced_at": "", "epics": [ + {"file": "epic-1-docs-epic.md", "jira_key": "EDM-101", + "content_hash": "h1", "synced_status": "active", "stories": []}, + {"file": "epic-2-dev-epic.md", "jira_key": "EDM-102", + "content_hash": "h2", "synced_status": "active", + "stories": [{"file": "story-01-api.md", "jira_key": "EDM-201", + "content_hash": "h3", "synced_status": "active"}]}, + ], + } + _write(mf_path, json.dumps(mf_data, indent=2)) + + # Runtime guard: story-1.01 should be ready + result = sync_helper.check_ref_availability( + ["story-1.01"], self.tmpdir, mf_path) + self.assertEqual(len(result["ready"]), 1, f"Expected ready, got: {result}") + self.assertEqual(len(result["blocked"]), 0) + + # PREP: description should contain Jira key EDM-201 + docs_path = sd / "epic-1" / "story-01-guide.md" + prep = sync_helper.prepare( + str(docs_path), str(mf_path), "story", story_type="DOCS") + self.assertIn("EDM-201", prep["description"], + "Contributing story must resolve to Jira key") + self.assertNotIn("[unresolved]", prep["description"], + "No unresolved refs when all are mapped") + # PREP.dependencies should be "None" + self.assertEqual(prep["dependencies"].strip(), "None", + "Dependencies must remain None for DOCS with only Contributing refs") + + # Verify no manifest mutation + mf_after = Path(mf_path).read_text(encoding="utf-8") + self.assertEqual(mf_after, json.dumps(mf_data, indent=2), + "Manifest must not be mutated") + + def test_fresh_manifest_missing_then_ready_after_key(self) -> None: + """Same-path: missing key → blocked, record key → ready.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 1.02") + _make_dev_story(sd, 1, 2, "lib", deps="None") + mf_path = Path(self.tmpdir) / "sync-manifest.json" + mf_data = {"schema_version": 2, "feature_key": "FEAT-100", + "synced_at": "", "epics": [ + {"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": []}]} + _write(mf_path, json.dumps(mf_data, indent=2)) + + # Before recording: story-1.01 is blocked (needs story-1.02) + r1 = sync_helper.check_ref_availability( + ["story-1.01"], self.tmpdir, mf_path) + self.assertEqual(len(r1["blocked"]), 1) + self.assertIn("story-1.02", r1["blocked"][0]["missing"]) + + # Record story-1.02 key in manifest + sync_helper.update_manifest( + mf_path, "create", "story-02-lib.md", "EDM-202", "abc", + item_type="story", epic_file="epic-1-building.md") + + # After recording: story-1.01 is ready + r2 = sync_helper.check_ref_availability( + ["story-1.01"], self.tmpdir, mf_path) + self.assertEqual(len(r2["ready"]), 1) + self.assertEqual(len(r2["blocked"]), 0) + + def test_failed_parent_blocks_dependent_independent_ready(self) -> None: + """Failed prerequisite blocks dependent; independent story is ready.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="None") + _make_dev_story(sd, 1, 2, "client", deps="Story 1.03") + _make_dev_story(sd, 1, 3, "missing-dep", deps="None") + mf_path = Path(self.tmpdir) / "sync-manifest.json" + mf_data = {"schema_version": 2, "feature_key": "FEAT-100", + "synced_at": "", "epics": [ + {"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": []}]} + _write(mf_path, json.dumps(mf_data, indent=2)) + + result = sync_helper.check_ref_availability( + ["story-1.01", "story-1.02"], self.tmpdir, mf_path) + ready_qids = [r["qid"] for r in result["ready"]] + blocked_qids = [b["qid"] for b in result["blocked"]] + self.assertIn("story-1.01", ready_qids, + "Independent story must be ready") + self.assertIn("story-1.02", blocked_qids, + "Dependent on missing must be blocked") + + def test_ambiguous_file_blocked(self) -> None: + """Multiple files matching same story index → blocked with diagnostic.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api") + _write(sd / "epic-1" / "story-01-also.md", + "---\n---\n# Story 1.01: [DEV] Also\n\n" + "**As a** dev,\n**I want to** x,\n**So that** y.\n\n" + "## Acceptance Criteria\n\n- AC\n\n" + "## Implementation Guidance\n\nDo.\n\n" + "## Testing Approach\n\nTest.\n\n" + "## Dependencies\n\nNone\n\n" + "## Design Reference\n\nEpic: Epic 1\n") + mf_path = Path(self.tmpdir) / "sync-manifest.json" + mf_data = {"schema_version": 2, "feature_key": "FEAT-100", + "synced_at": "", "epics": [ + {"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": []}]} + _write(mf_path, json.dumps(mf_data, indent=2)) + result = sync_helper.check_ref_availability( + ["story-1.01"], self.tmpdir, mf_path) + self.assertEqual(len(result["blocked"]), 1) + self.assertIn("ambiguous", result["blocked"][0]["reason"]) + + +# --------------------------------------------------------------------------- +# Test: QID fullmatch regression (finding 4458710793) +# --------------------------------------------------------------------------- + +class TestQIDFullmatch(_TmpDirMixin, unittest.TestCase): + """check_ref_availability must reject QIDs with trailing garbage.""" + + def setUp(self) -> None: + super().setUp() + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="None") + self.mf = Path(self.tmpdir) / "sync-manifest.json" + _write(self.mf, json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": []}]})) + + def test_trailing_garbage_rejected(self) -> None: + """story-1.01abc must not be accepted as story-1.01.""" + result = sync_helper.check_ref_availability( + ["story-1.01abc"], self.tmpdir, self.mf) + self.assertEqual(len(result["blocked"]), 1) + self.assertIn("invalid", result["blocked"][0]["reason"]) + + def test_trailing_newline_rejected(self) -> None: + """story-1.01\\n must not be accepted.""" + result = sync_helper.check_ref_availability( + ["story-1.01\n"], self.tmpdir, self.mf) + self.assertEqual(len(result["blocked"]), 1) + + def test_valid_qid_with_mapped_parent_is_ready(self) -> None: + """story-1.01 (exact) with mapped epic and no deps → ready with + actual ready entry and empty blocked.""" + result = sync_helper.check_ref_availability( + ["story-1.01"], self.tmpdir, self.mf) + self.assertEqual(len(result["ready"]), 1, + "Valid QID with mapped parent + no deps must be ready") + self.assertEqual(result["ready"][0]["qid"], "story-1.01") + self.assertEqual(len(result["blocked"]), 0, + "Blocked must be empty for a ready story") + + def test_cli_trailing_garbage_blocked(self) -> None: + """CLI check-ref-availability with trailing garbage → blocked.""" + import subprocess + r = subprocess.run( + [sys.executable, "-I", + str(Path(__file__).resolve().parent / "sync_helper.py"), + "check-ref-availability", self.tmpdir, "story-1.01xyz"], + capture_output=True, text=True, timeout=10) + self.assertNotEqual(r.returncode, 0) + out = json.loads(r.stdout) + self.assertEqual(len(out["blocked"]), 1) + self.assertIn("invalid", out["blocked"][0]["reason"]) + + +# --------------------------------------------------------------------------- +# Test: CLI fatal-vs-blocked distinction (finding 4458710829) +# --------------------------------------------------------------------------- + +class TestCLIFatalVsBlocked(_TmpDirMixin, unittest.TestCase): + """Distinguish structured blocked JSON (exit 1) from fatal stderr + (exit 1, no JSON) for check-ref-availability and plan-creation-order. + Instruction contracts, not live Jira/LLM E2E.""" + + def test_check_ref_blocked_produces_json_with_qid_and_missing(self) -> None: + """Blocked story → exit 1 with valid JSON containing matching QID, + missing refs list, empty ready.""" + import subprocess + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 9.99") + _write(Path(self.tmpdir) / "sync-manifest.json", json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": []}]})) + r = subprocess.run( + [sys.executable, "-I", + str(Path(__file__).resolve().parent / "sync_helper.py"), + "check-ref-availability", self.tmpdir, "story-1.01"], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 1) + out = json.loads(r.stdout) + self.assertEqual(len(out["ready"]), 0, "ready must be empty") + self.assertEqual(len(out["blocked"]), 1) + self.assertEqual(out["blocked"][0]["qid"], "story-1.01") + self.assertIn("story-9.99", out["blocked"][0]["missing"]) + + def test_check_ref_corrupted_manifest_fatal_no_json(self) -> None: + """Corrupted manifest → exit 1 with stderr, NO valid JSON.""" + import subprocess + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="None") + _write(Path(self.tmpdir) / "sync-manifest.json", "NOT JSON {{{") + r = subprocess.run( + [sys.executable, "-I", + str(Path(__file__).resolve().parent / "sync_helper.py"), + "check-ref-availability", self.tmpdir, "story-1.01"], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 1) + self.assertIn("ERROR", r.stderr) + # stdout should NOT contain valid JSON + with self.assertRaises(json.JSONDecodeError): + json.loads(r.stdout) + + def test_plan_creation_errors_produces_json(self) -> None: + """plan-creation-order with errors → exit 1 with valid JSON.""" + import subprocess + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "a", deps="Story 1.02") + _make_dev_story(sd, 1, 2, "b", deps="Story 1.01") + r = subprocess.run( + [sys.executable, "-I", + str(Path(__file__).resolve().parent / "sync_helper.py"), + "plan-creation-order", self.tmpdir], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 1) + out = json.loads(r.stdout) # Must be valid JSON + self.assertTrue(out["errors"]) + + def test_check_ref_ready_produces_json(self) -> None: + """Ready story → exit 0 with valid JSON containing QID in ready.""" + import subprocess + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="None") + _write(Path(self.tmpdir) / "sync-manifest.json", json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": []}]})) + r = subprocess.run( + [sys.executable, "-I", + str(Path(__file__).resolve().parent / "sync_helper.py"), + "check-ref-availability", self.tmpdir, "story-1.01"], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 0) + out = json.loads(r.stdout) + ready_qids = [e["qid"] for e in out["ready"]] + self.assertIn("story-1.01", ready_qids) + + +# --------------------------------------------------------------------------- +# Test: self-reference detection (R2) +# --------------------------------------------------------------------------- + +class TestDetectSelfRefs(unittest.TestCase): + """Unit tests for _detect_self_refs helper.""" + + def test_direct_qid_match(self) -> None: + """Direct own-qid in refs → detected.""" + result = sync_helper._detect_self_refs( + "story-1.01", ["story-1.01", "story-2.01"], [], + "", {}) + self.assertEqual(result, ["story-1.01"]) + + def test_native_key_match(self) -> None: + """Native Jira key matching own key → detected.""" + result = sync_helper._detect_self_refs( + "story-1.01", [], ["EDM-201"], + "EDM-201", {}) + self.assertEqual(result, ["EDM-201"]) + + def test_resolved_alias_match(self) -> None: + """Ref resolving to own key via available map → detected.""" + available = {"story-2.01": "EDM-201"} + result = sync_helper._detect_self_refs( + "story-1.01", ["story-2.01"], [], + "EDM-201", available) + self.assertEqual(result, ["story-2.01"]) + + def test_no_self_ref_peer(self) -> None: + """Legitimate peer ref with different key → not detected.""" + available = {"story-2.01": "EDM-202"} + result = sync_helper._detect_self_refs( + "story-1.01", ["story-2.01"], ["PROJ-999"], + "EDM-201", available) + self.assertEqual(result, []) + + def test_no_key_no_native_match(self) -> None: + """No own key: native keys cannot match → empty.""" + result = sync_helper._detect_self_refs( + "story-1.01", ["story-2.01"], ["EDM-999"], + "", {}) + self.assertEqual(result, []) + + def test_dedup_multiple_self_refs(self) -> None: + """Multiple self-ref paths: deduplicated.""" + available = {"story-1.01": "EDM-201"} + result = sync_helper._detect_self_refs( + "story-1.01", + ["story-1.01", "story-1.01"], + ["EDM-201"], + "EDM-201", available) + # story-1.01 appears once (direct), EDM-201 once (native) + self.assertEqual(len(result), 2) + self.assertIn("story-1.01", result) + self.assertIn("EDM-201", result) + + +class TestSelfRefInPlanner(_TmpDirMixin, unittest.TestCase): + """Self-references must BLOCK the source in plan_creation_order.""" + + def test_new_story_self_dep_blocked(self) -> None: + """New story with Dependencies = own story → blocked.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 1.01") + items = [{"type": "epic", "category": "new", + "epic_index": 1, "jira_key": None, + "file": "epic-1-building.md"}, + {"type": "story", "category": "new", + "epic_index": 1, "story_index": 1, + "jira_key": None, "title": "", + "file": "epic-1/story-01-api.md"}] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertTrue( + any("self-reference" in e for e in r["errors"]), + f"Expected self-reference error, got: {r['errors']}") + self.assertEqual(len(r["ordered"]), 0) + self.assertGreater(len(r["blocked"]), 0) + + def test_changed_story_mapped_self_ref_blocked(self) -> None: + """Changed story with own qid in deps (mapped key) → blocked.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 1.01") + manifest = {"epics": [{"file": "epic-1-building.md", + "jira_key": "T-100", + "stories": [{"file": "story-01-api.md", + "jira_key": "T-101", + "content_hash": "old", + "synced_status": "active"}]}]} + items = [ + {"type": "story", "category": "changed", "epic_index": 1, + "story_index": 1, "jira_key": "T-101", "title": "", + "file": "epic-1/story-01-api.md"}, + ] + r = sync_helper.plan_creation_order(items, self.tmpdir, manifest) + self.assertTrue( + any("self-reference" in e for e in r["errors"]), + f"Expected self-reference error, got: {r['errors']}") + self.assertGreater(len(r["blocked"]), 0) + + def test_changed_story_native_key_self_ref(self) -> None: + """Changed story with own Jira key in deps → blocked.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="TE-101") + manifest = {"epics": [{"file": "epic-1-building.md", + "jira_key": "TE-100", + "stories": [{"file": "story-01-api.md", + "jira_key": "TE-101", + "content_hash": "old", + "synced_status": "active"}]}]} + items = [ + {"type": "story", "category": "changed", "epic_index": 1, + "story_index": 1, "jira_key": "TE-101", "title": "", + "file": "epic-1/story-01-api.md"}, + ] + r = sync_helper.plan_creation_order(items, self.tmpdir, manifest) + self.assertTrue( + any("self-reference" in e for e in r["errors"]), + f"Expected self-reference error, got: {r['errors']}") + + def test_reopened_story_self_ref_blocked(self) -> None: + """Reopened story with self-dep → blocked.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 1.01") + manifest = {"epics": [{"file": "epic-1-building.md", + "jira_key": "T-100", + "stories": [{"file": "story-01-api.md", + "jira_key": "T-101", + "content_hash": "old", + "synced_status": "closed"}]}]} + items = [ + {"type": "story", "category": "reopened", "epic_index": 1, + "story_index": 1, "jira_key": "T-101", "title": "", + "file": "epic-1/story-01-api.md"}, + ] + r = sync_helper.plan_creation_order(items, self.tmpdir, manifest) + self.assertTrue( + any("self-reference" in e for e in r["errors"]), + f"Expected self-reference error, got: {r['errors']}") + + def test_docs_contributing_self_ref_blocked(self) -> None: + """DOCS story with Contributing = own story → blocked.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "docs-epic") + _make_docs_story(sd, 1, 1, "guide", + deps="None", + contributing_stories="Story 1.01") + items = [{"type": "story", "category": "new", + "epic_index": 1, "story_index": 1, + "jira_key": None, "title": "[DOCS] guide", + "file": "epic-1/story-01-guide.md"}] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertTrue( + any("self-reference" in e for e in r["errors"]), + f"Expected self-reference error, got: {r['errors']}") + self.assertEqual(len(r["ordered"]), 0) + + def test_peer_ref_not_blocked(self) -> None: + """Legitimate peer dep (different story) → not blocked.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 1.02") + _make_dev_story(sd, 1, 2, "lib", deps="None") + items = [ + {"type": "epic", "category": "new", "epic_index": 1, + "jira_key": None, "file": "epic-1-building.md"}, + {"type": "story", "category": "new", "epic_index": 1, + "story_index": 1, "jira_key": None, "title": "", + "file": "epic-1/story-01-api.md"}, + {"type": "story", "category": "new", "epic_index": 1, + "story_index": 2, "jira_key": None, "title": "", + "file": "epic-1/story-02-lib.md"}, + ] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertFalse( + any("self-reference" in e for e in r["errors"]), + "Peer ref must NOT produce self-reference error") + self.assertEqual(len(r["ordered"]), 2) + + def test_unmapped_new_cycle_detected(self) -> None: + """Two NEW unmapped stories with A→B→A → cycle detected.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "a", deps="Story 1.02") + _make_dev_story(sd, 1, 2, "b", deps="Story 1.01") + items = [ + {"type": "epic", "category": "new", "epic_index": 1, + "jira_key": None, "file": "epic-1-building.md"}, + {"type": "story", "category": "new", "epic_index": 1, + "story_index": 1, "jira_key": None, "title": "", + "file": "epic-1/story-01-a.md"}, + {"type": "story", "category": "new", "epic_index": 1, + "story_index": 2, "jira_key": None, "title": "", + "file": "epic-1/story-02-b.md"}, + ] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertTrue(any("cycle" in e.lower() for e in r["errors"]), + "Multi-story cycle must produce cycle error") + self.assertEqual(len(r["ordered"]), 0) + + def test_mapped_peer_cycle_accepted_at_runtime(self) -> None: + """Two MAPPED stories with A→B→A: runtime guard accepts each + individually (they have distinct keys; cycle is a creation-time + concern, not a runtime-update concern).""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "a", deps="Story 1.02") + _make_dev_story(sd, 1, 2, "b", deps="Story 1.01") + mf = Path(self.tmpdir) / "sync-manifest.json" + _write(mf, json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [ + {"file": "story-01-a.md", "jira_key": "EDM-201", + "content_hash": "h2", "synced_status": "active"}, + {"file": "story-02-b.md", "jira_key": "EDM-202", + "content_hash": "h3", "synced_status": "active"} + ]}]})) + # Each story depends on the other — distinct keys, so no self-ref + r1 = sync_helper.check_ref_availability( + ["story-1.01"], self.tmpdir, mf) + self.assertEqual(len(r1["ready"]), 1, + "A→B with distinct keys: A should be ready") + r2 = sync_helper.check_ref_availability( + ["story-1.02"], self.tmpdir, mf) + self.assertEqual(len(r2["ready"]), 1, + "B→A with distinct keys: B should be ready") + + def test_self_ref_no_mutation(self) -> None: + """Self-ref detection must not mutate the manifest.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 1.01") + mf = Path(self.tmpdir) / "sync-manifest.json" + manifest = {"schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", + "jira_key": "EDM-101", + "content_hash": "h", + "synced_status": "active", + "stories": []}]} + _write(mf, json.dumps(manifest, indent=2)) + items = [{"type": "story", "category": "new", + "epic_index": 1, "story_index": 1, + "jira_key": None, "title": "", + "file": "epic-1/story-01-api.md"}] + original = mf.read_bytes() + sync_helper.plan_creation_order(items, self.tmpdir, manifest) + self.assertEqual(mf.read_bytes(), original, + "Manifest must not be mutated by planner") + + +class TestSelfRefInRuntime(_TmpDirMixin, unittest.TestCase): + """Self-references must BLOCK in check_ref_availability.""" + + def test_runtime_self_ref_qid_blocked(self) -> None: + """Story referencing own qid → blocked at runtime.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 1.01") + mf = Path(self.tmpdir) / "sync-manifest.json" + _write(mf, json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [{"file": "story-01-api.md", + "jira_key": "EDM-201", + "content_hash": "h2", + "synced_status": "active"}]}]})) + r = sync_helper.check_ref_availability( + ["story-1.01"], self.tmpdir, mf) + self.assertEqual(len(r["blocked"]), 1) + self.assertIn("self-reference", r["blocked"][0]["reason"]) + self.assertEqual(len(r["ready"]), 0) + + def test_runtime_self_ref_native_key_blocked(self) -> None: + """Story with own native key in deps → blocked at runtime.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="EDM-201") + mf = Path(self.tmpdir) / "sync-manifest.json" + _write(mf, json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [{"file": "story-01-api.md", + "jira_key": "EDM-201", + "content_hash": "h2", + "synced_status": "active"}]}]})) + r = sync_helper.check_ref_availability( + ["story-1.01"], self.tmpdir, mf) + self.assertEqual(len(r["blocked"]), 1) + self.assertIn("self-reference", r["blocked"][0]["reason"]) + # Self-ref IDs in invalid_refs, not missing (they exist but are invalid) + self.assertEqual(r["blocked"][0]["missing"], []) + self.assertIn("EDM-201", r["blocked"][0]["invalid_refs"]) + + def test_runtime_docs_contributing_self_blocked(self) -> None: + """DOCS story Contributing self-ref → blocked at runtime.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "docs-epic") + _make_docs_story(sd, 1, 1, "guide", + deps="None", + contributing_stories="Story 1.01") + mf = Path(self.tmpdir) / "sync-manifest.json" + _write(mf, json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-docs-epic.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [{"file": "story-01-guide.md", + "jira_key": "EDM-301", + "content_hash": "h2", + "synced_status": "active"}]}]})) + r = sync_helper.check_ref_availability( + ["story-1.01"], self.tmpdir, mf) + self.assertEqual(len(r["blocked"]), 1) + self.assertIn("self-reference", r["blocked"][0]["reason"]) + + def test_runtime_peer_ref_ready(self) -> None: + """Legitimate peer ref (different story) → ready at runtime.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 1.02") + _make_dev_story(sd, 1, 2, "lib", deps="None") + mf = Path(self.tmpdir) / "sync-manifest.json" + _write(mf, json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [ + {"file": "story-01-api.md", + "jira_key": "EDM-201", + "content_hash": "h2", + "synced_status": "active"}, + {"file": "story-02-lib.md", + "jira_key": "EDM-202", + "content_hash": "h3", + "synced_status": "active"}]}]})) + r = sync_helper.check_ref_availability( + ["story-1.01"], self.tmpdir, mf) + self.assertEqual(len(r["ready"]), 1) + self.assertEqual(len(r["blocked"]), 0) + + def test_runtime_self_ref_no_mutation(self) -> None: + """Self-ref blocked: manifest not mutated.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 1.01") + mf = Path(self.tmpdir) / "sync-manifest.json" + mf_data = json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [{"file": "story-01-api.md", + "jira_key": "EDM-201", + "content_hash": "h2", + "synced_status": "active"}]}]}) + _write(mf, mf_data) + original = mf.read_bytes() + sync_helper.check_ref_availability( + ["story-1.01"], self.tmpdir, mf) + self.assertEqual(mf.read_bytes(), original) + + def test_cli_self_ref_exit_nonzero(self) -> None: + """CLI check-ref-availability with self-ref → exit 1 with JSON.""" + import subprocess + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 1.01") + _write(Path(self.tmpdir) / "sync-manifest.json", json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [{"file": "story-01-api.md", + "jira_key": "EDM-201", + "content_hash": "h2", + "synced_status": "active"}]}]})) + r = subprocess.run( + [sys.executable, "-I", str(_SCRIPT), + "check-ref-availability", self.tmpdir, "story-1.01"], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 1) + out = json.loads(r.stdout) + self.assertEqual(len(out["blocked"]), 1) + self.assertIn("self-reference", out["blocked"][0]["reason"]) + + def test_cli_planner_self_ref_exit_nonzero(self) -> None: + """CLI plan-creation-order with self-ref → exit 1.""" + import subprocess + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 1.01") + r = subprocess.run( + [sys.executable, "-I", str(_SCRIPT), + "plan-creation-order", self.tmpdir], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 1) + out = json.loads(r.stdout) + self.assertTrue( + any("self-reference" in e for e in out["errors"]), + f"Expected self-reference error, got: {out['errors']}") + + +# --------------------------------------------------------------------------- +# Test: Quality gate — Step 4 halts entire sync (R1) +# --------------------------------------------------------------------------- + +class TestQualityGateConsumer(unittest.TestCase): + """Step 4 quality gate must halt entire sync before Step 5.""" + + @classmethod + def setUpClass(cls) -> None: + import re + sync_path = Path(__file__).resolve().parent.parent / "skills" / "sync.md" + cls._sync_md = sync_path.read_text(encoding="utf-8") + m4 = re.search(r"### Step 4:.*?(?=### Step 5:)", cls._sync_md, re.DOTALL) + assert m4, "Step 4 not found" + cls._step4 = m4.group() + + def test_step4_halts_entire_sync(self) -> None: + """Step 4 failure stops the entire sync, not just the failing story.""" + self.assertIn("stop the entire sync", self._step4.lower()) + + def test_step4_no_jira_writes_on_failure(self) -> None: + """Step 4 failure prevents proceeding to Step 5 (Jira writes).""" + self.assertIn("do not proceed to Step 5", self._step4) + + def test_step4_fresh_approval_required(self) -> None: + """After correction, fresh preflight/preview/approval required.""" + step4_lower = self._step4.lower() + self.assertIn("fresh", step4_lower) + self.assertIn("approval", step4_lower) + + def test_step4_no_stale_approval_reuse(self) -> None: + """Step 4: stale approval must not be reused.""" + self.assertIn("Do not reuse a stale approval", self._step4) + + def test_step4_sync_does_not_edit_source(self) -> None: + """Step 4: sync does not edit story source files.""" + self.assertIn("does not edit story source", self._step4) + + def test_step4_distinct_from_runtime_independence(self) -> None: + """Step 4 gate is distinct from runtime independence.""" + self.assertIn("independent eligible work", self._step4) + + def test_step4_pass_continues(self) -> None: + """Step 4 section starts with validation instructions, implying + pass → continue to Step 5.""" + self.assertIn("Validate", self._step4) + self.assertIn("Step 5", self._step4) + + +# --------------------------------------------------------------------------- +# Test: Preview cleanup — exact remnant removal (R4) +# --------------------------------------------------------------------------- + +class TestPreviewCleanup(unittest.TestCase): + """Preview cleanup: exact Step 3 remnants removed, Step 8 preserved.""" + + @classmethod + def setUpClass(cls) -> None: + import re + sync_path = Path(__file__).resolve().parent.parent / "skills" / "sync.md" + cls._sync_md = sync_path.read_text(encoding="utf-8") + m3 = re.search(r"### Step 3:.*?(?=### Step 4:)", cls._sync_md, re.DOTALL) + assert m3, "Step 3 not found" + cls._step3 = m3.group() + + def test_no_blocked_placeholder(self) -> None: + """The blocked-stories placeholder is removed from Step 3.""" + self.assertNotIn( + "{If any stories are blocked, list them separately with reasons.}", + self._step3) + + def test_no_blocked_suffix_in_create_total(self) -> None: + """The blocked count suffix is removed from create stories total.""" + self.assertNotIn("({B} blocked)", self._step3) + + def test_story_create_total_preserved(self) -> None: + """The story create total line is still present (without blocked).""" + self.assertIn("To create (stories):", self._step3) + + def test_step8_blocked_reporting_preserved(self) -> None: + """Step 8 still reports blocked stories (not removed globally).""" + import re + m8 = re.search(r"### Step 8:.*", self._sync_md, re.DOTALL) + self.assertIsNotNone(m8) + step8 = m8.group() + self.assertIn("blocked", step8.lower()) + + def test_step3_epics_to_create_present(self) -> None: + """Epics to Create section still present in Step 3.""" + self.assertIn("### Epics to Create", self._step3) + + +# --------------------------------------------------------------------------- +# Test: Guard recipe — per-guard temp capture (R3) +# --------------------------------------------------------------------------- + +class TestGuardRecipeConsumer(unittest.TestCase): + """Guard recipe: per-guard temp capture, errexit-safe, cleanup.""" + + @classmethod + def setUpClass(cls) -> None: + import re + sync_path = Path(__file__).resolve().parent.parent / "skills" / "sync.md" + cls._sync_md = sync_path.read_text(encoding="utf-8") + ref_path = (Path(__file__).resolve().parent.parent + / "references" / "sync-helper-usage.md") + cls._ref_md = ref_path.read_text(encoding="utf-8") + m6a = re.search(r"#### 6a:.*?(?=#### 6b:)", cls._sync_md, re.DOTALL) + assert m6a, "6a section not found" + cls._section_6a = m6a.group() + m6b = re.search(r"#### 6b:.*?(?=#### 6c:)", cls._sync_md, re.DOTALL) + assert m6b, "6b section not found" + cls._section_6b = m6b.group() + # Guard section from 6a + g_start = cls._section_6a.find("**Reference-availability guard.**") + g_end = cls._section_6a.find("For each new story") + assert g_start >= 0 and g_end > g_start + cls._guard_6a = cls._section_6a[g_start:g_end] + # Guard section from 6b + gb_start = cls._section_6b.find("**Reference-availability guard.**") + gb_end = cls._section_6b.find("For each story categorized") + assert gb_start >= 0 and gb_end > gb_start + cls._guard_6b = cls._section_6b[gb_start:gb_end] + + def test_6a_uses_mktemp(self) -> None: + """Step 6a guard creates temp with mktemp.""" + self.assertIn("mktemp", self._guard_6a) + + def test_6a_no_fixed_filename(self) -> None: + """Step 6a guard does NOT use fixed ref_check_err.txt.""" + self.assertNotIn("ref_check_err.txt", self._guard_6a) + + def test_6a_inline_cleanup(self) -> None: + """Step 6a capture block does inline cleanup before branches.""" + # rm -f and cat appear in the capture block before branch text + self.assertIn('rm -f "$REF_ERR"', self._guard_6a) + self.assertIn('cat "$REF_ERR"', self._guard_6a) + + def test_6a_cleanup_before_interpretation(self) -> None: + """Temp cleanup happens before branch interpretation text.""" + rm_pos = self._guard_6a.find('rm -f "$REF_ERR"') + branch_pos = self._guard_6a.find("Interpret the result") + self.assertGreater(rm_pos, -1) + self.assertGreater(branch_pos, -1) + self.assertLess(rm_pos, branch_pos, + "Cleanup must be before branch interpretation") + + def test_6b_mentions_per_guard_temp(self) -> None: + """Step 6b references per-guard temp creation.""" + self.assertIn("mktemp", self._guard_6b) + + def test_6b_mentions_inline_cleanup(self) -> None: + """Step 6b references inline cleanup pattern from 6a.""" + guard_lower = self._guard_6b.lower() + self.assertIn("remove the temp", guard_lower) + + def test_helper_ref_uses_errexit_safe_capture(self) -> None: + """Helper reference uses || REF_EXIT=$? idiom.""" + self.assertIn("|| REF_EXIT=$?", self._ref_md) + + def test_helper_ref_uses_mktemp(self) -> None: + """Helper reference uses mktemp for temp capture.""" + self.assertIn("mktemp", self._ref_md) + + def test_helper_ref_no_bare_echo_dollar_question(self) -> None: + """Helper reference does NOT use bare 'echo $?' for status.""" + # The old recipe used 'echo $?' — verify it's gone + guard_section_start = self._ref_md.find("## Reference-Availability Guard") + guard_section = self._ref_md[guard_section_start:] + self.assertNotIn("echo $? # 0 = ready", guard_section) + + +# --------------------------------------------------------------------------- +# Test: Approval/order consistency (R5) +# --------------------------------------------------------------------------- + +class TestApprovalOrderConsistency(unittest.TestCase): + """Sync Critical Rules and helper-reference consistency.""" + + @classmethod + def setUpClass(cls) -> None: + sync_path = Path(__file__).resolve().parent.parent / "skills" / "sync.md" + cls._sync_md = sync_path.read_text(encoding="utf-8") + ref_path = (Path(__file__).resolve().parent.parent + / "references" / "sync-helper-usage.md") + cls._ref_md = ref_path.read_text(encoding="utf-8") + revise_path = (Path(__file__).resolve().parent.parent + / "skills" / "revise.md") + cls._revise_md = revise_path.read_text(encoding="utf-8") + + def test_batch_rule_cross_epic_creation(self) -> None: + """Critical Rules batch rule mentions cross-epic creation batch.""" + self.assertIn("cross-epic", self._sync_md.lower()) + + def test_batch_rule_explicit_approval_list(self) -> None: + """Critical Rules lists explicit approval points.""" + # Should mention plan, epic, and story creation approvals + batch_line = "" + for line in self._sync_md.splitlines(): + if "Batch by operation" in line: + batch_line = line + break + self.assertIn("plan", batch_line.lower()) + self.assertIn("epic", batch_line.lower()) + + def test_helper_ref_no_epic_order_promise(self) -> None: + """Helper reference does NOT promise per-epic creation order.""" + self.assertNotIn("in epic order", self._ref_md) + self.assertNotIn("Epic 1 stories before Epic 2", self._ref_md) + + def test_helper_ref_dependency_first_order(self) -> None: + """Helper reference mentions dependency-first order.""" + self.assertIn("dependency-first", self._ref_md) + + def test_helper_ref_stable_identity(self) -> None: + """Helper reference: local numbers are identity, not creation readiness.""" + self.assertIn("stable artifact identities", self._ref_md) + + def test_revise_manifest_not_all_synced(self) -> None: + """Revise Step 1: manifest presence does NOT mean all artifacts synced.""" + self.assertNotIn("means\n epics/stories have been synced", self._revise_md) + self.assertNotIn("filenames are locked", self._revise_md) + + def test_revise_manifest_partial(self) -> None: + """Revise Step 1: manifest may be partial.""" + self.assertIn("may be partial", self._revise_md) + + def test_revise_manifest_records_mappings(self) -> None: + """Revise Step 1: manifest records Jira key mappings.""" + self.assertIn("Jira key mappings", self._revise_md) + + +# --------------------------------------------------------------------------- +# Test: Bash temp capture snippets (R3 verification) +# --------------------------------------------------------------------------- + +class TestBashTempCapture(unittest.TestCase): + """Verify Bash temp-capture/cleanup snippets work correctly + under errexit, using the ACTUAL documented recipe pattern.""" + + def test_mktemp_allocation_failure_stops_helper(self) -> None: + """mktemp failure → exit before helper runs (shell test).""" + import subprocess + script = textwrap.dedent("""\ + set -euo pipefail + # Override mktemp to simulate allocation failure + mktemp() { return 1; } + export -f mktemp + REF_ERR=$(mktemp) || { echo "FATAL: cannot create temp" >&2; exit 1; } + echo "SHOULD_NOT_REACH" + """) + r = subprocess.run( + ["bash", "-c", script], + capture_output=True, text=True, timeout=5) + self.assertNotEqual(r.returncode, 0, + "Allocation failure must exit nonzero") + self.assertNotIn("SHOULD_NOT_REACH", r.stdout, + "Helper must not run after allocation failure") + self.assertIn("FATAL", r.stderr) + + def test_errexit_safe_status_capture(self) -> None: + """|| REF_EXIT=$? captures nonzero exit under set -e.""" + import subprocess + script = textwrap.dedent("""\ + set -euo pipefail + REF_ERR=$(mktemp) || exit 1 + REF_EXIT=0 + python3 -c "import sys; sys.exit(1)" 2>"$REF_ERR" || REF_EXIT=$? + echo "REF_EXIT=$REF_EXIT" + rm -f "$REF_ERR" + """) + r = subprocess.run( + ["bash", "-c", script], + capture_output=True, text=True, timeout=5) + self.assertEqual(r.returncode, 0) + self.assertIn("REF_EXIT=1", r.stdout) + + def test_temp_file_cleaned_on_all_paths(self) -> None: + """Temp file is removed in every branch (ready/blocked/fatal).""" + import subprocess + script = textwrap.dedent("""\ + set -euo pipefail + REF_ERR=$(mktemp) || exit 1 + echo "captured" > "$REF_ERR" + # Read text then clean + REF_ERR_TEXT=$(cat "$REF_ERR") + rm -f "$REF_ERR" + # Verify file is gone + if [ -f "$REF_ERR" ]; then + echo "LEAKED" + exit 1 + fi + echo "clean" + """) + r = subprocess.run( + ["bash", "-c", script], + capture_output=True, text=True, timeout=5) + self.assertEqual(r.returncode, 0) + self.assertIn("clean", r.stdout) + self.assertNotIn("LEAKED", r.stdout) + + def test_stderr_text_retained_before_cleanup(self) -> None: + """Fatal diagnostic text is readable before temp file removal.""" + import subprocess + script = textwrap.dedent("""\ + set -euo pipefail + REF_ERR=$(mktemp) || exit 1 + REF_EXIT=0 + python3 -c "import sys; print('FATAL_MSG', file=sys.stderr); sys.exit(1)" \\ + 2>"$REF_ERR" || REF_EXIT=$? + REF_ERR_TEXT=$(cat "$REF_ERR") + rm -f "$REF_ERR" + echo "exit=$REF_EXIT text=$REF_ERR_TEXT" + """) + r = subprocess.run( + ["bash", "-c", script], + capture_output=True, text=True, timeout=5) + self.assertEqual(r.returncode, 0) + self.assertIn("exit=1", r.stdout) + self.assertIn("FATAL_MSG", r.stdout) + + +# --------------------------------------------------------------------------- +# Test: DOCS Contributing native key extraction +# --------------------------------------------------------------------------- + +class TestDocsContribNativeKeyExtraction(unittest.TestCase): + """Contributing stories section extracts native Jira keys.""" + + def test_contributing_native_key_extracted(self) -> None: + """Native Jira key in Contributing → appears in native_keys.""" + content = textwrap.dedent("""\ + --- + --- + # Story 1.01: [DOCS] Guide + + **As a** writer, + **I want to** document, + **So that** users understand. + + ## Acceptance Criteria + + - [ ] Done + + ## Documentation Scope + + API docs. + + ## Documentation Inputs + + **EDM-201 — API:** output. + + ## Dependencies + + None + + ## Design Reference + + Epic: Epic 1 — Docs + Contributing stories: EDM-201 + """) + with tempfile.NamedTemporaryFile( + mode='w', suffix='.md', delete=False, + encoding='utf-8') as f: + f.write(content) + f.flush() + fp = Path(f.name) + try: + extraction = sync_helper.extract_required_refs( + fp, story_title="[DOCS] Guide") + self.assertIn("EDM-201", extraction["native_keys"], + "Native key in Contributing must be extracted") + finally: + fp.unlink() + + def test_docs_native_contributing_self_ref_blocked(self) -> None: + """DOCS story with own native key in Contributing → blocked.""" + content = textwrap.dedent("""\ + --- + --- + # Story 1.01: [DOCS] Guide + + **As a** writer, + **I want to** document, + **So that** users understand. + + ## Acceptance Criteria + + - [ ] Done + + ## Documentation Scope + + API docs. + + ## Documentation Inputs + + Input. + + ## Dependencies + + None + + ## Design Reference + + Epic: Epic 1 — Docs + Contributing stories: EDM-301 + """) + with tempfile.NamedTemporaryFile( + mode='w', suffix='.md', delete=False, + encoding='utf-8') as f: + f.write(content) + f.flush() + fp = Path(f.name) + try: + extraction = sync_helper.extract_required_refs( + fp, story_title="[DOCS] Guide") + # EDM-301 is the story's own key in this scenario + available = {"story-1.01": "EDM-301", "epic-1": "EDM-101"} + self_refs = sync_helper._detect_self_refs( + "story-1.01", extraction["refs"], + extraction["native_keys"], "EDM-301", available) + self.assertIn("EDM-301", self_refs, + "Own native key in Contributing must be detected") + finally: + fp.unlink() + + def test_contributing_story_ref_still_extracted(self) -> None: + """Story ref in Contributing → still in refs (unchanged).""" + content = textwrap.dedent("""\ + --- + --- + # Story 1.01: [DOCS] Guide + + As a writer. + + ## Acceptance Criteria + + - Done + + ## Documentation Scope + + Docs. + + ## Documentation Inputs + + Input. + + ## Dependencies + + None + + ## Design Reference + + Epic: Epic 1 + Contributing stories: Story 2.01 + """) + with tempfile.NamedTemporaryFile( + mode='w', suffix='.md', delete=False, + encoding='utf-8') as f: + f.write(content) + f.flush() + fp = Path(f.name) + try: + extraction = sync_helper.extract_required_refs( + fp, story_title="[DOCS] Guide") + self.assertIn("story-2.01", extraction["refs"]) + finally: + fp.unlink() + + +# --------------------------------------------------------------------------- +# Test: Canonical ID normalization in self-ref detection (Point A) +# --------------------------------------------------------------------------- + +class TestCanonicalIDSelfRef(_TmpDirMixin, unittest.TestCase): + """Self-ref detection uses canonical ID for own-key lookup.""" + + def test_noncanonical_qid_still_detects_self_ref(self) -> None: + """story-1.1 (non-canonical) with mapped story-1.01 → self-ref.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 1.01") + mf = Path(self.tmpdir) / "sync-manifest.json" + _write(mf, json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [{"file": "story-01-api.md", + "jira_key": "EDM-201", + "content_hash": "h2", + "synced_status": "active"}]}]})) + # Non-canonical input "story-1.1" maps to same story as "story-1.01" + r = sync_helper.check_ref_availability( + ["story-1.1"], self.tmpdir, mf) + self.assertEqual(len(r["blocked"]), 1, + "Non-canonical QID must still detect self-ref") + self.assertIn("self-reference", r["blocked"][0]["reason"]) + + def test_canonical_qid_self_ref(self) -> None: + """story-1.01 (canonical) with mapped key → self-ref detected.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 1.01") + mf = Path(self.tmpdir) / "sync-manifest.json" + _write(mf, json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [{"file": "story-01-api.md", + "jira_key": "EDM-201", + "content_hash": "h2", + "synced_status": "active"}]}]})) + r = sync_helper.check_ref_availability( + ["story-1.01"], self.tmpdir, mf) + self.assertEqual(len(r["blocked"]), 1) + self.assertIn("self-reference", r["blocked"][0]["reason"]) + + def test_resolved_alias_integration(self) -> None: + """Resolved alias self-ref via check_ref_availability integration.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + # story-1.01 depends on EDM-201 (its own key, as native dep) + _make_dev_story(sd, 1, 1, "api", deps="EDM-201") + mf = Path(self.tmpdir) / "sync-manifest.json" + _write(mf, json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [{"file": "story-01-api.md", + "jira_key": "EDM-201", + "content_hash": "h2", + "synced_status": "active"}]}]})) + r = sync_helper.check_ref_availability( + ["story-1.01"], self.tmpdir, mf) + self.assertEqual(len(r["blocked"]), 1) + self.assertIn("self-reference", r["blocked"][0]["reason"]) + self.assertIn("EDM-201", r["blocked"][0].get("invalid_refs", [])) + + +# --------------------------------------------------------------------------- +# Test: Self-ref diagnostics truthfulness (Point B) +# --------------------------------------------------------------------------- + +class TestSelfRefDiagnostics(_TmpDirMixin, unittest.TestCase): + """Self-ref blocked entries use invalid_refs, not missing.""" + + def test_new_story_self_ref_accurate_reason(self) -> None: + """New story planner self-ref: reason says 'self-reference', + missing is empty, invalid_refs contains the ref.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 1.01") + items = [{"type": "story", "category": "new", + "epic_index": 1, "story_index": 1, + "jira_key": None, "title": "", + "file": "epic-1/story-01-api.md"}] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertGreater(len(r["blocked"]), 0) + b = r["blocked"][0] + self.assertIn("self-reference", b["reason"]) + self.assertEqual(b["missing"], [], + "Self-ref IDs are invalid, not missing") + self.assertIn("story-1.01", b.get("invalid_refs", [])) + + def test_changed_story_self_ref_accurate_reason(self) -> None: + """Changed story planner self-ref: accurate diagnostics.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 1.01") + manifest = {"epics": [{"file": "epic-1-building.md", + "jira_key": "TE-100", + "stories": [{"file": "story-01-api.md", + "jira_key": "TE-101", + "content_hash": "old", + "synced_status": "active"}]}]} + items = [ + {"type": "story", "category": "changed", "epic_index": 1, + "story_index": 1, "jira_key": "TE-101", "title": "", + "file": "epic-1/story-01-api.md"}, + ] + r = sync_helper.plan_creation_order(items, self.tmpdir, manifest) + self.assertGreater(len(r["blocked"]), 0) + b = r["blocked"][0] + self.assertIn("self-reference", b["reason"]) + self.assertEqual(b["missing"], []) + self.assertIn("story-1.01", b.get("invalid_refs", [])) + + def test_runtime_self_ref_accurate_shape(self) -> None: + """Runtime self-ref: missing=[], invalid_refs has IDs.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 1.01") + mf = Path(self.tmpdir) / "sync-manifest.json" + _write(mf, json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": [{"file": "story-01-api.md", + "jira_key": "EDM-201", + "content_hash": "h2", + "synced_status": "active"}]}]})) + r = sync_helper.check_ref_availability( + ["story-1.01"], self.tmpdir, mf) + b = r["blocked"][0] + self.assertEqual(b["missing"], [], + "Self-ref IDs are invalid, not missing") + self.assertIn("story-1.01", b.get("invalid_refs", [])) + + def test_legitimate_missing_ref_still_in_missing(self) -> None: + """Actual missing ref → in 'missing', no invalid_refs.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 9.99") + mf = Path(self.tmpdir) / "sync-manifest.json" + _write(mf, json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "stories": []}]})) + r = sync_helper.check_ref_availability( + ["story-1.01"], self.tmpdir, mf) + b = r["blocked"][0] + self.assertIn("story-9.99", b["missing"], + "Legitimate missing ref must be in missing") + self.assertNotIn("invalid_refs", b, + "No invalid_refs for legitimate missing") + + +# --------------------------------------------------------------------------- +# Test: Real documented capture recipe (Point C) +# --------------------------------------------------------------------------- + +class TestRealCaptureRecipe(unittest.TestCase): + """Test the ACTUAL documented Bash capture recipe with the real + helper, not toy scripts with hardcoded cleanup.""" + + def test_recipe_ready_no_leaked_temp(self) -> None: + """Real recipe with ready story: temp file removed.""" + import subprocess + with tempfile.TemporaryDirectory() as tmpdir: + sd = Path(tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="None") + _write(Path(tmpdir) / "sync-manifest.json", json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", + "jira_key": "EDM-101", + "content_hash": "h", + "synced_status": "active", + "stories": []}]})) + script = textwrap.dedent(f"""\ + set -euo pipefail + REF_ERR=$(mktemp) || {{ echo "FATAL" >&2; exit 1; }} + REF_EXIT=0 + REF_CHECK=$(python3 -I "{_SCRIPT}" check-ref-availability \\ + "{tmpdir}" "story-1.01" 2>"$REF_ERR") || REF_EXIT=$? + REF_ERR_TEXT=$(cat "$REF_ERR") + rm -f "$REF_ERR" + if [ -f "$REF_ERR" ]; then echo "LEAKED"; exit 1; fi + echo "exit=$REF_EXIT leaked=no" + """) + r = subprocess.run( + ["bash", "-c", script], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 0) + self.assertIn("exit=0", r.stdout) + self.assertNotIn("LEAKED", r.stdout) + + def test_recipe_blocked_no_leaked_temp(self) -> None: + """Real recipe with blocked story: temp file removed.""" + import subprocess + with tempfile.TemporaryDirectory() as tmpdir: + sd = Path(tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="Story 9.99") + _write(Path(tmpdir) / "sync-manifest.json", json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", + "jira_key": "EDM-101", + "content_hash": "h", + "synced_status": "active", + "stories": []}]})) + script = textwrap.dedent(f"""\ + set -euo pipefail + REF_ERR=$(mktemp) || {{ echo "FATAL" >&2; exit 1; }} + REF_EXIT=0 + REF_CHECK=$(python3 -I "{_SCRIPT}" check-ref-availability \\ + "{tmpdir}" "story-1.01" 2>"$REF_ERR") || REF_EXIT=$? + REF_ERR_TEXT=$(cat "$REF_ERR") + rm -f "$REF_ERR" + if [ -f "$REF_ERR" ]; then echo "LEAKED"; exit 1; fi + echo "exit=$REF_EXIT leaked=no" + """) + r = subprocess.run( + ["bash", "-c", script], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 0) + self.assertIn("exit=1", r.stdout) + self.assertNotIn("LEAKED", r.stdout) + + def test_recipe_fatal_captures_stderr_text(self) -> None: + """Real recipe with corrupted manifest: stderr text captured.""" + import subprocess + with tempfile.TemporaryDirectory() as tmpdir: + sd = Path(tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="None") + _write(Path(tmpdir) / "sync-manifest.json", "NOT JSON {{{") + script = textwrap.dedent(f"""\ + set -euo pipefail + REF_ERR=$(mktemp) || {{ echo "FATAL" >&2; exit 1; }} + REF_EXIT=0 + REF_CHECK=$(python3 -I "{_SCRIPT}" check-ref-availability \\ + "{tmpdir}" "story-1.01" 2>"$REF_ERR") || REF_EXIT=$? + REF_ERR_TEXT=$(cat "$REF_ERR") + rm -f "$REF_ERR" + if [ -f "$REF_ERR" ]; then echo "LEAKED"; exit 1; fi + echo "exit=$REF_EXIT text=$REF_ERR_TEXT" + """) + r = subprocess.run( + ["bash", "-c", script], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 0) + self.assertIn("exit=1", r.stdout) + self.assertIn("ERROR", r.stdout, + "Fatal stderr text must be captured in REF_ERR_TEXT") + self.assertNotIn("LEAKED", r.stdout) + + def test_no_fixed_name_clobber(self) -> None: + """Recipe does not clobber a CWD file named ref_check_err.txt.""" + import subprocess + with tempfile.TemporaryDirectory() as tmpdir: + sd = Path(tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "api", deps="None") + _write(Path(tmpdir) / "sync-manifest.json", json.dumps({ + "schema_version": 2, "feature_key": "F", + "epics": [{"file": "epic-1-building.md", + "jira_key": "EDM-101", + "content_hash": "h", + "synced_status": "active", + "stories": []}]})) + sentinel = Path(tmpdir) / "ref_check_err.txt" + sentinel.write_text("SENTINEL", encoding="utf-8") + script = textwrap.dedent(f"""\ + set -euo pipefail + cd "{tmpdir}" + REF_ERR=$(mktemp) || exit 1 + REF_EXIT=0 + REF_CHECK=$(python3 -I "{_SCRIPT}" check-ref-availability \\ + "{tmpdir}" "story-1.01" 2>"$REF_ERR") || REF_EXIT=$? + REF_ERR_TEXT=$(cat "$REF_ERR") + rm -f "$REF_ERR" + cat ref_check_err.txt + """) + r = subprocess.run( + ["bash", "-c", script], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 0) + self.assertIn("SENTINEL", r.stdout, + "CWD file must not be overwritten by recipe") + + +# --------------------------------------------------------------------------- +# Test: Quality gate manual trace (Point D) +# --------------------------------------------------------------------------- + +class TestQualityGateApprovalTrace(unittest.TestCase): + """Verify Step 4 quality gate + approval flow cross-references.""" + + @classmethod + def setUpClass(cls) -> None: + import re + sync_path = Path(__file__).resolve().parent.parent / "skills" / "sync.md" + cls._sync_md = sync_path.read_text(encoding="utf-8") + m4 = re.search(r"### Step 4:.*?(?=### Step 5:)", cls._sync_md, re.DOTALL) + assert m4 + cls._step4 = m4.group() + m5 = re.search(r"### Step 5:.*?(?=### Step 6:)", cls._sync_md, re.DOTALL) + assert m5 + cls._step5 = m5.group() + m6a = re.search(r"#### 6a:.*?(?=#### 6b:)", cls._sync_md, re.DOTALL) + assert m6a + cls._step6a = m6a.group() + + def test_step3_plan_approval(self) -> None: + """Step 3 has explicit plan approval gate.""" + import re + m3 = re.search(r"### Step 3:.*?(?=### Step 4:)", self._sync_md, re.DOTALL) + self.assertIn("approval", m3.group().lower()) + + def test_step5_epic_confirmation(self) -> None: + """Step 5 has user confirmation before stories.""" + self.assertIn("Wait for user confirmation", self._step5) + + def test_step6a_creation_batch_approval_ref(self) -> None: + """Step 6a references the Step 5 confirmation covering creation.""" + self.assertIn("Step 5 confirmation", self._step6a) + + def test_step4_pass_continues_to_step5(self) -> None: + """Step 4 pass allows continuation to Step 5.""" + self.assertIn("Before proceeding to Jira", self._step4) + + def test_step4_fail_blocks_entire_plan(self) -> None: + """Step 4 failure blocks entire plan, not just failing story.""" + self.assertIn("stop the entire sync", self._step4.lower()) + self.assertIn("do not proceed to Step 5", self._step4) + + def test_step4_fresh_approval_after_revise(self) -> None: + """After /revise correction, fresh approval cycle required.""" + self.assertIn("fresh", self._step4.lower()) + self.assertIn("Do not reuse a stale approval", self._step4) + + def test_step4_unchanged_unaffected_preserved(self) -> None: + """Step 4 only evaluates relevant DOCS stories, not all.""" + self.assertIn("new", self._step4.lower()) + self.assertIn("changed", self._step4.lower()) + self.assertIn("reopened", self._step4.lower()) + + def test_runtime_blocked_independence_preserved(self) -> None: + """Step 4 note distinguishes from runtime independence.""" + self.assertIn("independent eligible work", self._step4) + + +# --------------------------------------------------------------------------- +# R1: Markdown list-prefix normalization for Contributing stories +# --------------------------------------------------------------------------- + +class TestContribListPrefixNormalization(_TmpDirMixin, unittest.TestCase): + """Contributing stories wrapped in Markdown list-item prefixes are + extracted correctly (R1 regression).""" + + def test_dash_prefix_extracted(self) -> None: + """``- Contributing stories: Story 1.02`` must be extracted.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_docs_story( + sd, 1, 3, "docs", + deps="Story 1.01", + contributing_stories="Story 1.01, Story 1.02", + ) + # Rewrite the file to use a list-prefixed Contributing stories line + fp = sd / "epic-1" / "story-03-docs.md" + content = fp.read_text(encoding="utf-8") + content = content.replace( + "Contributing stories: Story 1.01, Story 1.02", + "- Contributing stories: Story 1.01, Story 1.02", + ) + fp.write_text(content, encoding="utf-8") + result = sync_helper.extract_required_refs(fp) + ref_ids = result["refs"] + self.assertIn("story-1.01", ref_ids, + "story-1.01 must be extracted from list-prefixed Contributing") + self.assertIn("story-1.02", ref_ids, + "story-1.02 must be extracted from list-prefixed Contributing") + + def test_asterisk_prefix_extracted(self) -> None: + """``* Contributing stories: Story 2.01`` must be extracted.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_docs_story(sd, 2, 3, "docs", + deps="None", + contributing_stories="Story 2.01") + fp = sd / "epic-2" / "story-03-docs.md" + content = fp.read_text(encoding="utf-8") + content = content.replace( + "Contributing stories: Story 2.01", + "* Contributing stories: Story 2.01", + ) + fp.write_text(content, encoding="utf-8") + result = sync_helper.extract_required_refs(fp) + self.assertIn("story-2.01", result["refs"]) + + def test_plus_prefix_extracted(self) -> None: + """``+ Contributing stories: Story 1.01`` must be extracted.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_docs_story(sd, 1, 2, "docs", + deps="None", + contributing_stories="Story 1.01") + fp = sd / "epic-1" / "story-02-docs.md" + content = fp.read_text(encoding="utf-8") + content = content.replace( + "Contributing stories: Story 1.01", + "+ Contributing stories: Story 1.01", + ) + fp.write_text(content, encoding="utf-8") + result = sync_helper.extract_required_refs(fp) + self.assertIn("story-1.01", result["refs"]) + + def test_no_prefix_still_works(self) -> None: + """Unprefixed ``Contributing stories:`` still extracted.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_docs_story(sd, 1, 2, "docs", + deps="None", + contributing_stories="Story 1.01") + fp = sd / "epic-1" / "story-02-docs.md" + result = sync_helper.extract_required_refs(fp) + self.assertIn("story-1.01", result["refs"]) + + def test_both_sections_extracted(self) -> None: + """Refs from both Dependencies and Contributing are extracted + per existing grammar — deduplication is the consumer's concern.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_docs_story(sd, 1, 3, "docs", + deps="Story 1.01", + contributing_stories="Story 1.01, Story 1.02") + fp = sd / "epic-1" / "story-03-docs.md" + result = sync_helper.extract_required_refs(fp) + refs = result["refs"] + # story-1.01 extracted from both sections + self.assertGreaterEqual(refs.count("story-1.01"), 2, + "story-1.01 must be extracted from both sections") + self.assertIn("story-1.02", refs) + + def test_contrib_only_reversed_order(self) -> None: + """DOCS story-1.01 with list-prefixed Contributing-only ref to + later-numbered Story 2.01 — planner reverses numeric order so + DEV 2.01 is created before DOCS 1.01. Uses detect_changes + + plan_creation_order with real source files.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "docs-epic") + _make_epic(sd, 2, "dev-epic") + _make_dev_story(sd, 2, 1, "impl", deps="None") + # DOCS story-1.01 with Dependencies: None, Contributing-only ref + # to the later-numbered Story 2.01 + _make_docs_story(sd, 1, 1, "api-docs", + deps="None", + contributing_stories="Story 2.01") + fp = sd / "epic-1" / "story-01-api-docs.md" + content = fp.read_text(encoding="utf-8") + content = content.replace( + "Contributing stories: Story 2.01", + "- Contributing stories: Story 2.01", + ) + fp.write_text(content, encoding="utf-8") + # Use detect_changes to build the real change plan + change_plan = sync_helper.detect_changes(self.tmpdir) + self.assertFalse(change_plan["manifest_exists"]) + story_items = [i for i in change_plan["items"] + if i["type"] == "story"] + self.assertEqual(len(story_items), 2) + r = sync_helper.plan_creation_order( + change_plan["items"], self.tmpdir, None) + self.assertEqual(r["errors"], []) + qids = [f"story-{it['epic_index']}.{it['story_index']:02d}" + for it in r["ordered"]] + self.assertIn("story-2.01", qids) + self.assertIn("story-1.01", qids) + # DEV 2.01 must be created BEFORE DOCS 1.01 (reversed numeric) + self.assertLess(qids.index("story-2.01"), qids.index("story-1.01"), + "DEV 2.01 must precede DOCS 1.01 in creation order") + + def test_contrib_only_availability_lifecycle(self) -> None: + """DOCS with list-prefixed Contributing-only ref: blocked before + mapping, ready after prerequisite+parent keys recorded.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "impl", deps="None") + _make_docs_story(sd, 1, 2, "docs", + deps="None", + contributing_stories="Story 1.01") + fp = sd / "epic-1" / "story-02-docs.md" + content = fp.read_text(encoding="utf-8") + content = content.replace( + "Contributing stories: Story 1.01", + "- Contributing stories: Story 1.01", + ) + fp.write_text(content, encoding="utf-8") + # No manifest → prereq story-1.01 has no key → blocked + _make_manifest(Path(self.tmpdir), []) + mf = Path(self.tmpdir) / "sync-manifest.json" + r1 = sync_helper.check_ref_availability( + ["story-1.02"], str(self.tmpdir), str(mf)) + self.assertEqual(len(r1["blocked"]), 1, + "DOCS story blocked when Contributing prereq has no key") + # Record parent epic and prereq story keys + sync_helper.update_manifest( + str(mf), "create", "epic-1-building.md", "EDM-100", + "h", item_type="epic") + sync_helper.update_manifest( + str(mf), "create", "story-01-impl.md", "EDM-201", + "h", item_type="story", epic_file="epic-1-building.md") + r2 = sync_helper.check_ref_availability( + ["story-1.02"], str(self.tmpdir), str(mf)) + self.assertEqual(len(r2["ready"]), 1, + "DOCS story ready after prereq+parent keys mapped") + + def test_contrib_only_prepare_renders_native_key(self) -> None: + """After mapping, prepare renders native Jira key from Contributing + while Dependencies remains None.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "impl", deps="None") + _make_docs_story(sd, 1, 2, "docs", + deps="None", + contributing_stories="Story 1.01") + fp_docs = sd / "epic-1" / "story-02-docs.md" + content = fp_docs.read_text(encoding="utf-8") + content = content.replace( + "Contributing stories: Story 1.01", + "- Contributing stories: Story 1.01", + ) + fp_docs.write_text(content, encoding="utf-8") + mf = _make_manifest(Path(self.tmpdir), [{ + "file": "epic-1-building.md", "jira_key": "EDM-100", + "content_hash": "h", "synced_status": "active", + "stories": [ + {"file": "story-01-impl.md", "jira_key": "EDM-201", + "content_hash": "h", "synced_status": "active"}, + ], + }]) + prep = sync_helper.prepare( + str(fp_docs), str(mf), "story", + design_doc_link="http://ex.com/design", + epic_jira_key="EDM-100") + # Contributing story ref resolved to Jira key + self.assertIn("EDM-201", prep["description"]) + # Dependencies section is None, not a Jira link + self.assertEqual(prep["dependencies"], "None") + + def test_nondocs_ignores_contributing(self) -> None: + """Non-DOCS story ignores Contributing stories field entirely.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_dev_story(sd, 1, 1, "scaffold", deps="None") + fp = sd / "epic-1" / "story-01-scaffold.md" + # Manually add Contributing stories to a DEV story (should be ignored) + content = fp.read_text(encoding="utf-8") + content += "\nContributing stories: Story 2.01\n" + fp.write_text(content, encoding="utf-8") + result = sync_helper.extract_required_refs(fp) + ref_ids = result["refs"] + self.assertNotIn("story-2.01", ref_ids, + "Non-DOCS story must not extract Contributing stories") + + def test_native_key_preserved_in_existing_cases(self) -> None: + """Existing native Jira key extraction from Contributing is preserved.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_docs_story(sd, 1, 2, "docs", + deps="None", + contributing_stories="EDM-500") + fp = sd / "epic-1" / "story-02-docs.md" + result = sync_helper.extract_required_refs(fp) + self.assertIn("EDM-500", result["native_keys"]) + + +# --------------------------------------------------------------------------- +# R2: Actual unavailable ref IDs in blocked[].missing — no placeholder +# --------------------------------------------------------------------------- + +class TestUnavailableRefIDs(_TmpDirMixin, unittest.TestCase): + """Blocked entries must include actual unavailable ref IDs in + ``missing``, not ``(see errors for qid)`` (R2 regression).""" + + def _setup_stories(self, deps_list): + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + for eidx, sidx, slug, deps in deps_list: + _make_epic(sd, eidx, f"epic{eidx}") + _make_dev_story(sd, eidx, sidx, slug, deps=deps) + return sd + + def _item(self, eidx, sidx, slug): + return {"type": "story", "category": "new", + "epic_index": eidx, "story_index": sidx, + "jira_key": None, "title": "", + "file": f"epic-{eidx}/story-{sidx:02d}-{slug}.md"} + + def test_unavailable_ref_id_in_missing(self) -> None: + """Unavailable story ref appears directly in blocked[].missing.""" + self._setup_stories([(1, 1, "a", "Story 9.99")]) + items = [self._item(1, 1, "a")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertEqual(len(r["ordered"]), 0) + self.assertEqual(len(r["blocked"]), 1) + blocked = r["blocked"][0] + self.assertIn("story-9.99", blocked["missing"], + "Actual unavailable ref ID must be in missing") + for m in blocked["missing"]: + self.assertNotIn("(see errors", m, + "Placeholder must not appear in missing") + + def test_unavailable_epic_ref_id_in_missing(self) -> None: + """Unavailable epic ref appears directly in blocked[].missing.""" + self._setup_stories([(1, 1, "a", "Epic 999")]) + items = [self._item(1, 1, "a")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertEqual(len(r["blocked"]), 1) + blocked = r["blocked"][0] + self.assertIn("epic-999", blocked["missing"], + "Actual unavailable epic ref must be in missing") + + def test_both_direct_unavailable_and_blocked_graph_in_missing(self) -> None: + """Owner with BOTH direct unavailable ref AND graph prerequisite + that is itself blocked: owner's missing contains both actual IDs.""" + self._setup_stories([ + (1, 1, "a", "Story 9.99"), # a → missing 9.99 + (1, 2, "b", "Story 1.01"), # b → a (which is blocked) + (1, 3, "c", "Story 9.99, Story 1.02"), # c → missing 9.99 + b + ]) + items = [self._item(1, 1, "a"), self._item(1, 2, "b"), + self._item(1, 3, "c")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + blocked_map = { + f"story-{b['item']['epic_index']}.{b['item']['story_index']:02d}": b + for b in r["blocked"] + } + # story-1.03 has direct unavailable story-9.99 AND graph dep + # story-1.02 which is itself blocked — both must be in missing + self.assertIn("story-1.03", blocked_map) + c_missing = blocked_map["story-1.03"]["missing"] + self.assertIn("story-9.99", c_missing, + "Direct unavailable ref must be in missing") + self.assertIn("story-1.02", c_missing, + "Blocked graph prerequisite must be in missing") + # No placeholders anywhere + for m in c_missing: + self.assertNotIn("(see errors", m) + + def test_self_ref_uses_invalid_refs_not_missing(self) -> None: + """Self-ref uses invalid_refs/reason, NOT missing.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "a", deps="Story 1.01") + items = [self._item(1, 1, "a")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertEqual(len(r["blocked"]), 1) + b = r["blocked"][0] + self.assertEqual(b["missing"], []) + self.assertIn("invalid_refs", b) + self.assertIn("self-reference", b["reason"]) + + def test_source_read_failure_reason_no_fake_id(self) -> None: + """Unreadable source → reason set, no fake ID in missing.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "building") + items = [{"type": "story", "category": "new", + "epic_index": 1, "story_index": 1, + "jira_key": None, "title": "", + "file": "epic-1/story-01-ghost.md"}] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + self.assertEqual(len(r["blocked"]), 1) + b = r["blocked"][0] + self.assertIn("unreadable", b["reason"].lower(), + "Reason must describe source failure") + for m in b.get("missing", []): + self.assertNotIn("(see errors", m, + "No placeholder in missing for source failures") + + def test_no_placeholder_anywhere(self) -> None: + """No blocked entry may contain ``(see errors for ...)`` placeholder.""" + self._setup_stories([ + (1, 1, "a", "Story 9.01, Story 9.02"), + (1, 2, "b", "None"), + ]) + items = [self._item(1, 1, "a"), self._item(1, 2, "b")] + r = sync_helper.plan_creation_order(items, self.tmpdir, None) + for b in r["blocked"]: + for m in b.get("missing", []): + self.assertNotIn("(see errors", m) + + def test_cli_unavailable_ref_id_in_json(self) -> None: + """CLI plan-creation-order JSON contains actual ref IDs in blocked.""" + import subprocess + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir(exist_ok=True) + _make_epic(sd, 1, "epic1") + _make_dev_story(sd, 1, 1, "a", deps="Story 9.99") + r = subprocess.run( + [sys.executable, "-I", str(_SCRIPT), + "plan-creation-order", str(self.tmpdir)], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 1) + out = json.loads(r.stdout) + self.assertGreater(len(out["blocked"]), 0) + blocked = out["blocked"][0] + self.assertIn("story-9.99", blocked["missing"]) + + +# --------------------------------------------------------------------------- +# R5: Parent-epic retirement affects DOCS set (instruction contract) +# --------------------------------------------------------------------------- + +class TestDocsParentEpicRetirement(unittest.TestCase): + """Revise.md includes parent-epic retirement in DOCS affected set (R5).""" + + @classmethod + def setUpClass(cls) -> None: + import re + revise_path = Path(__file__).resolve().parent.parent / "skills" / "revise.md" + cls._revise_md = revise_path.read_text(encoding="utf-8") + m4 = re.search(r"### Step 4:.*?(?=### Step 5:)", cls._revise_md, re.DOTALL) + assert m4, "Step 4 not found in revise.md" + cls._step4 = m4.group() + # Find the DOCS re-evaluation block (trigger list a-e) + trigger = re.search( + r"A `\[DOCS\]` story is affected when.*?(?=For each affected)", + cls._step4, re.DOTALL) + assert trigger, "DOCS affected-set trigger block not found" + cls._trigger_block = trigger.group() + # Find post-cascade reconciliation block + cascade_idx = cls._step4.find("reconciliation after cascades") + assert cascade_idx >= 0, "Post-cascade reconciliation block not found" + cls._cascade_block = cls._step4[cascade_idx:] + + def test_parent_epic_retirement_in_trigger(self) -> None: + """Trigger list (a-e) includes parent epic retirement.""" + self.assertIn("parent epic", self._trigger_block.lower()) + + def test_contributor_status_removed_in_trigger(self) -> None: + """Trigger mentions status: removed on parent.""" + self.assertIn("status: removed", self._trigger_block) + + def test_unchanged_file_still_affected(self) -> None: + """Trigger notes unchanged contributor file affected by parent.""" + self.assertIn("unchanged", self._trigger_block.lower()) + + def test_parent_retirement_in_cascade_reconciliation(self) -> None: + """Post-cascade reconciliation mentions parent epic removal.""" + self.assertIn("parent epic removal", self._cascade_block.lower()) + + +class TestDocsParentRetirementHelper(_TmpDirMixin, unittest.TestCase): + """Parent epic retirement logically retires children via existing + helper detect_changes semantics (R5 execution-level evidence).""" + + def test_child_under_removed_parent_not_created(self) -> None: + """detect_changes skips new children under removed parent epic.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building", removed=True) + _make_dev_story(sd, 1, 1, "impl", deps="None") + result = sync_helper.detect_changes(self.tmpdir) + # Active child under removed parent should not appear as new + story_items = [i for i in result["items"] + if i["type"] == "story" and i["epic_index"] == 1] + self.assertEqual(len(story_items), 0, + "Child under removed parent must be skipped on fresh sync") + + def test_active_child_changed_under_removed_parent(self) -> None: + """An active child story that changed under a removed parent gets + category 'removed' (not 'changed') — parent retirement overrides.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building", removed=True) + _make_dev_story(sd, 1, 1, "impl", deps="None") + _make_manifest(Path(self.tmpdir), [{ + "file": "epic-1-building.md", "jira_key": "EDM-100", + "content_hash": "h", "synced_status": "active", + "stories": [ + {"file": "story-01-impl.md", "jira_key": "EDM-201", + "content_hash": "old-different-hash", "synced_status": "active"}, + ], + }]) + result = sync_helper.detect_changes(self.tmpdir) + child = [i for i in result["items"] + if i["type"] == "story" and i.get("story_index") == 1] + self.assertEqual(len(child), 1) + self.assertEqual(child[0]["category"], "removed", + "Changed child under removed parent = logically removed") + + +# --------------------------------------------------------------------------- +# R6: Post-cascade DOCS reconciliation (instruction contract) +# --------------------------------------------------------------------------- + +class TestDocsPostCascadeReconciliation(unittest.TestCase): + """Revise.md re-reads/reconciles DOCS after IC/testplan cascades (R6).""" + + @classmethod + def setUpClass(cls) -> None: + import re + revise_path = Path(__file__).resolve().parent.parent / "skills" / "revise.md" + cls._revise_md = revise_path.read_text(encoding="utf-8") + m4 = re.search(r"### Step 4:.*?(?=### Step 5:)", cls._revise_md, re.DOTALL) + assert m4, "Step 4 not found in revise.md" + cls._step4 = m4.group() + # Find the IC/testplan cascade sections (producers) + cls._ic_cascade_idx = cls._step4.find("design Interface Changes") + assert cls._ic_cascade_idx > 0, "IC cascade not found in Step 4" + cls._tc_cascade_idx = cls._step4.find("test cases were added, removed") + assert cls._tc_cascade_idx > 0, "TC cascade not found in Step 4" + # Find post-cascade reconciliation + cls._recon_idx = cls._step4.find( + "reconciliation after cascades") + assert cls._recon_idx > 0, "Post-cascade reconciliation not found" + # Find Step 5 boundary + cls._step5_boundary = len(cls._step4) + + def test_reconciliation_after_ic_cascade(self) -> None: + """Post-cascade DOCS reconciliation appears after IC cascade.""" + self.assertGreater(self._recon_idx, self._ic_cascade_idx, + "Reconciliation must appear after IC cascade") + + def test_reconciliation_after_tc_cascade(self) -> None: + """Post-cascade DOCS reconciliation appears after TC cascade.""" + self.assertGreater(self._recon_idx, self._tc_cascade_idx, + "Reconciliation must appear after TC cascade") + + def test_reconciliation_before_step5(self) -> None: + """Post-cascade DOCS reconciliation appears before Step 5 in full source.""" + # Use whole-source positions — the Step 4 slice comparison was vacuous + # (any valid index within a slice is trivially < len(slice)). + recon_pos = self._revise_md.find("reconciliation after cascades") + step5_pos = self._revise_md.find("### Step 5:") + self.assertGreater(recon_pos, 0) + self.assertGreater(step5_pos, 0) + self.assertLess(recon_pos, step5_pos, + "Reconciliation must appear before Step 5 in source") + + def test_recomputes_affected_set(self) -> None: + """Reconciliation recomputes affected DOCS set, not cached initial.""" + recon_text = self._step4[self._recon_idx:] + self.assertIn("recompute", recon_text.lower()) + + def test_preserves_prerequisite_deps(self) -> None: + """Reconciliation preserves prerequisite Dependencies.""" + recon_text = self._step4[self._recon_idx:] + self.assertIn("prerequisite", recon_text.lower()) + + def test_reapplies_seven_criteria(self) -> None: + """Reconciliation reapplies seven DOCS criteria after edits.""" + recon_text = self._step4[self._recon_idx:] + self.assertIn("seven", recon_text.lower()) + self.assertIn("criteria", recon_text.lower()) + + def test_covers_documentation_inputs(self) -> None: + """Reconciliation covers Documentation Inputs.""" + recon_text = self._step4[self._recon_idx:] + # Handle line-wrapped "Documentation\nInputs" + joined = " ".join(recon_text.split()) + self.assertIn("Documentation Inputs", joined) + + +# --------------------------------------------------------------------------- +# R7: Step 3 fail-closed capture recipe (instruction contract) +# --------------------------------------------------------------------------- + +class TestStep3FailClosedCapture(unittest.TestCase): + """Sync.md Step 3 uses fail-closed capture for plan-creation-order (R7).""" + + @classmethod + def setUpClass(cls) -> None: + import re + sync_path = Path(__file__).resolve().parent.parent / "skills" / "sync.md" + cls._sync_md = sync_path.read_text(encoding="utf-8") + m3 = re.search(r"### Step 3:.*?(?=### Step 4:)", cls._sync_md, re.DOTALL) + assert m3, "Step 3 not found in sync.md" + cls._step3 = m3.group() + # Find capture recipe + cls._recipe_idx = cls._step3.find("PLAN_ERR=$(mktemp)") + assert cls._recipe_idx >= 0, "Capture recipe not found" + # Find interpretation section + cls._interp_idx = cls._step3.find("Interpret the result") + assert cls._interp_idx >= 0, "Interpretation section not found" + # Find preflight + cls._preflight_idx = cls._step3.find("Preflight check") + assert cls._preflight_idx >= 0, "Preflight check not found" + # Find preview + cls._preview_idx = cls._step3.find("Dry-run preview") + assert cls._preview_idx >= 0, "Preview not found" + # Find individual branch boundaries within interpretation + interp = cls._step3[cls._interp_idx:cls._preflight_idx] + cls._branch1_idx = interp.find("1. **Exit 0") + assert cls._branch1_idx >= 0, "Branch 1 not found" + cls._branch2_idx = interp.find("2. **Exit 1") + assert cls._branch2_idx >= 0, "Branch 2 not found" + cls._branch3_idx = interp.find("3. **Any other") + assert cls._branch3_idx >= 0, "Branch 3 not found" + cls._branch1 = interp[cls._branch1_idx:cls._branch2_idx] + cls._branch2 = interp[cls._branch2_idx:cls._branch3_idx] + cls._branch3 = interp[cls._branch3_idx:] + + def test_recipe_uses_mktemp(self) -> None: + """Capture recipe uses mktemp for safe temp file.""" + self.assertIn("mktemp", self._step3) + + def test_recipe_captures_exit_code(self) -> None: + """Capture recipe uses PLAN_EXIT for errexit-safe status.""" + recipe = self._step3[self._recipe_idx:self._interp_idx] + self.assertIn("PLAN_EXIT", recipe) + self.assertIn("|| PLAN_EXIT=$?", recipe) + + def test_recipe_inline_cleanup(self) -> None: + """Temp file cleaned before branch interpretation.""" + recipe = self._step3[self._recipe_idx:self._interp_idx] + self.assertIn("rm -f", recipe) + + def test_recipe_mktemp_failure_fatal(self) -> None: + """mktemp allocation failure stops immediately.""" + recipe = self._step3[self._recipe_idx:self._interp_idx] + self.assertIn("FATAL", recipe) + self.assertIn("exit 1", recipe) + + def test_interpretation_precedes_preview(self) -> None: + """Interpretation happens before preview/preflight.""" + self.assertLess(self._interp_idx, self._preview_idx) + self.assertLess(self._interp_idx, self._preflight_idx) + + def test_success_requires_four_fields(self) -> None: + """Branch 1 requires all four JSON fields: ordered, blocked, + errors, satisfied_keys.""" + for field in ("ordered", "blocked", "errors", "satisfied_keys"): + self.assertIn(f"`{field}`", self._branch1, + f"Branch 1 success contract must require {field}") + + def test_success_requires_empty_errors_blocked(self) -> None: + """Branch 1 requires errors empty AND blocked empty.""" + self.assertIn("`errors` empty", self._branch1) + self.assertIn("`blocked` empty", self._branch1) + + def test_success_allows_empty_ordered(self) -> None: + """Branch 1: empty ordered is valid (no new stories to create).""" + self.assertIn("`ordered` may be empty", self._branch1, + "Branch 1 must explicitly allow empty ordered") + + def test_exit1_structured_errors_stop(self) -> None: + """Branch 2: exit 1 + valid JSON + non-empty errors → STOP.""" + self.assertIn("STOP", self._branch2, + "STOP must appear in Branch 2 (structured errors)") + + def test_malformed_fatal(self) -> None: + """Branch 3: malformed/empty/missing fields → fatal STOP.""" + lower = self._branch3.lower() + self.assertIn("malformed", lower) + self.assertIn("wrong-type", lower) + + def test_no_new_status_field(self) -> None: + """Success contract does not invent a 'status' field.""" + # The word "status" should not appear as a JSON field name + self.assertNotIn("`status`", self._branch1) + + +class TestStep3CLIFixtures(_TmpDirMixin, unittest.TestCase): + """Step 3 CLI validation: valid-clean, structural-error, fatal-output.""" + + def test_valid_clean_empty_ordered(self) -> None: + """Exit 0, empty ordered/blocked/errors: valid clean plan.""" + import subprocess + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building") + # No new stories at all + _make_manifest(Path(self.tmpdir), [{ + "file": "epic-1-building.md", "jira_key": "EDM-100", + "content_hash": sync_helper.compute_file_hash( + sd / "epic-1-building.md"), + "synced_status": "active", "stories": [], + }]) + r = subprocess.run( + [sys.executable, "-I", str(_SCRIPT), + "plan-creation-order", str(self.tmpdir)], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 0) + out = json.loads(r.stdout) + self.assertIsInstance(out["ordered"], list) + self.assertIsInstance(out["blocked"], list) + self.assertIsInstance(out["errors"], list) + self.assertIsInstance(out["satisfied_keys"], dict) + self.assertEqual(out["errors"], []) + self.assertEqual(out["blocked"], []) + + def test_structural_error_exit1_with_json(self) -> None: + """Exit 1, valid JSON with non-empty errors: structural error.""" + import subprocess + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "a", deps="Story 9.99") + r = subprocess.run( + [sys.executable, "-I", str(_SCRIPT), + "plan-creation-order", str(self.tmpdir)], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 1, "Structural errors must exit 1") + out = json.loads(r.stdout) + self.assertGreater(len(out["errors"]), 0) + # JSON still has all four required fields + for field in ("ordered", "blocked", "errors", "satisfied_keys"): + self.assertIn(field, out) + + def test_fatal_corrupted_manifest_no_json(self) -> None: + """Corrupted manifest → exit 1, stderr error, no valid JSON.""" + import subprocess + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "a", deps="None") + mf = Path(self.tmpdir) / "sync-manifest.json" + mf.write_text("NOT VALID JSON", encoding="utf-8") + r = subprocess.run( + [sys.executable, "-I", str(_SCRIPT), + "plan-creation-order", str(self.tmpdir)], + capture_output=True, text=True, timeout=10) + self.assertNotEqual(r.returncode, 0) + self.assertIn("ERROR", r.stderr) + # stdout should NOT be valid JSON with all four fields + try: + out = json.loads(r.stdout) + has_all = all(k in out for k in + ("ordered", "blocked", "errors", "satisfied_keys")) + self.assertFalse(has_all, + "Fatal error must not produce complete JSON") + except json.JSONDecodeError: + pass # Expected: no JSON at all + + +# --------------------------------------------------------------------------- +# R7: Step 3 Bash capture recipe execution under errexit +# --------------------------------------------------------------------------- + +class TestStep3CaptureRecipeExecution(unittest.TestCase): + """The documented Step 3 Bash capture recipe for plan-creation-order + executes correctly under set -euo pipefail with real helper calls. + + The recipe is extracted from the single bash fence in sync.md Step 3 + rather than hardcoded, so the tests break if the documented consumer + diverges.""" + + @classmethod + def setUpClass(cls) -> None: + import re as _r + sync_path = (Path(__file__).resolve().parent.parent + / "skills" / "sync.md") + sync_md = sync_path.read_text(encoding="utf-8") + m3 = _r.search(r"### Step 3:.*?(?=### Step 4:)", + sync_md, _r.DOTALL) + assert m3, "Step 3 not found in sync.md" + step3 = m3.group() + # Extract the single bash fence containing the capture recipe + fence_match = _r.search( + r"```bash\n(PLAN_ERR=\$\(mktemp\).*?)```", + step3, _r.DOTALL) + assert fence_match, "Capture fence not found in Step 3" + cls._fence = fence_match.group(1) + # Verify boundary markers + assert "PLAN_ERR=$(mktemp)" in cls._fence + assert "rm -f" in cls._fence + assert "PLAN_EXIT" in cls._fence + assert "PLAN_ERR_TEXT" in cls._fence + + @staticmethod + def _adapt_fence(artifacts_dir: str) -> str: + """Substitute controlled paths into the extracted fence.""" + script = TestStep3CaptureRecipeExecution._fence + # Replace python3 "$SYNC_HELPER" with sys.executable -I _SCRIPT + script = script.replace( + 'python3 "$SYNC_HELPER"', + f"'{sys.executable}' -I '{_SCRIPT}'") + # Replace artifact path placeholder + script = script.replace( + ".artifacts/design/{issue-key}/", artifacts_dir + "/") + return script + + def test_valid_clean_capture(self) -> None: + """Valid clean plan: exit=0, valid JSON, temp cleaned.""" + import subprocess + with tempfile.TemporaryDirectory() as tmpdir: + sd = Path(tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "impl", deps="None") + fence = self._adapt_fence(tmpdir) + script = ( + "set -euo pipefail\n" + + fence + + 'if [ -f "$PLAN_ERR" ]; then echo "LEAKED"; exit 1; fi\n' + + 'echo "exit=$PLAN_EXIT"\n' + + 'echo "$PLAN_JSON"\n' + ) + r = subprocess.run( + ["bash", "-c", script], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("exit=0", r.stdout) + self.assertNotIn("LEAKED", r.stdout) + # Parse the JSON from stdout (after "exit=0\n") + json_text = r.stdout.split("\n", 1)[1] + out = json.loads(json_text) + self.assertEqual(out["errors"], []) + self.assertEqual(out["blocked"], []) + self.assertIsInstance(out["ordered"], list) + self.assertIsInstance(out["satisfied_keys"], dict) + + def test_structured_error_capture(self) -> None: + """Structured error (exit=1 + JSON): captured correctly.""" + import subprocess + with tempfile.TemporaryDirectory() as tmpdir: + sd = Path(tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "impl", deps="Story 9.99") + fence = self._adapt_fence(tmpdir) + script = ( + "set -euo pipefail\n" + + fence + + 'if [ -f "$PLAN_ERR" ]; then echo "LEAKED"; exit 1; fi\n' + + 'echo "exit=$PLAN_EXIT"\n' + + 'echo "$PLAN_JSON"\n' + ) + r = subprocess.run( + ["bash", "-c", script], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("exit=1", r.stdout) + self.assertNotIn("LEAKED", r.stdout) + json_text = r.stdout.split("\n", 1)[1] + out = json.loads(json_text) + self.assertGreater(len(out["errors"]), 0) + + def test_fatal_corrupted_capture(self) -> None: + """Fatal (corrupted manifest): exit!=0, stderr captured, temp cleaned.""" + import subprocess + with tempfile.TemporaryDirectory() as tmpdir: + sd = Path(tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "impl", deps="None") + _write(Path(tmpdir) / "sync-manifest.json", "NOT JSON {{{") + fence = self._adapt_fence(tmpdir) + script = ( + "set -euo pipefail\n" + + fence + + 'if [ -f "$PLAN_ERR" ]; then echo "LEAKED"; exit 1; fi\n' + + 'echo "exit=$PLAN_EXIT"\n' + + 'echo "stderr=$PLAN_ERR_TEXT"\n' + ) + r = subprocess.run( + ["bash", "-c", script], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("exit=1", r.stdout) + self.assertIn("stderr=ERROR", r.stdout) + self.assertNotIn("LEAKED", r.stdout) + + +# --------------------------------------------------------------------------- +# R8: Repeat /decompose loads existing artifacts (instruction contract) +# --------------------------------------------------------------------------- + +class TestRepeatDecomposeArtifactLoad(unittest.TestCase): + """Decompose.md handles repeat runs with existing artifacts (R8).""" + + @classmethod + def setUpClass(cls) -> None: + import re + decompose_path = (Path(__file__).resolve().parent.parent + / "skills" / "decompose.md") + cls._decompose_md = decompose_path.read_text(encoding="utf-8") + m1 = re.search(r"### Step 1:.*?(?=### Step 2:)", + cls._decompose_md, re.DOTALL) + assert m1, "Step 1 not found in decompose.md" + cls._step1 = m1.group() + # Find the repeat-decompose block + repeat_idx = cls._step1.find("already exist") + assert repeat_idx >= 0, "Repeat-decompose block not found" + cls._repeat_block = cls._step1[repeat_idx:] + + def test_existing_artifact_handling(self) -> None: + """Step 1 mentions handling existing decomposition artifacts.""" + self.assertIn("already exist", self._step1) + + def test_loads_before_step2_planning(self) -> None: + """Repeat-decompose loading appears in Step 1 before Step 2.""" + self.assertIn("05-epics.md", self._repeat_block) + self.assertIn("06-stories", self._repeat_block) + self.assertIn("07-coverage.md", self._repeat_block) + + def test_includes_removed_status_in_inventory(self) -> None: + """Inventory includes files with status: removed.""" + self.assertIn("status: removed", self._repeat_block) + + def test_preserves_qualified_identities(self) -> None: + """Step 1 preserves existing qualified identities.""" + self.assertIn("qualified identit", self._repeat_block.lower()) + + def test_no_overwrite_mismatched_ids(self) -> None: + """Step 1 prohibits overwriting mismatched existing IDs.""" + self.assertIn("overwrite", self._repeat_block.lower()) + + def test_retired_numbers_not_reused(self) -> None: + """Step 1 prohibits reuse of retired numbers.""" + self.assertIn("retired", self._repeat_block.lower()) + + def test_explicit_restoration_required(self) -> None: + """Step 1 requires explicit approved restoration for removed items.""" + self.assertIn("restoration", self._repeat_block.lower()) + + def test_initial_empty_still_works(self) -> None: + """Step 1 notes initial empty decomposition still works.""" + self.assertIn("initial empty", self._repeat_block.lower()) + + def test_no_delete_recreate(self) -> None: + """Step 1 prohibits delete and recreate pattern.""" + self.assertIn("delete", self._repeat_block.lower()) + self.assertIn("recreate", self._repeat_block.lower()) + + +# --------------------------------------------------------------------------- +# R8: Controller 'from scratch' wording (instruction contract) +# --------------------------------------------------------------------------- + +class TestControllerDecomposeWording(unittest.TestCase): + """Controller.md does not say 'from scratch' for repeat decompose (R8).""" + + @classmethod + def setUpClass(cls) -> None: + ctrl_path = (Path(__file__).resolve().parent.parent + / "skills" / "controller.md") + cls._ctrl_md = ctrl_path.read_text(encoding="utf-8") + + def test_no_from_scratch(self) -> None: + """Controller does not say 'from scratch'.""" + self.assertNotIn("from scratch", self._ctrl_md) + + def test_preserves_existing_identities(self) -> None: + """Controller mentions preserved identities for repeat decompose.""" + self.assertIn("preserved existing identities", self._ctrl_md) + + +# --------------------------------------------------------------------------- +# R9: Relative protocol reference (instruction contract) +# --------------------------------------------------------------------------- + +class TestSyncHelperUsageReference(unittest.TestCase): + """sync-helper-usage.md uses correct relative path for sync protocol (R9).""" + + @classmethod + def setUpClass(cls) -> None: + ref_path = (Path(__file__).resolve().parent.parent + / "references" / "sync-helper-usage.md") + cls._ref_md = ref_path.read_text(encoding="utf-8") + + def test_relative_protocol_reference(self) -> None: + """Reference to sync protocol uses ../skills/sync.md path.""" + self.assertIn("../skills/sync.md", self._ref_md) + + def test_no_bare_sync_md_resource_ref(self) -> None: + """No bare ``sync.md`` used as a protocol resource reference.""" + import re + matches = re.findall(r'from `sync\.md`', self._ref_md) + self.assertEqual(len(matches), 0, + "Bare sync.md resource reference should use " + "../skills/sync.md") + + +# --------------------------------------------------------------------------- +# R3: DOCS criterion 5 scope wording (instruction contract) +# --------------------------------------------------------------------------- + +class TestDocsCriterion5Scope(unittest.TestCase): + """Decompose.md criterion 5 uses DOCS-story scope, not feature-wide (R3).""" + + @classmethod + def setUpClass(cls) -> None: + decompose_path = (Path(__file__).resolve().parent.parent + / "skills" / "decompose.md") + cls._decompose_md = decompose_path.read_text(encoding="utf-8") + + def test_criterion5_not_feature_wide(self) -> None: + """Criterion 5 does NOT say 'by the feature'.""" + import re + m = re.search(r"5\.\s+\*\*Complete Documentation Inputs\.\*\*.*?" + r"(?=6\.\s+\*\*Contributing)", + self._decompose_md, re.DOTALL) + self.assertIsNotNone(m, "Criterion 5 not found") + c5 = m.group() + self.assertNotIn("by the feature", c5.lower()) + + def test_criterion5_story_scope(self) -> None: + """Criterion 5 scopes to this DOCS story's declared scope.""" + import re + m = re.search(r"5\.\s+\*\*Complete Documentation Inputs\.\*\*.*?" + r"(?=6\.\s+\*\*Contributing)", + self._decompose_md, re.DOTALL) + self.assertIsNotNone(m) + c5 = m.group() + self.assertIn("story's declared scope", c5.lower()) + + def test_template_scope(self) -> None: + """Template Documentation Inputs placeholder scopes to story.""" + import re + m = re.search(r"## Documentation Inputs\n\n\{.*?\}", self._decompose_md, + re.DOTALL) + self.assertIsNotNone(m, "Template Documentation Inputs not found") + tmpl = m.group() + self.assertIn("scope", tmpl.lower()) + + +# --------------------------------------------------------------------------- +# R4: Replacement-reviewer includes testplan (instruction contract) +# --------------------------------------------------------------------------- + +class TestReplacementReviewerTestplan(unittest.TestCase): + """Decompose.md replacement-reviewer includes 04-testplan.md (R4).""" + + @classmethod + def setUpClass(cls) -> None: + import re + decompose_path = (Path(__file__).resolve().parent.parent + / "skills" / "decompose.md") + cls._decompose_md = decompose_path.read_text(encoding="utf-8") + m9 = re.search(r"### Step 9:.*?(?=### Step 10:)", + cls._decompose_md, re.DOTALL) + assert m9, "Step 9 not found in decompose.md" + cls._step9 = m9.group() + # Find the exact replacement-reviewer paragraph + replacement = re.search( + r"\*\*If resumption is not available:\*\*.*?(?=\*\*If subagents " + r"are not available:\*\*)", + cls._step9, re.DOTALL) + assert replacement, "Replacement-reviewer paragraph not found" + cls._replacement_para = replacement.group() + + def test_replacement_reviewer_includes_testplan(self) -> None: + """The exact replacement-reviewer paragraph includes 04-testplan.md.""" + self.assertIn("04-testplan.md", self._replacement_para) + + def test_replacement_reviewer_excludes_design(self) -> None: + """The exact replacement-reviewer paragraph excludes design doc.""" + self.assertIn("NOT the design document", self._replacement_para) + + +# --------------------------------------------------------------------------- +# F1: Partial-resume — decompose.md handles missing 07-coverage.md +# --------------------------------------------------------------------------- + +class TestPartialResumeContract(unittest.TestCase): + """Decompose.md Step 1 repeat-decompose handles interrupted prior run + where 07-coverage.md was not produced (F1).""" + + @classmethod + def setUpClass(cls) -> None: + import re + decompose_path = (Path(__file__).resolve().parent.parent + / "skills" / "decompose.md") + cls._decompose_md = decompose_path.read_text(encoding="utf-8") + m1 = re.search(r"### Step 1:.*?(?=### Step 2:)", + cls._decompose_md, re.DOTALL) + assert m1, "Step 1 not found in decompose.md" + cls._step1 = m1.group() + repeat_idx = cls._step1.find("already exist") + assert repeat_idx >= 0, "Repeat-decompose block not found" + cls._repeat_block = cls._step1[repeat_idx:] + + def test_coverage_read_is_conditional(self) -> None: + """07-coverage.md read in item 1 is conditional (if it exists).""" + # Find the numbered read instruction (item 1 of the list), + # not the detection condition that uses OR + item1_idx = self._repeat_block.find("1. Read") + self.assertGreater(item1_idx, 0, "Item 1 read instruction not found") + item1_text = self._repeat_block[item1_idx:item1_idx + 400] + self.assertIn("07-coverage.md", item1_text) + self.assertIn("if it exists", item1_text.lower(), + "07-coverage.md read must be conditional for interrupted runs") + + def test_interrupted_run_recovery_guidance(self) -> None: + """Step 1 mentions prior interrupted run and rebuild in Step 7.""" + self.assertIn("interrupted", self._repeat_block.lower()) + self.assertIn("Step 7", self._repeat_block) + + +# --------------------------------------------------------------------------- +# F2: Obsolete-work retirement on repeat decompose +# --------------------------------------------------------------------------- + +class TestObsoleteWorkRetirement(unittest.TestCase): + """Decompose.md retirement reconciliation after planning (F2). + + Step 1 inventories without deciding retirements; the reconciliation + sub-step at the end of Step 2 marks files no longer in the plan.""" + + @classmethod + def setUpClass(cls) -> None: + import re + decompose_path = (Path(__file__).resolve().parent.parent + / "skills" / "decompose.md") + cls._decompose_md = decompose_path.read_text(encoding="utf-8") + # Retirement reconciliation now lives in Step 2 + m2 = re.search(r"### Step 2:.*?(?=### Step 3:)", + cls._decompose_md, re.DOTALL) + assert m2, "Step 2 not found" + cls._step2 = m2.group() + + def test_retirement_via_status_removed(self) -> None: + """Individually retired work is marked status: removed, not deleted.""" + self.assertIn("status: removed", self._step2) + self.assertIn("individually", self._step2.lower()) + self.assertIn("explicitly retires", self._step2.lower()) + + def test_epic_stays_active_if_children_retained(self) -> None: + """Epic stays active if any child stories are retained.""" + self.assertIn("child stories are retained", self._step2.lower()) + + def test_cascade_safety_mentioned(self) -> None: + """Retirement guidance mentions cascade safety.""" + self.assertIn("cascade", self._step2.lower()) + + +class TestObsoleteWorkHelper(_TmpDirMixin, unittest.TestCase): + """detect_changes correctly classifies logically retired work (F2).""" + + def test_unmarked_stale_active_detected_as_changed(self) -> None: + """An active story with changed content is detected as changed, + eligible for retirement marking by the decomposer.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "impl", deps="None") + _make_manifest(Path(self.tmpdir), [{ + "file": "epic-1-building.md", "jira_key": "EDM-100", + "content_hash": "old-hash", "synced_status": "active", + "stories": [ + {"file": "story-01-impl.md", "jira_key": "EDM-201", + "content_hash": "old-different-hash", + "synced_status": "active"}, + ], + }]) + result = sync_helper.detect_changes(self.tmpdir) + story_items = [i for i in result["items"] + if i["type"] == "story" and i["epic_index"] == 1] + self.assertEqual(len(story_items), 1) + self.assertEqual(story_items[0]["category"], "changed", + "Active changed story must be 'changed', not skipped") + + def test_logically_removed_never_synced_skipped(self) -> None: + """A story with status: removed that was never synced is skipped.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "impl", deps="None", removed=True) + result = sync_helper.detect_changes(self.tmpdir) + story_items = [i for i in result["items"] + if i["type"] == "story" and i["epic_index"] == 1] + # Never-synced removed story should not appear in items + self.assertEqual(len(story_items), 0, + "Never-synced removed story must be skipped") + + def test_mixed_retention_same_epic(self) -> None: + """Two stories in same epic with different retention decisions. + + Regression proof: active epic, one story retained (active), one + retired (status: removed). Only the obsolete story is classified + for retirement; parent epic stays active; paths are stable. + """ + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building") + active_sf = _make_dev_story(sd, 1, 1, "scaffold", deps="None") + retired_sf = _make_dev_story(sd, 1, 2, "obsolete", deps="None", + removed=True) + _make_manifest(Path(self.tmpdir), [{ + "file": "epic-1-building.md", "jira_key": "EDM-100", + "content_hash": "old-hash", "synced_status": "active", + "stories": [ + {"file": "story-01-scaffold.md", "jira_key": "EDM-201", + "content_hash": "old-hash-1", "synced_status": "active"}, + {"file": "story-02-obsolete.md", "jira_key": "EDM-202", + "content_hash": "old-hash-2", "synced_status": "active"}, + ], + }]) + result = sync_helper.detect_changes(self.tmpdir) + # Parent epic stays active (changed, not removed) + epic_items = [i for i in result["items"] + if i["type"] == "epic" and i["epic_index"] == 1] + self.assertEqual(len(epic_items), 1) + self.assertNotEqual(epic_items[0]["category"], "removed", + "Parent epic must stay active") + # Active story: changed (content differs from manifest) + s1 = [i for i in result["items"] + if i["type"] == "story" and i.get("story_index") == 1] + self.assertEqual(len(s1), 1) + self.assertEqual(s1[0]["category"], "changed", + "Retained story must be 'changed'") + # Retired story: removed + s2 = [i for i in result["items"] + if i["type"] == "story" and i.get("story_index") == 2] + self.assertEqual(len(s2), 1) + self.assertEqual(s2[0]["category"], "removed", + "Retired story must be 'removed'") + # Both files still exist on disk (paths stable) + self.assertTrue(active_sf.exists(), "Retained story file must exist") + self.assertTrue(retired_sf.exists(), "Retired story file must exist") + + def test_retired_epic_no_child_dir_accepted(self) -> None: + """Retired epic without child directory is accepted by detect_changes. + + A partial run may create an epic file but never generate its + story directory. detect_changes must not crash or error; the + retired epic is an identity record. + """ + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + # Active epic with stories — required structure + _make_epic(sd, 1, "building") + _make_dev_story(sd, 1, 1, "scaffold", deps="None") + # Retired epic — only the file, no child directory + _make_epic(sd, 2, "legacy", removed=True) + self.assertFalse((sd / "epic-2").is_dir(), + "Retired epic must NOT have child directory") + result = sync_helper.detect_changes(self.tmpdir) + # Active epic appears; retired epic (never synced) is skipped + epic_items = [i for i in result["items"] + if i["type"] == "epic"] + active_epics = [e for e in epic_items if e["epic_index"] == 1] + self.assertEqual(len(active_epics), 1, "Active epic must appear") + retired_epics = [e for e in epic_items if e["epic_index"] == 2] + self.assertEqual(len(retired_epics), 0, + "Never-synced retired epic must be skipped") + + +# --------------------------------------------------------------------------- +# F3: Restoration/DOCS trigger in revise.md +# --------------------------------------------------------------------------- + +class TestDocsRestorationTrigger(unittest.TestCase): + """Revise.md includes parent-epic restoration in DOCS affected set (F3).""" + + @classmethod + def setUpClass(cls) -> None: + import re + revise_path = (Path(__file__).resolve().parent.parent + / "skills" / "revise.md") + cls._revise_md = revise_path.read_text(encoding="utf-8") + m4 = re.search(r"### Step 4:.*?(?=### Step 5:)", + cls._revise_md, re.DOTALL) + assert m4, "Step 4 not found in revise.md" + cls._step4 = m4.group() + trigger = re.search( + r"A `\[DOCS\]` story is affected when.*?(?=For each affected)", + cls._step4, re.DOTALL) + assert trigger, "DOCS affected-set trigger block not found" + cls._trigger_block = trigger.group() + cascade_idx = cls._step4.find("reconciliation after cascades") + assert cascade_idx >= 0 + cls._cascade_block = cls._step4[cascade_idx:] + + def test_restoration_trigger_exists(self) -> None: + """Trigger list includes parent epic restoration (trigger f).""" + self.assertIn("restored", self._trigger_block.lower()) + + def test_restoration_reactivates_unmarked_children(self) -> None: + """Restored parent makes children without own removal active.""" + self.assertIn("without their own", self._trigger_block.lower()) + + def test_individually_removed_child_stays_removed(self) -> None: + """Child with its own status: removed stays removed after restore.""" + self.assertIn("individually retired", self._trigger_block.lower()) + + def test_restoration_in_cascade_reconciliation(self) -> None: + """Post-cascade reconciliation mentions restoration.""" + self.assertIn("restoration", self._cascade_block.lower()) + + +class TestDocsRestorationHelper(_TmpDirMixin, unittest.TestCase): + """detect_changes handles parent restoration correctly (F3).""" + + def test_child_active_when_parent_restored(self) -> None: + """Unmarked child reopens when parent restored from closed. + + Both parent and child were previously cascade-closed (synced_status + "closed"). Parent is now locally active (no removed marker) — + approved restoration. Unmarked child should reopen.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + # Parent epic locally active (restored — no removed marker) + _make_epic(sd, 1, "building", removed=False) + # Child story locally active (no removed marker) + _make_dev_story(sd, 1, 1, "impl", deps="None") + # Sibling individually removed — should stay removed + _make_dev_story(sd, 1, 2, "other", deps="None", removed=True) + _make_manifest(Path(self.tmpdir), [{ + "file": "epic-1-building.md", "jira_key": "EDM-100", + "content_hash": "old-hash", "synced_status": "closed", + "stories": [ + {"file": "story-01-impl.md", "jira_key": "EDM-201", + "content_hash": "old-hash", "synced_status": "closed"}, + {"file": "story-02-other.md", "jira_key": "EDM-202", + "content_hash": "old-hash", "synced_status": "closed"}, + ], + }]) + result = sync_helper.detect_changes(self.tmpdir) + # Parent epic should reopen + epic_items = [i for i in result["items"] + if i["type"] == "epic" and i["epic_index"] == 1] + self.assertEqual(len(epic_items), 1) + self.assertEqual(epic_items[0]["category"], "reopened", + "Restored parent epic reopens") + # Unmarked child should reopen + story1 = [i for i in result["items"] + if i["type"] == "story" and i.get("story_index") == 1] + self.assertEqual(len(story1), 1) + self.assertEqual(story1[0]["category"], "reopened", + "Unmarked child under restored parent reopens") + # Individually removed sibling: locally removed, already closed + # in Jira (synced_status "closed") → unchanged (stays closed). + # _categorize_item(removed=True, synced_status="closed") returns + # "unchanged" — no spurious update/create for retired closed child. + story2 = [i for i in result["items"] + if i["type"] == "story" and i.get("story_index") == 2] + self.assertEqual(len(story2), 1) + self.assertEqual(story2[0]["category"], "unchanged", + "Individually removed+closed sibling stays unchanged") + + +# --------------------------------------------------------------------------- +# F4: Active-only coverage in decompose.md and review protocol +# --------------------------------------------------------------------------- + +class TestActiveCoverageContract(unittest.TestCase): + """Coverage matrix and review protocol evaluate active stories only (F4).""" + + @classmethod + def setUpClass(cls) -> None: + import re + decompose_path = (Path(__file__).resolve().parent.parent + / "skills" / "decompose.md") + cls._decompose_md = decompose_path.read_text(encoding="utf-8") + m7 = re.search(r"### Step 7:.*?(?=### Step 8:)", + cls._decompose_md, re.DOTALL) + assert m7, "Step 7 not found in decompose.md" + cls._step7 = m7.group() + + review_path = (Path(__file__).resolve().parent.parent + / "decomposition-review.md") + cls._review_md = review_path.read_text(encoding="utf-8") + crit1 = re.search( + r"1\.\s+\*\*Requirement Coverage\*\*.*?(?=2\.\s+\*\*Epic Structure\*\*)", + cls._review_md, re.DOTALL) + assert crit1, "Criterion 1 not found in review protocol" + cls._criterion1 = crit1.group() + + def test_step7_active_only_coverage(self) -> None: + """Step 7 defines active-only coverage for the matrix.""" + self.assertIn("effectively active", self._step7.lower()) + + def test_step7_retired_not_satisfying(self) -> None: + """Step 7 states retired stories do not satisfy coverage.""" + lower = self._step7.lower() + self.assertIn("identity", lower) + self.assertIn("traceability audit", lower) + + def test_step7_no_mask_coverage_gap(self) -> None: + """Step 7 forbids masking coverage gaps with retired work.""" + self.assertIn("Do not mask", self._step7) + + def test_step7_structure_gate_active_epics(self) -> None: + """Step 7 check 3 limits directory/story-file requirement to active epics.""" + self.assertIn("Each active epic", self._step7) + # Retired epic from partial run explicitly allowed without child dir + self.assertIn("identity record", self._step7.lower()) + + def test_step7_ic_tc_gate_active_stories(self) -> None: + """Step 7 check 6 limits IC/TC reference requirement to active stories.""" + self.assertIn("Every active non-", self._step7) + + def test_review_criterion1_active_only(self) -> None: + """Review criterion 1 requires effectively active stories.""" + self.assertIn("effectively active", self._criterion1.lower()) + + def test_review_criterion1_identity_audit(self) -> None: + """Review criterion 1 mentions retired stories retained for audit.""" + self.assertIn("identity", self._criterion1.lower()) + self.assertIn("audit", self._criterion1.lower()) + + def test_review_shared_active_scope(self) -> None: + """Evaluation criteria preamble scopes criteria 1-8 to active stories.""" + import re + m = re.search(r"## Evaluation Criteria\n\n.*?(?=1\.\s+\*\*Requirement)", + self._review_md, re.DOTALL) + self.assertIsNotNone(m, "Evaluation criteria preamble not found") + preamble = m.group().lower() + self.assertIn("effectively active", preamble) + self.assertIn("criteria 1", preamble) + self.assertIn("criterion 9", preamble) + + def test_review_retained_files_not_violating(self) -> None: + """Shared scope states retained files do not violate active coverage.""" + import re + m = re.search(r"## Evaluation Criteria\n\n.*?(?=1\.\s+\*\*Requirement)", + self._review_md, re.DOTALL) + self.assertIsNotNone(m) + # Join whitespace for line-wrapped text + preamble = " ".join(m.group().lower().split()) + self.assertIn("do not satisfy or violate", preamble) + + +# --------------------------------------------------------------------------- +# Frontmatter encoding: YAML frontmatter recognized, body marker ignored +# --------------------------------------------------------------------------- + +class TestFrontmatterEncoding(_TmpDirMixin, unittest.TestCase): + """has_removed_status only recognizes YAML frontmatter, not body markers.""" + + def test_frontmatter_status_removed_recognized(self) -> None: + """YAML frontmatter status: removed is detected by the helper.""" + p = Path(self.tmpdir) / "fm.md" + _write(p, "---\nstatus: removed\n---\n# Title\nBody text.\n") + self.assertTrue(sync_helper.has_removed_status(p)) + + def test_body_marker_status_removed_ignored(self) -> None: + """A body line 'status: removed' without frontmatter is NOT detected.""" + p = Path(self.tmpdir) / "body.md" + _write(p, "# Title\n\nstatus: removed\n\nMore text.\n") + self.assertFalse(sync_helper.has_removed_status(p)) + + def test_body_marker_after_frontmatter_ignored(self) -> None: + """Body 'status: removed' after valid frontmatter without it is ignored.""" + p = Path(self.tmpdir) / "mixed.md" + _write(p, "---\ntitle: active work\n---\n# Title\n\nstatus: removed\n") + self.assertFalse(sync_helper.has_removed_status(p)) + + +# --------------------------------------------------------------------------- +# Review protocol: criteria 2/4 scope to active epics +# --------------------------------------------------------------------------- + +class TestReviewEpicScope(unittest.TestCase): + """Review protocol criteria 2 and 4 evaluate active epics only.""" + + @classmethod + def setUpClass(cls) -> None: + import re + review_path = (Path(__file__).resolve().parent.parent + / "decomposition-review.md") + cls._review_md = review_path.read_text(encoding="utf-8") + + crit2 = re.search( + r"2\.\s+\*\*Epic Structure\*\*.*?(?=3\.\s+\*\*Story Completeness\*\*)", + cls._review_md, re.DOTALL) + assert crit2, "Criterion 2 not found" + cls._criterion2 = crit2.group() + + crit4 = re.search( + r"4\.\s+\*\*Testing Commitment\*\*.*?(?=5\.\s+\*\*Implementation Traceability\*\*)", + cls._review_md, re.DOTALL) + assert crit4, "Criterion 4 not found" + cls._criterion4 = crit4.group() + + def test_criterion2_scoped_to_active_epics(self) -> None: + """Criterion 2 evaluates active epics only.""" + lower = self._criterion2.lower() + self.assertIn("active epic", lower) + + def test_criterion4_scoped_to_active_epics(self) -> None: + """Criterion 4 QE check applies to active epics only.""" + lower = self._criterion4.lower() + self.assertIn("active epic", lower) + + def test_preamble_covers_both_artifact_types(self) -> None: + """Evaluation criteria preamble scopes to both epics and stories.""" + import re + m = re.search(r"## Evaluation Criteria\n\n.*?(?=1\.\s+\*\*Requirement)", + self._review_md, re.DOTALL) + self.assertIsNotNone(m) + preamble = " ".join(m.group().lower().split()) + self.assertIn("epic-level checks", preamble) + self.assertIn("story-level checks", preamble) + + +# --------------------------------------------------------------------------- +# Decompose instruction order: inventory before plan, retirement after plan +# --------------------------------------------------------------------------- + +class TestDecomposeRetirementOrder(unittest.TestCase): + """Step 1 inventories without retirement; reconciliation follows Step 2.""" + + @classmethod + def setUpClass(cls) -> None: + import re + decompose_path = (Path(__file__).resolve().parent.parent + / "skills" / "decompose.md") + cls._decompose_md = decompose_path.read_text(encoding="utf-8") + m1 = re.search(r"### Step 1:.*?(?=### Step 2:)", + cls._decompose_md, re.DOTALL) + assert m1, "Step 1 not found" + cls._step1 = m1.group() + + m2 = re.search(r"### Step 2:.*?(?=### Step 3:)", + cls._decompose_md, re.DOTALL) + assert m2, "Step 2 not found" + cls._step2 = m2.group() + + def test_step1_does_not_decide_retirement(self) -> None: + """Step 1 explicitly defers retirement decisions to after planning.""" + lower = self._step1.lower() + # Must NOT say "when the new plan no longer retains" in Step 1 + self.assertNotIn("new plan no longer retains", lower) + # Must say retirement decisions wait + self.assertIn("do not decide", lower) + + def test_reconciliation_after_planning(self) -> None: + """Retention planning completes before reconciliation writes markers. + + Move-sensitive: fails if reconciliation moves ahead of retention + planning in the source. Both positions are found in the same + bounded Step 2 slice. + """ + retention_pos = self._step2.lower().find("per-story retention") + reconcile_pos = self._step2.lower().find("reconcile retained inventory") + self.assertGreaterEqual(retention_pos, 0, + "Per-story retention assessment must exist in Step 2") + self.assertGreaterEqual(reconcile_pos, 0, + "Reconciliation must exist in Step 2") + self.assertLess(retention_pos, reconcile_pos, + "Per-story retention planning must complete before " + "reconciliation/marker writes") + + def test_story_level_retention_in_step2(self) -> None: + """Step 2 includes per-story retention decisions before reconciliation.""" + lower = self._step2.lower() + self.assertIn("per-story retention", lower) + self.assertIn("each existing\nstory", lower) + self.assertIn("individually", lower) + + def test_reconciliation_specifies_frontmatter(self) -> None: + """Reconciliation specifies YAML frontmatter encoding.""" + self.assertIn("read_frontmatter", self._step2) + self.assertIn("---", self._step2) + + def test_reconciliation_preserves_cascade_safety(self) -> None: + """Reconciliation mentions cascade safety for partial epic retention.""" + lower = self._step2.lower() + self.assertIn("cascade", lower) + self.assertIn("child stories are retained", lower) + + +# --------------------------------------------------------------------------- +# Guidelines retirement encoding rule +# --------------------------------------------------------------------------- + +class TestGuidelinesRetirementEncoding(unittest.TestCase): + """guidelines.md includes canonical retirement encoding rule.""" + + @classmethod + def setUpClass(cls) -> None: + guidelines_path = (Path(__file__).resolve().parent.parent + / "guidelines.md") + cls._guidelines = guidelines_path.read_text(encoding="utf-8") + + def test_retirement_encoding_present(self) -> None: + """Guidelines include retirement encoding rule.""" + self.assertIn("Retirement encoding", self._guidelines) + + def test_retirement_encoding_specifies_frontmatter(self) -> None: + """Retirement encoding rule references YAML frontmatter.""" + self.assertIn("read_frontmatter", self._guidelines) + + def test_retirement_encoding_warns_body_marker(self) -> None: + """Retirement encoding warns that body markers are not detected.""" + lower = self._guidelines.lower() + self.assertIn("body line", lower) + self.assertIn("not detected", lower) + + +# --------------------------------------------------------------------------- +# R1: Parent-only retirement preserves child marker distinction +# --------------------------------------------------------------------------- + +class TestParentOnlyRetirementContract(unittest.TestCase): + """Step 2 distinguishes a child's own removal marker from inherited + inactivity via a removed parent (R1). + + Contract tests verify the decompose.md instruction text contains the + required distinction so that parent-only retirement does not + materialize child ``status: removed`` markers. + """ + + _step2: str + + @classmethod + def setUpClass(cls) -> None: + import re + decompose_path = (Path(__file__).resolve().parent.parent + / "skills" / "decompose.md") + text = decompose_path.read_text(encoding="utf-8") + m2 = re.search(r"### Step 2:.*?(?=### Step 3:)", text, re.DOTALL) + assert m2, "Step 2 not found in decompose.md" + cls._step2 = m2.group() + + def _joined(self) -> str: + return " ".join(self._step2.lower().split()) + + def test_assessment_distinguishes_own_vs_inherited(self) -> None: + """Assessment distinguishes child's own disposition from inherited.""" + text = self._joined() + self.assertIn("own disposition", text) + self.assertIn("inherited inactivity", text) + + def test_reconcile_marks_parent_only(self) -> None: + """Reconcile instructs marking parent only, not child stories.""" + text = self._joined() + self.assertIn("mark the parent only", text) + + def test_reconcile_children_inherit_without_own_markers(self) -> None: + """Children inherit parent's inactive status without own markers.""" + text = self._joined() + self.assertIn("children inherit the parent's inactive status " + "through cascade without needing their own markers", + text) + + def test_reconcile_preserves_child_content(self) -> None: + """Reconcile preserves each child's original frontmatter and body.""" + text = self._joined() + self.assertIn("preserve each child's original frontmatter and body", + text) + + def test_reconcile_only_explicit_individual_retirement(self) -> None: + """Reconcile adds status:removed only for individually retired items.""" + text = self._joined() + self.assertIn("individually and explicitly retires", text) + + +class TestParentOnlyRetirementHelper(_TmpDirMixin, unittest.TestCase): + """detect_changes preserves unmarked child file bytes when only the + parent epic is retired (R1 helper counterexample). + + Parent removed → unmarked child suppressed (category 'removed') + but file content untouched. Already individually removed sibling + stays unchanged. Approved parent restoration → unmarked child + reopens, individually removed sibling stays exactly unchanged. + """ + + def test_unmarked_child_bytes_preserved_under_removed_parent(self) -> None: + """Parent removed, child unmarked: file bytes unchanged.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building", removed=True) + child = _make_dev_story(sd, 1, 1, "impl") + child_bytes_before = child.read_bytes() + child_hash_before = sync_helper.compute_file_hash(child) + + _make_manifest(Path(self.tmpdir), [{ + "file": "epic-1-building.md", "jira_key": "EDM-100", + "content_hash": "old-hash", "synced_status": "active", + "stories": [{ + "file": "story-01-impl.md", "jira_key": "EDM-201", + "content_hash": "old-hash", "synced_status": "active", + }], + }]) + result = sync_helper.detect_changes(self.tmpdir) + + # Child suppressed via parent cascade + stories = [i for i in result["items"] if i["type"] == "story"] + self.assertEqual(len(stories), 1) + self.assertEqual(stories[0]["category"], "removed", + "Unmarked child under removed parent must be suppressed") + # File bytes must be exactly unchanged + self.assertEqual(child.read_bytes(), child_bytes_before, + "Child file bytes must not be modified by detect_changes") + self.assertEqual(sync_helper.compute_file_hash(child), child_hash_before, + "Child content hash must remain unchanged") + + def test_individually_removed_sibling_stays_unchanged(self) -> None: + """Individually removed sibling stays 'unchanged' (already closed).""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building", removed=True) + _make_dev_story(sd, 1, 1, "impl") + sibling = _make_dev_story(sd, 1, 2, "other", removed=True) + sibling_bytes = sibling.read_bytes() + + _make_manifest(Path(self.tmpdir), [{ + "file": "epic-1-building.md", "jira_key": "EDM-100", + "content_hash": "old-hash", "synced_status": "active", + "stories": [ + {"file": "story-01-impl.md", "jira_key": "EDM-201", + "content_hash": "old-hash", "synced_status": "active"}, + {"file": "story-02-other.md", "jira_key": "EDM-202", + "content_hash": "old-hash", "synced_status": "closed"}, + ], + }]) + result = sync_helper.detect_changes(self.tmpdir) + + story2 = [i for i in result["items"] + if i["type"] == "story" and i.get("story_index") == 2] + self.assertEqual(len(story2), 1) + self.assertEqual(story2[0]["category"], "unchanged", + "Individually removed+closed sibling stays unchanged") + self.assertEqual(sibling.read_bytes(), sibling_bytes, + "Individually removed sibling bytes must be exact") + + def test_restoration_reopens_unmarked_not_individually_removed(self) -> None: + """Approved parent restoration → unmarked child reopens, + individually removed sibling stays exactly unchanged.""" + sd = Path(self.tmpdir) / "06-stories" + sd.mkdir() + _make_epic(sd, 1, "building", removed=False) # restored + child = _make_dev_story(sd, 1, 1, "impl") + sibling = _make_dev_story(sd, 1, 2, "other", removed=True) + sibling_bytes = sibling.read_bytes() + + _make_manifest(Path(self.tmpdir), [{ + "file": "epic-1-building.md", "jira_key": "EDM-100", + "content_hash": "old-hash", "synced_status": "closed", + "stories": [ + {"file": "story-01-impl.md", "jira_key": "EDM-201", + "content_hash": "old-hash", "synced_status": "closed"}, + {"file": "story-02-other.md", "jira_key": "EDM-202", + "content_hash": "old-hash", "synced_status": "closed"}, + ], + }]) + result = sync_helper.detect_changes(self.tmpdir) + + # Unmarked child reopens under restored parent + story1 = [i for i in result["items"] + if i["type"] == "story" and i.get("story_index") == 1] + self.assertEqual(len(story1), 1) + self.assertEqual(story1[0]["category"], "reopened", + "Unmarked child under restored parent must reopen") + # Individually removed sibling stays unchanged + story2 = [i for i in result["items"] + if i["type"] == "story" and i.get("story_index") == 2] + self.assertEqual(len(story2), 1) + self.assertEqual(story2[0]["category"], "unchanged", + "Individually removed sibling stays unchanged on restore") + self.assertEqual(sibling.read_bytes(), sibling_bytes, + "Individually removed sibling bytes exact after restore") + + +# --------------------------------------------------------------------------- +# R2: Step 7 active epic requires effectively active child +# --------------------------------------------------------------------------- + +class TestActiveEpicChildGateContract(unittest.TestCase): + """Step 7 check 3 requires at least one effectively active child story, + not merely a directory containing historical removed files (R2).""" + + _step7: str + + @classmethod + def setUpClass(cls) -> None: + import re + decompose_path = (Path(__file__).resolve().parent.parent + / "skills" / "decompose.md") + text = decompose_path.read_text(encoding="utf-8") + m7 = re.search(r"### Step 7:.*?(?=### Step 8:)", text, re.DOTALL) + assert m7, "Step 7 not found in decompose.md" + cls._step7 = m7.group() + + def _joined(self) -> str: + return " ".join(self._step7.lower().split()) + + def test_requires_effectively_active_child(self) -> None: + """Check 3 requires at least one effectively active child story.""" + self.assertIn("at least one effectively active", self._joined()) + + def test_removed_only_directory_insufficient(self) -> None: + """Directory with only status:removed files does not satisfy check.""" + self.assertIn("does not satisfy this check", self._joined()) + + def test_retired_epic_exception_preserved(self) -> None: + """Retired epic from partial run may lack child directory.""" + self.assertIn("retired epic from a partial run", self._joined()) + + +# --------------------------------------------------------------------------- +# R3: Step 9 Action 2 provenance scoped to effectively active DOCS +# --------------------------------------------------------------------------- + +class TestDocsProvenanceScopeContract(unittest.TestCase): + """Step 9 Action 2 limits provenance corrections to effectively active + DOCS stories only; retired/parent-inactive histories are not rewritten + against the current design (R3).""" + + _step9: str + + @classmethod + def setUpClass(cls) -> None: + import re + decompose_path = (Path(__file__).resolve().parent.parent + / "skills" / "decompose.md") + text = decompose_path.read_text(encoding="utf-8") + m9 = re.search(r"### Step 9:.*?(?=### Step 10:)", text, re.DOTALL) + assert m9, "Step 9 not found in decompose.md" + cls._step9 = m9.group() + + def _joined(self) -> str: + return " ".join(self._step9.lower().split()) + + def test_provenance_scoped_to_active_docs(self) -> None: + """Action 2 checks only effectively active DOCS stories.""" + self.assertIn("effectively active `[docs]` story", self._joined()) + + def test_retired_docs_not_rewritten(self) -> None: + """Retired/parent-inactive DOCS markers not rewritten.""" + text = self._joined() + self.assertIn("retained for identity audit", text) + self.assertIn("not rewritten against the current design", text) + + def test_unconditional_verification_preserved(self) -> None: + """Action 2 still runs unconditionally (even zero findings).""" + self.assertIn("runs unconditionally", self._step9) + + +# --------------------------------------------------------------------------- +# R1: Source activity field — quality eligibility (review 5832225697) +# --------------------------------------------------------------------------- + +class TestSourceActivityField(_TmpDirMixin, unittest.TestCase): + """Verify detect_changes emits source_active on every item. + + source_active determines whether a story is from an effectively + active source (own not-removed AND parent epic active). Step 4 + uses this to restrict the quality gate to active DOCS stories. + """ + + def setUp(self) -> None: + super().setUp() + self.artifacts = Path(self.tmpdir) / "art" + self.stories_dir = self.artifacts / "06-stories" + self.stories_dir.mkdir(parents=True) + + def test_fresh_sync_items_all_source_active(self) -> None: + """Fresh sync: all emitted items have source_active True.""" + _make_epic(self.stories_dir, 1, "building") + _make_dev_story(self.stories_dir, 1, 1, "scaffold") + result = sync_helper.detect_changes(self.artifacts) + for item in result["items"]: + self.assertTrue( + item.get("source_active"), + f"Fresh-sync item {item['file']} must be source_active") + + def test_active_story_under_active_parent_source_active(self) -> None: + """Unchanged story under active parent has source_active True.""" + ef = _make_epic(self.stories_dir, 1, "building") + sf = _make_dev_story(self.stories_dir, 1, 1, "scaffold") + _make_manifest(self.artifacts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": sync_helper.compute_file_hash(ef), + "synced_status": "active", + "stories": [{ + "file": "story-01-scaffold.md", "jira_key": "EDM-201", + "content_hash": sync_helper.compute_file_hash(sf), + "synced_status": "active", + }], + }]) + result = sync_helper.detect_changes(self.artifacts) + story = next(i for i in result["items"] if i["type"] == "story") + self.assertEqual(story["category"], "unchanged") + self.assertTrue(story["source_active"], + "Active story under active parent must be source_active") + + def test_unchanged_story_under_removed_parent_not_source_active(self) -> None: + """Hash-identical story under removed parent: source_active False. + + This is the R1 counterexample: the story's category stays + 'unchanged' (detect_changes suppression only covers changed/ + reopened/new), but source_active must be False because the parent + is inactive. Step 4 uses this to exclude it from quality gate. + """ + _make_epic(self.stories_dir, 1, "building", removed=True) + sf = _make_dev_story(self.stories_dir, 1, 1, "scaffold") + sf_hash = sync_helper.compute_file_hash(sf) + _make_manifest(self.artifacts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [{ + "file": "story-01-scaffold.md", "jira_key": "EDM-201", + "content_hash": sf_hash, + "synced_status": "active", + }], + }]) + result = sync_helper.detect_changes(self.artifacts) + story = next(i for i in result["items"] if i["type"] == "story") + self.assertEqual(story["category"], "unchanged", + "Hash-identical child keeps unchanged category") + self.assertFalse(story["source_active"], + "Story under removed parent must NOT be source_active") + + def test_changed_story_under_removed_parent_not_source_active(self) -> None: + """Changed story under removed parent: reclassified as removed, + source_active False.""" + _make_epic(self.stories_dir, 1, "building", removed=True) + _make_dev_story(self.stories_dir, 1, 1, "scaffold") + _make_manifest(self.artifacts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [{ + "file": "story-01-scaffold.md", "jira_key": "EDM-201", + "content_hash": "DIFFERENT", + "synced_status": "active", + }], + }]) + result = sync_helper.detect_changes(self.artifacts) + story = next(i for i in result["items"] if i["type"] == "story") + self.assertEqual(story["category"], "removed") + self.assertFalse(story["source_active"]) + + def test_story_under_reopened_parent_source_active(self) -> None: + """Story under a reopened parent (manifest closed, file active) + has source_active True — restoration makes parent active.""" + ef = _make_epic(self.stories_dir, 1, "building") + sf = _make_dev_story(self.stories_dir, 1, 1, "scaffold") + _make_manifest(self.artifacts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "closed", + "stories": [{ + "file": "story-01-scaffold.md", "jira_key": "EDM-201", + "content_hash": "oldhash", + "synced_status": "closed", + }], + }]) + result = sync_helper.detect_changes(self.artifacts) + epic = next(i for i in result["items"] if i["type"] == "epic") + story = next(i for i in result["items"] if i["type"] == "story") + self.assertEqual(epic["category"], "reopened") + self.assertTrue(epic["source_active"], + "Reopened epic must be source_active") + self.assertEqual(story["category"], "reopened") + self.assertTrue(story["source_active"], + "Story under reopened parent must be source_active") + + def test_individually_removed_story_under_active_parent(self) -> None: + """Story with own status:removed under active parent: + source_active False regardless of parent status.""" + _make_epic(self.stories_dir, 1, "building") + _make_dev_story(self.stories_dir, 1, 1, "scaffold", removed=True) + _make_manifest(self.artifacts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [{ + "file": "story-01-scaffold.md", "jira_key": "EDM-201", + "content_hash": "oldhash", + "synced_status": "active", + }], + }]) + result = sync_helper.detect_changes(self.artifacts) + story = next(i for i in result["items"] if i["type"] == "story") + self.assertEqual(story["category"], "removed") + self.assertFalse(story["source_active"], + "Individually removed story must NOT be source_active") + + def test_orphaned_items_not_source_active(self) -> None: + """Orphaned epic and story entries have source_active False.""" + # Create a manifest with items whose files don't exist + (self.stories_dir / "epic-1-gone").mkdir(parents=True) + _make_manifest(self.artifacts, [{ + "file": "epic-1-gone.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "title": "Gone", + "stories": [{ + "file": "story-01-lost.md", "jira_key": "EDM-201", + "content_hash": "h", "synced_status": "active", + "title": "Lost", + }], + }]) + # No files on disk → orphaned + result = sync_helper.detect_changes(self.artifacts) + orphans = [i for i in result["items"] if i["category"] == "orphaned"] + self.assertTrue(len(orphans) >= 1, "Should have orphaned items") + for item in orphans: + self.assertFalse(item["source_active"], + f"Orphan {item['file']} must NOT be source_active") + + def test_epic_with_removed_status_not_source_active(self) -> None: + """Epic with status:removed has source_active False.""" + _make_epic(self.stories_dir, 1, "building", removed=True) + _make_manifest(self.artifacts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [], + }]) + result = sync_helper.detect_changes(self.artifacts) + epic = next(i for i in result["items"] if i["type"] == "epic") + self.assertEqual(epic["category"], "removed") + self.assertFalse(epic["source_active"]) + + +class TestQualityEligibilityContract(unittest.TestCase): + """Step 4 quality gate must filter on source_active (R1).""" + + @classmethod + def setUpClass(cls) -> None: + import re + sync_path = Path(__file__).resolve().parent.parent / "skills" / "sync.md" + cls._sync_md = sync_path.read_text(encoding="utf-8") + m4 = re.search(r"### Step 4:.*?(?=### Step 5:)", cls._sync_md, re.DOTALL) + assert m4, "Step 4 not found" + cls._step4 = m4.group() + + def test_step4_references_source_active(self) -> None: + """Step 4 mentions source_active as the activity filter.""" + self.assertIn("source_active", self._step4) + + def test_step4_inactive_excluded_from_quality(self) -> None: + """Step 4 excludes inactive-source stories from quality gate.""" + lower = self._step4.lower() + self.assertIn("excluded from quality validation", lower) + + def test_step4_fail_closed_unknown_parent(self) -> None: + """Step 4 STOPs sync when source activity cannot be determined.""" + self.assertIn("stop the sync", self._step4.lower()) + + def test_step4_restored_parent_eligible(self) -> None: + """Step 4 recognizes reopened parents as active.""" + self.assertIn("reopened", self._step4.lower()) + self.assertIn("eligible", self._step4.lower()) + + def test_step4_individually_removed_ineligible(self) -> None: + """Step 4 keeps individually removed stories ineligible.""" + self.assertIn("Individually removed stories remain ineligible", + self._step4) + + def test_step4_eligible_filter_before_criteria(self) -> None: + """source_active filter appears before the seven-criteria check.""" + sa_pos = self._step4.find("source_active") + criteria_pos = self._step4.find("seven criteria") + self.assertGreater(criteria_pos, sa_pos, + "source_active filter must appear before criteria check") + + def test_step4_affected_unchanged_also_filtered(self) -> None: + """Affected-unchanged DOCS also require eligible (source-active).""" + # The text should say "eligible" before the unchanged-but-affected clause + eligible_pos = self._step4.find("eligible (source-active)") + unchanged_clause = self._step4.find("unchanged but whose contributor") + self.assertGreater(unchanged_clause, eligible_pos, + "Eligible filter must cover unchanged-but-affected DOCS too") + + +# --------------------------------------------------------------------------- +# R2: Truthful cascade preview (review 5832225697) +# --------------------------------------------------------------------------- + +class TestCascadeClosurePreview(_TmpDirMixin, unittest.TestCase): + """Helper-level tests for compute_cascade_closures. + + Verifies that the function correctly identifies cascade closures + that Step 5c will perform but that detect_changes doesn't surface. + """ + + def setUp(self) -> None: + super().setUp() + self.artifacts = Path(self.tmpdir) / "art" + self.stories_dir = self.artifacts / "06-stories" + self.stories_dir.mkdir(parents=True) + + def test_unchanged_child_under_removed_parent_in_cascade(self) -> None: + """Hash-identical active child under removed parent appears in + cascade list — it is category 'unchanged' in detect_changes but + Step 5c will close it.""" + _make_epic(self.stories_dir, 1, "building", removed=True) + sf = _make_dev_story(self.stories_dir, 1, 1, "scaffold") + sf_hash = sync_helper.compute_file_hash(sf) + manifest_data = [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [{ + "file": "story-01-scaffold.md", "jira_key": "EDM-201", + "title": "[DEV] Scaffold", + "content_hash": sf_hash, + "synced_status": "active", + }], + }] + _make_manifest(self.artifacts, manifest_data) + manifest = sync_helper.load_manifest( + self.artifacts / "sync-manifest.json") + plan = sync_helper.detect_changes(self.artifacts) + # Confirm the story is unchanged in detect_changes + story = next(i for i in plan["items"] if i["type"] == "story") + self.assertEqual(story["category"], "unchanged") + # Compute cascade closures + result = sync_helper.compute_cascade_closures( + plan["items"], manifest) + cascades = result["cascades"] + self.assertEqual(len(cascades), 1, + "Unchanged active child must appear in cascade") + self.assertEqual(cascades[0]["jira_key"], "EDM-201") + self.assertEqual(cascades[0]["parent_jira_key"], "EDM-101") + self.assertEqual(cascades[0]["parent_reason"], "removed") + + def test_individually_removed_child_not_duplicated(self) -> None: + """Child with own status:removed is already in ToClose; + cascade must not double-count it.""" + _make_epic(self.stories_dir, 1, "building", removed=True) + _make_dev_story(self.stories_dir, 1, 1, "scaffold", removed=True) + manifest_data = [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [{ + "file": "story-01-scaffold.md", "jira_key": "EDM-201", + "content_hash": "oldhash", + "synced_status": "active", + }], + }] + _make_manifest(self.artifacts, manifest_data) + manifest = sync_helper.load_manifest( + self.artifacts / "sync-manifest.json") + plan = sync_helper.detect_changes(self.artifacts) + result = sync_helper.compute_cascade_closures( + plan["items"], manifest) + self.assertEqual(len(result["cascades"]), 0, + "Individually removed child must NOT be in cascades") + + def test_already_closed_child_excluded(self) -> None: + """Child with synced_status 'closed' in manifest is excluded.""" + _make_epic(self.stories_dir, 1, "building", removed=True) + _make_dev_story(self.stories_dir, 1, 1, "scaffold") + manifest_data = [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [{ + "file": "story-01-scaffold.md", "jira_key": "EDM-201", + "content_hash": "oldhash", + "synced_status": "closed", + }], + }] + _make_manifest(self.artifacts, manifest_data) + manifest = sync_helper.load_manifest( + self.artifacts / "sync-manifest.json") + plan = sync_helper.detect_changes(self.artifacts) + result = sync_helper.compute_cascade_closures( + plan["items"], manifest) + self.assertEqual(len(result["cascades"]), 0, + "Already-closed child must NOT be in cascades") + + def test_no_cascade_when_no_closing_parents(self) -> None: + """No cascades when all epics are active.""" + _make_epic(self.stories_dir, 1, "building") + sf = _make_dev_story(self.stories_dir, 1, 1, "scaffold") + sf_hash = sync_helper.compute_file_hash(sf) + manifest_data = [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": sync_helper.compute_file_hash( + self.stories_dir / "epic-1-building.md"), + "synced_status": "active", + "stories": [{ + "file": "story-01-scaffold.md", "jira_key": "EDM-201", + "content_hash": sf_hash, + "synced_status": "active", + }], + }] + _make_manifest(self.artifacts, manifest_data) + manifest = sync_helper.load_manifest( + self.artifacts / "sync-manifest.json") + plan = sync_helper.detect_changes(self.artifacts) + result = sync_helper.compute_cascade_closures( + plan["items"], manifest) + self.assertEqual(len(result["cascades"]), 0) + + def test_orphan_parent_cascade_requires_close_orphans(self) -> None: + """Orphaned parent only cascades when close_orphans=True.""" + # Manifest has epic-1 but no file on disk → orphaned + (self.stories_dir / "epic-1").mkdir(parents=True) + manifest_data = [{ + "file": "epic-1-gone.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "title": "Gone", + "stories": [{ + "file": "story-01-lost.md", "jira_key": "EDM-201", + "content_hash": "h", "synced_status": "active", + "title": "Lost", + }], + }] + _make_manifest(self.artifacts, manifest_data) + manifest = sync_helper.load_manifest( + self.artifacts / "sync-manifest.json") + plan = sync_helper.detect_changes(self.artifacts) + # Without close_orphans: no cascade + result_no = sync_helper.compute_cascade_closures( + plan["items"], manifest, close_orphans=False) + self.assertEqual(len(result_no["cascades"]), 0, + "Ignored orphan parent must NOT produce cascades") + # With close_orphans: cascade includes children + result_yes = sync_helper.compute_cascade_closures( + plan["items"], manifest, close_orphans=True) + # Both epic and story are orphaned; with close_orphans the story + # orphan is already individually_closing, so cascade should be 0 + # unless the story orphan was already matched. + # Actually: orphaned story is also in items as "orphaned" and + # close_orphans=True puts it in individually_closing. So 0. + # The cascade is for cases where children have FILES but are + # unchanged. Purely orphaned children are already shown. + self.assertEqual(len(result_yes["cascades"]), 0, + "Orphaned child already individually shown") + + def test_cascade_mixed_children(self) -> None: + """Parent with mixed children: only unchanged active children + appear in cascade, not individually removed or already closed.""" + _make_epic(self.stories_dir, 1, "building", removed=True) + sf1 = _make_dev_story(self.stories_dir, 1, 1, "active-child") + sf2 = _make_dev_story(self.stories_dir, 1, 2, "removed-child", + removed=True) + sf3 = _make_dev_story(self.stories_dir, 1, 3, "also-active") + sf1_hash = sync_helper.compute_file_hash(sf1) + sf3_hash = sync_helper.compute_file_hash(sf3) + manifest_data = [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [ + {"file": "story-01-active-child.md", "jira_key": "EDM-201", + "title": "[DEV] Active", "content_hash": sf1_hash, + "synced_status": "active"}, + {"file": "story-02-removed-child.md", "jira_key": "EDM-202", + "title": "[DEV] Removed", "content_hash": "oldhash", + "synced_status": "active"}, + {"file": "story-03-also-active.md", "jira_key": "EDM-203", + "title": "[DEV] Also Active", "content_hash": sf3_hash, + "synced_status": "active"}, + {"file": "story-04-closed.md", "jira_key": "EDM-204", + "title": "[DEV] Closed", "content_hash": "h", + "synced_status": "closed"}, + ], + }] + _make_manifest(self.artifacts, manifest_data) + manifest = sync_helper.load_manifest( + self.artifacts / "sync-manifest.json") + plan = sync_helper.detect_changes(self.artifacts) + result = sync_helper.compute_cascade_closures( + plan["items"], manifest) + cascade_keys = {c["jira_key"] for c in result["cascades"]} + # story-01 and story-03 are unchanged active → cascade + self.assertIn("EDM-201", cascade_keys, + "Unchanged active child must be in cascade") + self.assertIn("EDM-203", cascade_keys, + "Unchanged active child must be in cascade") + # story-02 is individually removed → not in cascade + self.assertNotIn("EDM-202", cascade_keys, + "Individually removed child must NOT be in cascade") + # story-04 is already closed → not in cascade + self.assertNotIn("EDM-204", cascade_keys, + "Already-closed child must NOT be in cascade") + + def test_selected_vs_ignored_orphan_parent(self) -> None: + """Only selected (close_orphans=True) orphan parent cascades.""" + # Create a real epic with unchanged active children, plus an + # orphan epic with children. Only the removed epic should cascade + # regardless; orphan only cascades with flag. + ef = _make_epic(self.stories_dir, 1, "real", removed=True) + sf = _make_dev_story(self.stories_dir, 1, 1, "child") + sf_hash = sync_helper.compute_file_hash(sf) + (self.stories_dir / "epic-2").mkdir(parents=True) + manifest_data = [ + {"file": "epic-1-real.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [{ + "file": "story-01-child.md", "jira_key": "EDM-201", + "title": "Child", "content_hash": sf_hash, + "synced_status": "active", + }]}, + {"file": "epic-2-orphan.md", "jira_key": "EDM-102", + "content_hash": "h", "synced_status": "active", + "title": "Orphan", + "stories": [{ + "file": "story-01-orphchild.md", "jira_key": "EDM-301", + "title": "OrphChild", "content_hash": "h", + "synced_status": "active", + }]}, + ] + _make_manifest(self.artifacts, manifest_data) + manifest = sync_helper.load_manifest( + self.artifacts / "sync-manifest.json") + plan = sync_helper.detect_changes(self.artifacts) + # Without close_orphans: only removed parent cascades + result_no = sync_helper.compute_cascade_closures( + plan["items"], manifest, close_orphans=False) + keys_no = {c["jira_key"] for c in result_no["cascades"]} + self.assertIn("EDM-201", keys_no, + "Removed parent must cascade child") + self.assertNotIn("EDM-301", keys_no, + "Ignored orphan parent must NOT cascade child") + + def test_cascade_cli_subcommand(self) -> None: + """CLI compute-cascade-closures produces valid JSON.""" + _make_epic(self.stories_dir, 1, "building", removed=True) + sf = _make_dev_story(self.stories_dir, 1, 1, "scaffold") + sf_hash = sync_helper.compute_file_hash(sf) + _make_manifest(self.artifacts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [{ + "file": "story-01-scaffold.md", "jira_key": "EDM-201", + "title": "[DEV] Scaffold", + "content_hash": sf_hash, + "synced_status": "active", + }], + }]) + out = StringIO() + with mock.patch("sys.stdout", out): + exit_code = sync_helper.main([ + "compute-cascade-closures", str(self.artifacts)]) + self.assertEqual(exit_code, 0) + data = json.loads(out.getvalue()) + self.assertIn("cascades", data) + self.assertEqual(len(data["cascades"]), 1) + self.assertEqual(data["cascades"][0]["jira_key"], "EDM-201") + + def test_approved_union_covers_individual_plus_cascade(self) -> None: + """Counterexample: removed parent EDM-101, individually removed + active child EDM-201, unchanged active child EDM-202. + + compute_cascade_closures returns only {EDM-202} (excludes + individually removed child). detect_changes items list + EDM-201 as 'removed'. The approved union is {EDM-201, EDM-202}. + Both EDM-201 and EDM-202 are manifest-active at Step 5c time. + + Confirms the data supports the union: individually listed + children + cascade supplement = full approved scope. + """ + _make_epic(self.stories_dir, 1, "building", removed=True) + sf1 = _make_dev_story(self.stories_dir, 1, 1, "scaffold", + removed=True) + sf2 = _make_dev_story(self.stories_dir, 1, 2, "also-active") + sf2_hash = sync_helper.compute_file_hash(sf2) + manifest_data = [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [ + {"file": "story-01-scaffold.md", "jira_key": "EDM-201", + "content_hash": "oldhash", "synced_status": "active"}, + {"file": "story-02-also-active.md", "jira_key": "EDM-202", + "content_hash": sf2_hash, "synced_status": "active"}, + ], + }] + _make_manifest(self.artifacts, manifest_data) + manifest = sync_helper.load_manifest( + self.artifacts / "sync-manifest.json") + plan = sync_helper.detect_changes(self.artifacts) + + # EDM-201 is individually listed as 'removed' in items + individually_removed = { + i["jira_key"] for i in plan["items"] + if i["type"] == "story" and i["category"] == "removed" + } + self.assertIn("EDM-201", individually_removed) + + # cascade returns only EDM-202 (excludes individually removed) + result = sync_helper.compute_cascade_closures( + plan["items"], manifest) + cascade_keys = {c["jira_key"] for c in result["cascades"]} + self.assertEqual(cascade_keys, {"EDM-202"}) + self.assertNotIn("EDM-201", cascade_keys, + "Individually removed child excluded from cascade") + + # The approved union = individual + cascade = {EDM-201, EDM-202} + approved_union = individually_removed | cascade_keys + self.assertEqual(approved_union, {"EDM-201", "EDM-202"}) + + # Manifest-active children = {EDM-201, EDM-202} + manifest_active = { + s["jira_key"] for e in manifest["epics"] + if e["file"] == "epic-1-building.md" + for s in e.get("stories", []) + if s.get("synced_status", "active") == "active" + } + self.assertEqual(manifest_active, {"EDM-201", "EDM-202"}) + + # Guard must pass: manifest_active ⊆ approved_union + self.assertTrue( + manifest_active <= approved_union, + "Manifest-active children must be a subset of the approved " + "union (individual + cascade), not just cascade-only") + + def test_orphan_parent_close_orphans_true_union(self) -> None: + """Selected orphan parent (close_orphans=True): missing parent + + child source files, both manifest-active with Jira keys. + + compute_cascade_closures(close_orphans=True) returns 0 + supplement because the orphaned child is already individually + closing. The approved union must still include that child + via the individually-closing orphan path. + """ + # No files on disk for this parent — purely orphaned + (self.stories_dir / "epic-2").mkdir(parents=True, exist_ok=True) + manifest_data = [{ + "file": "epic-2-gone.md", "jira_key": "EDM-101", + "content_hash": "h", "synced_status": "active", + "title": "Gone", + "stories": [{ + "file": "story-01-lost.md", "jira_key": "EDM-201", + "content_hash": "h", "synced_status": "active", + "title": "[DEV] Lost", + }], + }] + _make_manifest(self.artifacts, manifest_data) + manifest = sync_helper.load_manifest( + self.artifacts / "sync-manifest.json") + plan = sync_helper.detect_changes(self.artifacts) + + # Both parent and child are orphaned + orphaned_items = [i for i in plan["items"] + if i["category"] == "orphaned"] + self.assertEqual(len(orphaned_items), 2, + "Both parent and child must be orphaned") + + # With close_orphans=True: child is individually closing + individually_closing = { + i["jira_key"] for i in plan["items"] + if i["type"] == "story" and i["category"] == "orphaned" + } + self.assertIn("EDM-201", individually_closing) + + # Cascade returns 0 (child already individually closing) + result = sync_helper.compute_cascade_closures( + plan["items"], manifest, close_orphans=True) + self.assertEqual(len(result["cascades"]), 0, + "Orphaned child already individually shown") + + # Approved union = individually closing orphans + cascade = {EDM-201} + cascade_keys = {c["jira_key"] for c in result["cascades"]} + approved_union = individually_closing | cascade_keys + self.assertEqual(approved_union, {"EDM-201"}) + + # Manifest-active child = {EDM-201} + manifest_active = { + s["jira_key"] for e in manifest["epics"] + for s in e.get("stories", []) + if s.get("synced_status", "active") == "active" + } + self.assertEqual(manifest_active, {"EDM-201"}) + + # Guard passes: manifest_active ⊆ approved_union + self.assertTrue( + manifest_active <= approved_union, + "Orphan child must be in approved union via " + "individually-closing path") + + # Without close_orphans: no closures at all + result_no = sync_helper.compute_cascade_closures( + plan["items"], manifest, close_orphans=False) + self.assertEqual(len(result_no["cascades"]), 0) + + +class TestCascadePreviewContract(unittest.TestCase): + """Step 3 preview must include cascade closures (R2).""" + + @classmethod + def setUpClass(cls) -> None: + import re + sync_path = Path(__file__).resolve().parent.parent / "skills" / "sync.md" + cls._sync_md = sync_path.read_text(encoding="utf-8") + m3 = re.search(r"### Step 3:.*?(?=### Step 4:)", cls._sync_md, re.DOTALL) + assert m3, "Step 3 not found" + cls._step3 = m3.group() + + def test_cascade_closure_preview_present(self) -> None: + """Step 3 includes cascade closure computation.""" + self.assertIn("compute-cascade-closures", self._step3) + + def test_cascade_in_to_close_section(self) -> None: + """To Close section includes cascade count.""" + self.assertIn("including {C} cascade closures", self._step3) + + def test_cascade_parent_annotation(self) -> None: + """Cascade items show parent key annotation.""" + self.assertIn("cascade: parent {parent_jira_key}", self._step3) + + def test_cascade_approval_covers_children(self) -> None: + """Step 3 states parent approval covers cascade children.""" + self.assertIn("parent's close approval covers them", self._step3) + + def test_no_cascade_omit_instruction(self) -> None: + """If no cascades, the cascade count and rows are omitted.""" + self.assertIn("omit the cascade count and rows", self._step3) + + def test_adjusted_unchanged_count(self) -> None: + """Step 3 instructs adjusting Unchanged total for cascades.""" + self.assertIn("subtract from Unchanged", self._step3) + + def test_ignored_orphan_no_cascade(self) -> None: + """Ignored orphan parents must not produce cascades.""" + self.assertIn("Ignored orphan parents", self._step3) + + def test_step3_preview_consistent_with_step5c(self) -> None: + """Step 3 preview references Step 5c for cascade behavior.""" + self.assertIn("Step 5c", self._step3) + + +class TestCascadePreviewSourceConsistency(_TmpDirMixin, unittest.TestCase): + """Verify cascade preview is consistent with Step 5c's actual set. + + Step 5c closes ALL manifest-active children under a removed parent. + The cascade preview must enumerate the same set (minus individually- + removed children which are already shown in ToClose). + """ + + def setUp(self) -> None: + super().setUp() + self.artifacts = Path(self.tmpdir) / "art" + self.stories_dir = self.artifacts / "06-stories" + self.stories_dir.mkdir(parents=True) + + def test_preview_matches_step5c_closure_set(self) -> None: + """cascade closures + individually-removed = all active children + that Step 5c will close.""" + _make_epic(self.stories_dir, 1, "building", removed=True) + sf1 = _make_dev_story(self.stories_dir, 1, 1, "unchanged") + sf2 = _make_dev_story(self.stories_dir, 1, 2, "changed") + sf3 = _make_dev_story(self.stories_dir, 1, 3, "also-removed", + removed=True) + sf1_hash = sync_helper.compute_file_hash(sf1) + manifest_data = [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [ + {"file": "story-01-unchanged.md", "jira_key": "EDM-201", + "title": "S1", "content_hash": sf1_hash, + "synced_status": "active"}, + {"file": "story-02-changed.md", "jira_key": "EDM-202", + "title": "S2", "content_hash": "DIFFERENT", + "synced_status": "active"}, + {"file": "story-03-also-removed.md", "jira_key": "EDM-203", + "title": "S3", "content_hash": "oldhash", + "synced_status": "active"}, + {"file": "story-04-closed.md", "jira_key": "EDM-204", + "title": "S4", "content_hash": "h", + "synced_status": "closed"}, + ], + }] + _make_manifest(self.artifacts, manifest_data) + manifest = sync_helper.load_manifest( + self.artifacts / "sync-manifest.json") + plan = sync_helper.detect_changes(self.artifacts) + result = sync_helper.compute_cascade_closures( + plan["items"], manifest) + + # Step 5c would close: EDM-201, EDM-202, EDM-203 (all active) + # Not EDM-204 (already closed) + step5c_set = {"EDM-201", "EDM-202", "EDM-203"} + + # From detect_changes: EDM-202 is "removed" (changed → suppressed), + # EDM-203 is "removed" (own status:removed) + individually_removed = set() + for item in plan["items"]: + if item.get("type") == "story" and item.get("category") == "removed": + individually_removed.add(item["jira_key"]) + # EDM-201 is "unchanged" → only in cascade + cascade_keys = {c["jira_key"] for c in result["cascades"]} + + # Combined set must equal Step 5c set + preview_total = individually_removed | cascade_keys + self.assertEqual(preview_total, step5c_set, + "Preview (individual + cascade) must match Step 5c set") + + def test_no_child_file_cascade(self) -> None: + """Manifest child with no file on disk: appears as orphan in + items, not as cascade (unless orphan is ignored).""" + _make_epic(self.stories_dir, 1, "building", removed=True) + # Only story-01 exists; story-02 is orphan (no file) + sf1 = _make_dev_story(self.stories_dir, 1, 1, "exists") + sf1_hash = sync_helper.compute_file_hash(sf1) + manifest_data = [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [ + {"file": "story-01-exists.md", "jira_key": "EDM-201", + "title": "Exists", "content_hash": sf1_hash, + "synced_status": "active"}, + {"file": "story-02-nofile.md", "jira_key": "EDM-202", + "title": "NoFile", "content_hash": "h", + "synced_status": "active"}, + ], + }] + _make_manifest(self.artifacts, manifest_data) + manifest = sync_helper.load_manifest( + self.artifacts / "sync-manifest.json") + plan = sync_helper.detect_changes(self.artifacts) + + # story-02 should be orphaned in items + orphan_stories = [i for i in plan["items"] + if i["type"] == "story" and i["category"] == "orphaned"] + orphan_keys = {i["jira_key"] for i in orphan_stories} + self.assertIn("EDM-202", orphan_keys, + "No-file child must appear as orphaned in items") + + # Without close_orphans: orphan child NOT individually closing + result = sync_helper.compute_cascade_closures( + plan["items"], manifest, close_orphans=False) + cascade_keys = {c["jira_key"] for c in result["cascades"]} + # EDM-202 is orphaned but not individually_closing (no close_orphans) + # So it should appear in cascade (it's active in manifest under + # the removed parent) + self.assertIn("EDM-202", cascade_keys, + "Orphan child not selected for individual close " + "must appear in cascade when parent is removed") + # EDM-201 unchanged → also in cascade + self.assertIn("EDM-201", cascade_keys) + + +# --------------------------------------------------------------------------- +# Gap A: DOCS-specific source_active fixtures +# --------------------------------------------------------------------------- + +class TestDocsQualityEligibility(_TmpDirMixin, unittest.TestCase): + """DOCS-specific fixtures for source_active quality gate (gap A).""" + + def setUp(self) -> None: + super().setUp() + self.artifacts = Path(self.tmpdir) / "art" + self.stories_dir = self.artifacts / "06-stories" + self.stories_dir.mkdir(parents=True) + + def test_unchanged_docs_under_removed_parent_ineligible(self) -> None: + """DOCS story unchanged but under removed parent: source_active + False. Quality gate must NOT pull this historical DOCS into + validation even if a contributor changed.""" + _make_epic(self.stories_dir, 1, "building", removed=True) + sf = _make_docs_story( + self.stories_dir, 1, 2, "api-docs", + contributing_stories="Story 1.01") + sf_hash = sync_helper.compute_file_hash(sf) + # Also create a changed contributor (story 1.01) + _make_dev_story(self.stories_dir, 1, 1, "scaffold") + _make_manifest(self.artifacts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [ + {"file": "story-01-scaffold.md", "jira_key": "EDM-201", + "content_hash": "DIFFERENT", "synced_status": "active"}, + {"file": "story-02-api-docs.md", "jira_key": "EDM-202", + "content_hash": sf_hash, "synced_status": "active"}, + ], + }]) + result = sync_helper.detect_changes(self.artifacts) + docs = next(i for i in result["items"] + if i["file"].endswith("story-02-api-docs.md")) + # DOCS is unchanged (hash matches) + self.assertEqual(docs["category"], "unchanged") + # But source_active is False because parent is removed + self.assertFalse(docs["source_active"], + "Historical DOCS under removed parent must NOT " + "be source_active — quality gate must skip it") + + def test_active_docs_under_reopened_parent_eligible(self) -> None: + """DOCS story under reopened parent: source_active True. + Restored parent makes its children eligible for quality gate.""" + _make_epic(self.stories_dir, 1, "building") + _make_docs_story( + self.stories_dir, 1, 2, "api-docs", + contributing_stories="Story 1.01") + _make_dev_story(self.stories_dir, 1, 1, "scaffold") + _make_manifest(self.artifacts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "closed", + "stories": [ + {"file": "story-01-scaffold.md", "jira_key": "EDM-201", + "content_hash": "oldhash", "synced_status": "closed"}, + {"file": "story-02-api-docs.md", "jira_key": "EDM-202", + "content_hash": "oldhash", "synced_status": "closed"}, + ], + }]) + result = sync_helper.detect_changes(self.artifacts) + epic = next(i for i in result["items"] if i["type"] == "epic") + docs = next(i for i in result["items"] + if i["file"].endswith("story-02-api-docs.md")) + self.assertEqual(epic["category"], "reopened") + self.assertTrue(epic["source_active"]) + self.assertEqual(docs["category"], "reopened") + self.assertTrue(docs["source_active"], + "DOCS under reopened parent must be source_active") + + +# --------------------------------------------------------------------------- +# Gap B: cascade synced_status default and identity matching +# --------------------------------------------------------------------------- + +class TestCascadeStatusDefault(_TmpDirMixin, unittest.TestCase): + """cascade uses same synced_status default as detect_changes (gap B).""" + + def setUp(self) -> None: + super().setUp() + self.artifacts = Path(self.tmpdir) / "art" + self.stories_dir = self.artifacts / "06-stories" + self.stories_dir.mkdir(parents=True) + + def test_missing_synced_status_treated_as_active(self) -> None: + """Manifest story entry with no synced_status field is treated + as active (same default as detect_changes .get('synced_status', + 'active')), and appears in cascade closures.""" + _make_epic(self.stories_dir, 1, "building", removed=True) + sf = _make_dev_story(self.stories_dir, 1, 1, "scaffold") + sf_hash = sync_helper.compute_file_hash(sf) + # Manifest entry deliberately omits synced_status + manifest = { + "schema_version": 2, "feature_key": "F-1", + "synced_at": "2026-01-01T00:00:00Z", + "epics": [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [{ + "file": "story-01-scaffold.md", "jira_key": "EDM-201", + "title": "[DEV] Scaffold", + "content_hash": sf_hash, + # NO synced_status field + }], + }], + } + mp = self.artifacts / "sync-manifest.json" + _write(mp, json.dumps(manifest, indent=2)) + plan = sync_helper.detect_changes(self.artifacts) + result = sync_helper.compute_cascade_closures( + plan["items"], manifest) + cascade_keys = {c["jira_key"] for c in result["cascades"]} + self.assertIn("EDM-201", cascade_keys, + "Missing synced_status must default to active") + + def test_cascade_uses_detect_changes_file_identity(self) -> None: + """Cascade qualified_file matches detect_changes items path.""" + _make_epic(self.stories_dir, 1, "building", removed=True) + sf = _make_dev_story(self.stories_dir, 1, 1, "scaffold") + sf_hash = sync_helper.compute_file_hash(sf) + _make_manifest(self.artifacts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [{ + "file": "story-01-scaffold.md", "jira_key": "EDM-201", + "title": "[DEV] Scaffold", "content_hash": sf_hash, + "synced_status": "active", + }], + }]) + manifest = sync_helper.load_manifest( + self.artifacts / "sync-manifest.json") + plan = sync_helper.detect_changes(self.artifacts) + result = sync_helper.compute_cascade_closures( + plan["items"], manifest) + # Get the actual file path from detect_changes items + story_item = next(i for i in plan["items"] + if i["type"] == "story") + cascade_file = result["cascades"][0]["file"] + self.assertEqual(cascade_file, story_item["file"], + "Cascade file must match detect_changes identity") + + +class TestCascadeJiraKeyValidation(_TmpDirMixin, unittest.TestCase): + """cascade must validate Jira keys — no blank approved ops (gap B).""" + + def setUp(self) -> None: + super().setUp() + self.artifacts = Path(self.tmpdir) / "art" + self.stories_dir = self.artifacts / "06-stories" + self.stories_dir.mkdir(parents=True) + + def test_missing_child_jira_key_emits_error(self) -> None: + """Manifest child without jira_key produces error, not cascade.""" + _make_epic(self.stories_dir, 1, "building", removed=True) + sf = _make_dev_story(self.stories_dir, 1, 1, "scaffold") + sf_hash = sync_helper.compute_file_hash(sf) + manifest = { + "schema_version": 2, "feature_key": "F-1", + "synced_at": "2026-01-01", "epics": [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [{ + "file": "story-01-scaffold.md", + "jira_key": "", # blank + "content_hash": sf_hash, + "synced_status": "active", + }], + }], + } + mp = self.artifacts / "sync-manifest.json" + _write(mp, json.dumps(manifest, indent=2)) + plan = sync_helper.detect_changes(self.artifacts) + result = sync_helper.compute_cascade_closures( + plan["items"], manifest) + self.assertEqual(len(result["cascades"]), 0, + "Blank jira_key child must NOT be in cascades") + self.assertIn("errors", result) + self.assertTrue(len(result["errors"]) > 0) + + def test_missing_parent_jira_key_emits_error(self) -> None: + """Manifest parent without jira_key produces error.""" + _make_epic(self.stories_dir, 1, "building", removed=True) + sf = _make_dev_story(self.stories_dir, 1, 1, "scaffold") + sf_hash = sync_helper.compute_file_hash(sf) + manifest = { + "schema_version": 2, "feature_key": "F-1", + "synced_at": "2026-01-01", "epics": [{ + "file": "epic-1-building.md", "jira_key": "", + "content_hash": "oldhash", "synced_status": "active", + "stories": [{ + "file": "story-01-scaffold.md", "jira_key": "EDM-201", + "content_hash": sf_hash, "synced_status": "active", + }], + }], + } + mp = self.artifacts / "sync-manifest.json" + _write(mp, json.dumps(manifest, indent=2)) + plan = sync_helper.detect_changes(self.artifacts) + result = sync_helper.compute_cascade_closures( + plan["items"], manifest) + self.assertEqual(len(result["cascades"]), 0) + self.assertIn("errors", result) + + +# --------------------------------------------------------------------------- +# Gap C: close_orphans=True full preview union +# --------------------------------------------------------------------------- + +class TestCascadeOrphanUnion(_TmpDirMixin, unittest.TestCase): + """close_orphans=True: removed parent cascades + orphan union (gap C).""" + + def setUp(self) -> None: + super().setUp() + self.artifacts = Path(self.tmpdir) / "art" + self.stories_dir = self.artifacts / "06-stories" + self.stories_dir.mkdir(parents=True) + + def test_close_orphans_true_removed_still_cascades(self) -> None: + """close_orphans flag does not suppress removed-parent cascades.""" + _make_epic(self.stories_dir, 1, "building", removed=True) + sf = _make_dev_story(self.stories_dir, 1, 1, "child") + sf_hash = sync_helper.compute_file_hash(sf) + _make_manifest(self.artifacts, [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [{ + "file": "story-01-child.md", "jira_key": "EDM-201", + "title": "Child", "content_hash": sf_hash, + "synced_status": "active", + }], + }]) + manifest = sync_helper.load_manifest( + self.artifacts / "sync-manifest.json") + plan = sync_helper.detect_changes(self.artifacts) + result = sync_helper.compute_cascade_closures( + plan["items"], manifest, close_orphans=True) + cascade_keys = {c["jira_key"] for c in result["cascades"]} + self.assertIn("EDM-201", cascade_keys, + "Removed parent must cascade regardless of " + "close_orphans flag") + + def test_close_orphans_true_full_union(self) -> None: + """close_orphans=True: orphan child already individually closing + means 0 additional cascades for that parent, but removed parent + cascade is separate.""" + _make_epic(self.stories_dir, 1, "real", removed=True) + sf = _make_dev_story(self.stories_dir, 1, 1, "child") + sf_hash = sync_helper.compute_file_hash(sf) + (self.stories_dir / "epic-2").mkdir(parents=True) + # epic-2 is orphaned (no epic file); its child is also orphaned + _make_manifest(self.artifacts, [ + {"file": "epic-1-real.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [{ + "file": "story-01-child.md", "jira_key": "EDM-201", + "title": "Child", "content_hash": sf_hash, + "synced_status": "active", + }]}, + {"file": "epic-2-orphan.md", "jira_key": "EDM-102", + "content_hash": "h", "synced_status": "active", + "title": "Orphan", + "stories": [{ + "file": "story-01-orphchild.md", "jira_key": "EDM-301", + "title": "OrphChild", "content_hash": "h", + "synced_status": "active", + }]}, + ]) + manifest = sync_helper.load_manifest( + self.artifacts / "sync-manifest.json") + plan = sync_helper.detect_changes(self.artifacts) + result = sync_helper.compute_cascade_closures( + plan["items"], manifest, close_orphans=True) + cascade_keys = {c["jira_key"] for c in result["cascades"]} + # EDM-201 is cascade from removed parent (unchanged active child) + self.assertIn("EDM-201", cascade_keys, + "Unchanged child of removed parent in cascade") + # EDM-301 is orphan child; with close_orphans=True it's + # individually_closing, so should NOT be in cascade + self.assertNotIn("EDM-301", cascade_keys, + "Orphan child already individually closing " + "should NOT be in cascade") + + +# --------------------------------------------------------------------------- +# Gap D: cascade recipe fail-closed capture contract +# --------------------------------------------------------------------------- + +class TestCascadeRecipeContract(unittest.TestCase): + """Step 3 cascade recipe uses fail-closed capture pattern (gap D).""" + + @classmethod + def setUpClass(cls) -> None: + import re + sync_path = Path(__file__).resolve().parent.parent / "skills" / "sync.md" + cls._sync_md = sync_path.read_text(encoding="utf-8") + m3 = re.search(r"### Step 3:.*?(?=### Step 4:)", cls._sync_md, re.DOTALL) + assert m3, "Step 3 not found" + cls._step3 = m3.group() + + def test_errexit_safe_capture(self) -> None: + """Cascade recipe uses || CASC_EXIT=$? errexit-safe idiom.""" + self.assertIn("|| CASC_EXIT=$?", self._step3) + + def test_per_call_temp_capture(self) -> None: + """Cascade recipe uses mktemp for stderr capture.""" + self.assertIn("CASC_ERR=$(mktemp)", self._step3) + + def test_immediate_temp_cleanup(self) -> None: + """Temp file removed immediately after read.""" + self.assertIn("rm -f \"$CASC_ERR\"", self._step3) + + def test_typed_json_validation(self) -> None: + """Recipe validates cascades list in result.""" + self.assertIn("cascades", self._step3) + self.assertIn("valid JSON", self._step3) + + def test_errors_key_check(self) -> None: + """Recipe checks for errors key in cascade output.""" + self.assertIn("errors", self._step3) + + def test_stop_on_fatal(self) -> None: + """Recipe STOPs on unexpected exit / malformed output.""" + lower = self._step3.lower() + self.assertIn("casc_exit", lower) + self.assertIn("casc_err_text", lower) + + def test_no_literal_bracket_arg(self) -> None: + """No literal [--close-orphans] — actual instructions specify + when to add the flag.""" + self.assertNotIn("[--close-orphans]", self._step3) + + def test_close_orphans_conditional(self) -> None: + """close-orphans is conditional on Step 1 user choice.""" + self.assertIn("--close-orphans", self._step3) + self.assertIn("option (a)", self._step3) + + +class TestCascadeCLINegative(_TmpDirMixin, unittest.TestCase): + """CLI compute-cascade-closures negative paths (gap D).""" + + def setUp(self) -> None: + super().setUp() + self.artifacts = Path(self.tmpdir) / "art" + self.stories_dir = self.artifacts / "06-stories" + self.stories_dir.mkdir(parents=True) + + def test_cli_errors_in_output(self) -> None: + """CLI output includes errors key when manifest has blank keys.""" + _make_epic(self.stories_dir, 1, "building", removed=True) + sf = _make_dev_story(self.stories_dir, 1, 1, "scaffold") + sf_hash = sync_helper.compute_file_hash(sf) + manifest = { + "schema_version": 2, "feature_key": "F-1", + "synced_at": "2026-01-01", "epics": [{ + "file": "epic-1-building.md", "jira_key": "EDM-101", + "content_hash": "oldhash", "synced_status": "active", + "stories": [{ + "file": "story-01-scaffold.md", "jira_key": "", + "content_hash": sf_hash, "synced_status": "active", + }], + }], + } + _write(self.artifacts / "sync-manifest.json", + json.dumps(manifest, indent=2)) + out = StringIO() + with mock.patch("sys.stdout", out): + exit_code = sync_helper.main([ + "compute-cascade-closures", str(self.artifacts)]) + self.assertEqual(exit_code, 0) + data = json.loads(out.getvalue()) + self.assertIn("errors", data) + self.assertTrue(len(data["errors"]) > 0) + self.assertEqual(len(data["cascades"]), 0) + + +# --------------------------------------------------------------------------- +# Gap E: Step 5c cascade scope guard contract +# --------------------------------------------------------------------------- + +class TestCascadeScopeGuardContract(unittest.TestCase): + """Step 5c must verify cascade set matches approved preview (gap E).""" + + @classmethod + def setUpClass(cls) -> None: + import re + sync_path = Path(__file__).resolve().parent.parent / "skills" / "sync.md" + cls._sync_md = sync_path.read_text(encoding="utf-8") + m5c = re.search(r"#### 5c:.*?(?=Present all epic)", cls._sync_md, re.DOTALL) + assert m5c, "Step 5c not found" + cls._step5c = m5c.group() + + def test_cascade_scope_guard_present(self) -> None: + """Step 5c has a cascade scope guard before child writes.""" + self.assertIn("Cascade scope guard", self._step5c) + + def test_guard_compares_approved_union(self) -> None: + """Guard compares against the full union of individually listed + children and supplemental cascade closures.""" + flat = " ".join(self._step5c.split()) + self.assertIn("union", flat.lower()) + self.assertIn("compute-cascade-closures", flat) + self.assertIn("individually listed", flat.lower()) + + def test_guard_union_includes_orphaned_children(self) -> None: + """Guard union covers user-selected orphaned children too.""" + flat = " ".join(self._step5c.lower().split()) + self.assertIn("orphaned", flat) + + def test_guard_uses_subset_not_exact_match(self) -> None: + """Guard checks remaining active children are a subset of + approved union — not exact equality to cascade-only set.""" + flat = " ".join(self._step5c.lower().split()) + self.assertIn("subset", flat) + + def test_guard_stops_on_expansion(self) -> None: + """Guard STOPs if new unapproved children would expand writes.""" + self.assertIn("stop", self._step5c.lower()) + # Text wraps across line: "fresh\npreview/approval" + flat = " ".join(self._step5c.lower().split()) + self.assertIn("fresh preview/approval", flat) + + def test_closed_resume_permitted(self) -> None: + """Already-closed children are skipped on resume (not blocked).""" + self.assertIn("closed", self._step5c.lower()) + self.assertIn("resume", self._step5c.lower()) + + def test_original_child_failure_preserved(self) -> None: + """Original stop-on-child-failure rule still present.""" + self.assertIn("stop the cascade immediately", self._step5c) + + def test_checkpoint_recovery_preserved(self) -> None: + """Close-checkpoint recovery rule still present.""" + self.assertIn("close-checkpoint recovery", self._step5c.lower()) + + +# --------------------------------------------------------------------------- +# FIX2: Unknown parent identity diagnostic (plan-creation-order) +# --------------------------------------------------------------------------- + +class TestInvalidParentIdentity(_TmpDirMixin, unittest.TestCase): + """plan-creation-order must reject actionable items with + unparseable parent identity rather than silently dropping them. + + Counterexample: a story under ``epic-abc/story-01-docs.md`` has + ``source_active: True`` (fresh sync) but ``epic_index: None``. + plan-creation-order previously dropped it silently; now it must + report a structural error. + """ + + def setUp(self) -> None: + super().setUp() + self.artifacts = Path(self.tmpdir) / "art" + self.stories_dir = self.artifacts / "06-stories" + self.stories_dir.mkdir(parents=True) + + def test_fresh_nonnumeric_parent_source_active_false(self) -> None: + """Fresh sync: story under epic-abc/ gets source_active False.""" + # Create a non-numeric epic dir with a story + _make_epic(self.stories_dir, 1, "real") + nonnumeric_dir = self.stories_dir / "epic-abc" + nonnumeric_dir.mkdir(parents=True) + story_path = nonnumeric_dir / "story-01-docs.md" + story_path.write_text( + "---\n---\n# Story abc.01: [DOCS] API docs\n\n" + "As a writer I want to document the API.\n\n" + "## Acceptance Criteria\n\nDocs complete\n\n" + "## Documentation Scope\n\nAPI reference.\n\n" + "## Dependencies\n\nNone\n\n" + "## Design Reference\n\n" + "- Epic: Epic abc\n", + encoding="utf-8", + ) + result = sync_helper.detect_changes(self.artifacts) + # Find the story under epic-abc + abc_stories = [ + i for i in result["items"] + if i["type"] == "story" and i["file"].startswith("epic-abc/") + ] + self.assertEqual(len(abc_stories), 1) + self.assertIsNone(abc_stories[0]["epic_index"]) + self.assertFalse( + abc_stories[0]["source_active"], + "Story under non-numeric parent must NOT be source_active") + + def test_plan_creation_order_reports_nonnumeric_parent(self) -> None: + """plan-creation-order emits error for actionable story with + unparseable parent — does not silently drop planned work.""" + _make_epic(self.stories_dir, 1, "real") + nonnumeric_dir = self.stories_dir / "epic-abc" + nonnumeric_dir.mkdir(parents=True) + story_path = nonnumeric_dir / "story-01-docs.md" + story_path.write_text( + "---\n---\n# Story abc.01: [DOCS] API docs\n\n" + "As a writer I want to document the API.\n\n" + "## Acceptance Criteria\n\nDocs complete\n\n" + "## Documentation Scope\n\nAPI reference.\n\n" + "## Dependencies\n\nNone\n\n" + "## Design Reference\n\n" + "- Epic: Epic abc\n", + encoding="utf-8", + ) + plan = sync_helper.detect_changes(self.artifacts) + result = sync_helper.plan_creation_order( + plan["items"], str(self.artifacts), None) + # Must report error for the non-numeric parent story + self.assertTrue( + result["errors"], + "plan-creation-order must report errors for stories " + "with unparseable parent identity") + error_text = " ".join(result["errors"]) + self.assertIn("epic-abc", error_text, + "Error must name the problematic parent") + + def test_plan_creation_order_cli_nonnumeric_parent_exit1(self) -> None: + """CLI plan-creation-order exits 1 for non-numeric parent story.""" + _make_epic(self.stories_dir, 1, "real") + nonnumeric_dir = self.stories_dir / "epic-abc" + nonnumeric_dir.mkdir(parents=True) + story_path = nonnumeric_dir / "story-01-docs.md" + story_path.write_text( + "---\n---\n# Story abc.01: [DOCS] API docs\n\n" + "As a writer I want to document the API.\n\n" + "## Acceptance Criteria\n\nDocs complete\n\n" + "## Documentation Scope\n\nAPI reference.\n\n" + "## Dependencies\n\nNone\n\n" + "## Design Reference\n\n" + "- Epic: Epic abc\n", + encoding="utf-8", + ) + out = StringIO() + with mock.patch("sys.stdout", out): + exit_code = sync_helper.main([ + "plan-creation-order", str(self.artifacts)]) + self.assertEqual(exit_code, 1, + "CLI must exit 1 when errors are present") + data = json.loads(out.getvalue()) + self.assertTrue(data["errors"]) + + def test_unchanged_story_under_nonnumeric_not_error(self) -> None: + """Non-actionable (unchanged/orphaned) stories with non-numeric + parent are NOT flagged as identity errors — this preserves + legitimate orphan cleanup and closed manifest recovery.""" + _make_epic(self.stories_dir, 1, "real") + manifest_data = [{ + "file": "epic-1-real.md", "jira_key": "EDM-101", + "content_hash": sync_helper.compute_file_hash( + self.stories_dir / "epic-1-real.md"), + "synced_status": "active", + "stories": [], + }] + _make_manifest(self.artifacts, manifest_data) + plan = sync_helper.detect_changes(self.artifacts) + # Inject a synthetic unchanged story with None epic_index + # (simulates an orphan/closed manifest entry) + plan["items"].append({ + "file": "epic-old/story-01-legacy.md", + "type": "story", + "category": "unchanged", + "title": "Legacy", + "epic_index": None, + "story_index": 1, + "jira_key": "EDM-301", + "content_hash": "h", + "old_hash": "h", + "synced_status": "active", + "source_active": False, + }) + manifest = sync_helper.load_manifest( + self.artifacts / "sync-manifest.json") + result = sync_helper.plan_creation_order( + plan["items"], str(self.artifacts), manifest) + # No identity errors for unchanged/non-actionable items + identity_errs = [e for e in result["errors"] + if "qualified ID" in e] + self.assertEqual( + len(identity_errs), 0, + "Non-actionable stories must NOT cause identity errors") + + def test_numeric_missing_parent_blocked_at_preflight(self) -> None: + """Story under epic-99/ with valid file but NO epic-99 source + artifact → preflight error, not silent ordering. + + This is the counterexample: the story FILE exists (valid + numeric epic_index=99, story_index=1), but no epic-99-*.md + source artifact exists, so no Epic 99 will be created. + plan-creation-order must catch this at preflight, not + silently order the story for creation. + """ + _make_epic(self.stories_dir, 1, "building") + # Create story under epic-99 (no epic-99 source artifact) + epic99_dir = self.stories_dir / "epic-99" + epic99_dir.mkdir(parents=True) + (epic99_dir / "story-01-docs.md").write_text( + "---\n---\n# Story 99.01: [DOCS] API docs\n\n" + "As a writer I want to document.\n\n" + "## Acceptance Criteria\n\nDone\n\n" + "## Documentation Scope\n\nAPI.\n\n" + "## Dependencies\n\nNone\n\n" + "## Design Reference\n\n- Epic: Epic 99\n", + encoding="utf-8", + ) + plan = sync_helper.detect_changes(self.artifacts) + result = sync_helper.plan_creation_order( + plan["items"], str(self.artifacts), None) + # Must be in errors, not silently ordered + self.assertTrue(result["errors"], + "Missing owning parent must produce errors") + error_text = " ".join(result["errors"]) + self.assertIn("epic-99", error_text) + self.assertIn("owning", error_text.lower()) + # Story must NOT be in ordered list + ordered_files = [i["file"] for i in result["ordered"]] + self.assertNotIn("epic-99/story-01-docs.md", ordered_files, + "Story without parent must NOT be ordered") + # Valid story-1.01 should still be orderable + # (epic-1 has source artifact) + + def test_numeric_valid_parent_passes_preflight(self) -> None: + """Story under epic-1/ with matching epic-1-*.md source: no + owning-parent error — confirms no false positives.""" + _make_epic(self.stories_dir, 1, "building") + _make_dev_story(self.stories_dir, 1, 1, "scaffold", + deps="None") + plan = sync_helper.detect_changes(self.artifacts) + result = sync_helper.plan_creation_order( + plan["items"], str(self.artifacts), None) + owning_errs = [e for e in result["errors"] + if "owning" in e.lower()] + self.assertEqual(len(owning_errs), 0, + "Valid parent must NOT produce owning errors") + self.assertEqual(len(result["ordered"]), 1) + + def test_zero_epic_cli_owning_parent_exit1(self) -> None: + """CLI plan-creation-order with ZERO epic source files and a + present story under epic-99/ → exit 1 with owning-parent error. + + This is the exact counterexample that the conditional bypass + (``if epic_items_by_idx:``) would have let through as exit 0. + The fixture has no epic-*.md files at all, only a story + directory and story file. + """ + # Verify: no epic-*.md files exist in stories dir + epic_files = list(self.stories_dir.glob("epic-*.md")) + self.assertEqual(len(epic_files), 0, + "Fixture must have ZERO epic source files") + # Create only a story under epic-99 (no epic source) + epic99_dir = self.stories_dir / "epic-99" + epic99_dir.mkdir(parents=True) + (epic99_dir / "story-01-docs.md").write_text( + "---\n---\n# Story 99.01: [DOCS] API docs\n\n" + "As a writer I want to document.\n\n" + "## Acceptance Criteria\n\nDone\n\n" + "## Documentation Scope\n\nAPI.\n\n" + "## Dependencies\n\nNone\n\n" + "## Design Reference\n\n- Epic: Epic 99\n", + encoding="utf-8", + ) + # Re-verify: still no epic-*.md files + epic_files_after = list(self.stories_dir.glob("epic-*.md")) + self.assertEqual(len(epic_files_after), 0, + "Fixture must still have ZERO epic source files") + out = StringIO() + with mock.patch("sys.stdout", out): + exit_code = sync_helper.main([ + "plan-creation-order", str(self.artifacts)]) + self.assertEqual(exit_code, 1, + "Zero-epic fixture must exit 1") + data = json.loads(out.getvalue()) + self.assertTrue(data["errors"], + "Must contain owning-parent errors") + error_text = " ".join(data["errors"]) + self.assertIn("epic-99", error_text) + self.assertIn("owning", error_text.lower()) + # Story must NOT be in ordered list + ordered_files = [i["file"] for i in data["ordered"]] + self.assertNotIn("epic-99/story-01-docs.md", ordered_files, + "Story must NOT be silently ordered") + + if __name__ == "__main__": unittest.main() diff --git a/design/skills/controller.md b/design/skills/controller.md index 675a9af1..f5ae3ceb 100644 --- a/design/skills/controller.md +++ b/design/skills/controller.md @@ -153,7 +153,9 @@ Other options: subagent review with up to 2 fix-and-re-review rounds. Running it right after `/draft` pays that cost once as a design-validation step — but if the design changes substantially during `/revise` or `/respond`, the controller -recommends re-running `/decompose` from scratch, paying that cost again. +recommends re-running `/decompose`, which rebuilds the plan against +preserved existing identities rather than discarding artifacts, paying +that cost again. Whether this is worth it depends on how much churn the design is likely to see, which varies per feature. diff --git a/design/skills/decompose.md b/design/skills/decompose.md index c188c44b..7f216463 100644 --- a/design/skills/decompose.md +++ b/design/skills/decompose.md @@ -29,7 +29,7 @@ must leave the system in a stable state. ## Process -### Step 1: Read Source Material +### Step 1: Load Source Material and Identify Feature Read these files: 1. `.artifacts/design/{issue-key}/03-design.md` (design document) @@ -44,12 +44,42 @@ run first. If the testplan doesn't exist, tell the user that `/draft` should be run first. -### Step 2: Identify the Feature - Read the Jira Feature issue key from the PRD or context. This Feature is the parent for all epics created by this workflow. -### Step 3: Plan the Decomposition +**If decomposition artifacts already exist** (`05-epics.md`, +`06-stories/`, or `07-coverage.md`), this is a repeat `/decompose`. +Load and reconcile existing artifacts before planning: + +1. Read `05-epics.md`, all `06-stories/epic-{N}-{slug}.md` files, all + `06-stories/epic-{N}/story-{NN}-{slug}.md` files, and + `07-coverage.md` (if it exists — a prior run interrupted before + Step 7 may not have produced coverage; it will be rebuilt in + Step 7 from the retained decomposition artifacts). +2. Record all existing qualified identities (epic numbers, story + numbers, filenames, containing paths) including files with + `status: removed`. +3. Preserve existing numbers, qualified identities, paths, and removal + markers — do not overwrite mismatched existing IDs, delete and + recreate files, or reuse retired numbers. New stories and epics + append strictly above the highest existing number (including + retired). +4. If an existing identity conflicts with the new plan (e.g., the + same epic number maps to a different slug), require clarification + rather than silently repurposing. +5. Files with `status: removed` remain on disk with their numbers + reserved. Do not silently re-activate retired work; explicit + approved restoration is required to reopen a removed item. +6. Do not decide or apply retirement markers during inventory + loading — the new plan does not exist yet. Existing `status: + removed` markers discovered in sub-step 2 are preserved as-is; + new retirement decisions wait until after planning (see the + reconciliation sub-step at the end of Step 2). + +The initial empty decomposition (no prior artifacts) still works +unchanged — these constraints apply only to repeat runs. + +### Step 2: Plan the Decomposition Before writing, plan: @@ -86,13 +116,77 @@ Before writing, plan: split into smaller epics before proceeding. Split by user-value slice, not technical layer. -### Step 4: Write Epics +#### Per-story retention assessment (repeat `/decompose` only) + +On a repeat `/decompose`, before reconciliation: for each existing +story loaded in Step 1, match its qualified ID, path, and +requirement mapping against the current verified PRD/design and the +epic plan above. Explicitly determine whether each story is +retained in the active plan or intentionally retired, with the +requirement mapping or reason for each decision. Distinguish a +child's own disposition from inherited inactivity: a child whose +parent epic is being retired is not itself individually retired +unless the plan separately drops that child. + +A retained epic alone is not sufficient input for reconciliation — +account for its child stories individually. Use current known +planned work only; do not anticipate future implementation facts. +Unclassified or ambiguous identity must not default to removal or +repurposing; complete the planning assessment or seek clarification +under existing rules before any status mutations. + +Already-retired files (with `status: removed` discovered in +Step 1) stay retired unless explicit approved restoration is +required. Do not silently restore, move, or copy old IDs when +requirements return. Any retained child requires its original +parent epic to be active. + +These are ordinary planning notes that feed the reconciliation +sub-step below — no new persistent retention artifact, manifest, +or status enum is required. + +#### Reconcile retained inventory against the new plan + +On a repeat `/decompose` only (existing artifacts were loaded in +Step 1): compare the retained inventory from Step 1 against the +plan produced above. For each artifact that the plan individually +and explicitly retires, add `status: removed` to its YAML +frontmatter (the `---`-delimited block at the very start of the +file — this is what `read_frontmatter` and `has_removed_status` +examine; a plain Markdown body line is not recognized). When +only a parent epic is retired, mark the parent only — do not +add `status: removed` to its child stories; children inherit the +parent's inactive status through cascade without needing their +own markers. Preserve each child's original frontmatter and +body unless the current plan separately retires that child. +Merge into any existing frontmatter; do not clobber other fields +or prepend a second frontmatter block. If the file has no +frontmatter, add a valid opening and closing `---` block before +the first Markdown heading. Example: + +```yaml +--- +status: removed +--- +``` + +Keep an epic active if any of its child stories are retained in +the new plan — only mark an epic `status: removed` when all its +child work is intentionally retired. This preserves cascade +safety: marking an epic removed logically retires all its +children, so a partially-retained epic must stay active. Never +delete artifact files; do not reuse retired numbers. Missing or +incomplete inventory from Step 1 is never evidence that work +should be retired — only an explicit planning decision to drop +work from the active plan triggers retirement. + +### Step 3: Write Epics This step produces two things: a metadata index (`05-epics.md`) and one file per epic. Each epic file has a 1:1 correspondence with a Jira Epic issue — its content is what `/sync` will push to Jira. -#### 4a: Write Epic Metadata +#### 3a: Write Epic Metadata Write `.artifacts/design/{issue-key}/05-epics.md`: @@ -114,7 +208,7 @@ Write `.artifacts/design/{issue-key}/05-epics.md`: {PRD Requirements column: list the primary requirements for quick reference. If an epic maps to more than ~8 requirements, list the most significant and add "See coverage matrix for full mapping." The coverage matrix - (Step 8) is the authoritative source for requirement-to-story traceability.} + (Step 7) is the authoritative source for requirement-to-story traceability.} ## Dependency Order @@ -129,10 +223,17 @@ Write `.artifacts/design/{issue-key}/05-epics.md`: that can proceed in parallel.} ``` -#### 4b: Write Epic Files +On a repeat `/decompose`, the index reflects the active plan from +Step 2 — list active epics only. Retired epic files are retained +on disk for identity audit but are not included in the active +index table. + +#### 3b: Write Epic Files -For each epic, write -`.artifacts/design/{issue-key}/06-stories/epic-{N}-{slug}.md`: +For each active epic (no `status: removed`), write or update +`.artifacts/design/{issue-key}/06-stories/epic-{N}-{slug}.md`. +Do not rewrite retired epic files — their `status: removed` +frontmatter must be preserved: ```markdown # Epic {N}: {title} @@ -161,11 +262,12 @@ Design sections: {§4.1 Architecture, §4.3 API Changes, or relevant subsections - Gaps in epic numbering are acceptable (e.g., if epic-2 is removed during revision, epic-1 and epic-3 remain — do not renumber to close the gap) -### Step 5: Write Stories +### Step 4: Write Stories -For each epic, create a story directory and write one file per story. Each -story file has a 1:1 correspondence with a Jira Story issue — its content -is what `/sync` will push to Jira. +For each active epic, create a story directory and write one file per +story. Do not fabricate directories or story files for retired epics. +Each story file has a 1:1 correspondence with a Jira Story issue — its +content is what `/sync` will push to Jira. ```bash mkdir -p .artifacts/design/{issue-key}/06-stories/epic-{N} @@ -223,22 +325,21 @@ Validated by: {TC-FR1-01, TC-FR3-02} `Interface Changes` lists the ICs from the design's §5 that this story implements. `Validated by` lists the TCs from the testplan that validate the behavior this story delivers. These references are populated in -Step 7 after stories are written. +Step 6 after stories are written. -**For `[DOCS]` stories** (see `[DOCS]` story requirements below for the -Documentation Inputs section): +**For `[DOCS]` stories** (see `[DOCS]` story quality criteria below): ```markdown -# Story {N}.{NN}: [DOCS] {title} +# Story {N}.{NN}: [DOCS] {product/feature/command — document title} -**As a** {role}, -**I want to** {capability}, -**So that** {benefit}. +**As a** {specific reader — e.g., fleet administrator, application developer}, +**I want to** {concrete task — what the reader will learn or accomplish}, +**So that** {benefit — why this documentation matters to the reader}. ## Acceptance Criteria -- [ ] {documentation outcome — what the reader can learn or do, verifiable by reading the documentation} -- [ ] {documentation outcome} +- [ ] {reader-verifiable documentation outcome — what the reader can learn or do, verifiable by reading the documentation} +- [ ] {reader-verifiable documentation outcome} ## Documentation Scope @@ -248,13 +349,14 @@ Documentation Inputs section): up the ticket. Example: "This feature introduces a new CLI command for managing device - rollbacks. Users need to understand when to use rollback vs. revert, + rollbacks. Operators need to understand when to use rollback vs. revert, the required preconditions, and how to verify success."} ## Documentation Inputs -{Inventories every user-facing artifact introduced or changed by the - feature, grouped by the implementation story that delivers it. +{Inventories all known, planned user-facing inputs within this DOCS + story's declared scope, grouped by the relevant DEV/UI story that + delivers each artifact. See the Documentation Inputs requirements below for format details.} ## Dependencies @@ -268,6 +370,7 @@ Documentation Inputs section): Epic: Epic {N} — {epic title} PRD Requirements: {FR-1, NFR-1} Design section: {§4.3 API Changes, or specific subsection} +Contributing stories: {Story 1.01, Story 1.02 — DEV/UI stories whose user-facing artifacts this DOCS story covers} ``` **Naming conventions:** @@ -288,36 +391,91 @@ the work: If unsure of the correct prefix, use `[DEV]` as the default and note it for user review. -**`[DOCS]` story requirements:** `[DOCS]` stories use a different template -than other story types (see above). They replace `Implementation Guidance` -and `Testing Approach` with `Documentation Scope` and `Documentation Inputs`. - -The **Documentation Scope** section describes what the reader needs to -understand about the feature — not how to write the docs or where to put -them. Those decisions belong to the documentation author or workflow that -picks up the ticket. See the template example above for illustration. - -The **Documentation Inputs** section inventories every user-facing artifact -introduced or changed by the feature, grouped by the implementation story -that delivers it. At design time you have the complete picture of what's -new and what changed. Capture it so the documentation author (human or AI) -gets a concrete starting point — rather than reverse-engineering the -user-visible surface from code diffs after implementation. Note that -implementation may diverge from the design, so these inputs are guidance, -not authority. The documentation author should verify against the actual -implementation. - -Group items under bold story headers (e.g., **Story 1.01 — {title}:**) -followed by bullet points listing each user-facing artifact: new or changed -API fields/resources, CLI output or behavior, configuration options, status -values and their meanings, error messages, and UI elements. Give enough -detail that the writer knows what to look for in the implementation. - -Place each `[DOCS]` story after all stories it references so that all -references are resolvable during `/sync`. For per-epic `[DOCS]` stories, -place them as the last story within their own epic. For a cross-cutting -`[DOCS]` story that spans multiple epics, place it as the last story in -the last epic. +**`[DOCS]` story quality criteria:** `[DOCS]` stories use a different +template than other story types (see above). They replace `Implementation +Guidance` and `Testing Approach` with `Documentation Scope` and +`Documentation Inputs`. The criteria below define what a well-formed +`[DOCS]` story contains at design time. These same criteria are used by +the decomposition review (criterion #8), by `/revise` and `/respond` +when changes affect `[DOCS]` stories, and by `/sync` as a pre-sync +validation gate. + +1. **Searchable title.** The title must keep the `[DOCS]` prefix and + include recognizable product, feature, or command terms that make the + story findable in Jira search and backlog views — e.g., + `[DOCS] Device rollback command reference` rather than + `[DOCS] Feature documentation`. Use terms already established by the + PRD or design; do not invent identifiers or version strings that are + not yet known. + +2. **Explicit reader and task.** The persona/goal/benefit statement must + name the actual documentation reader (e.g., "fleet administrator," + "application developer") and a concrete task — not a generic role + like "user" or a process-oriented goal like "have documentation." + +3. **Reader-verifiable acceptance criteria.** Each AC must describe a + documentation outcome verifiable by reading the published + documentation — what the reader can learn, look up, or accomplish by + following the documented procedure. Do not require implementation + artifacts (merged PRs, CI status) that do not exist at design time. + +4. **Documentation Scope.** Describes what the reader needs to understand + about the feature — not how to write the docs or where to put them. + Those decisions belong to the documentation author or workflow that + picks up the ticket. See the template example above for illustration. + +5. **Complete Documentation Inputs.** Inventories all known, planned + user-facing inputs within this DOCS story's declared scope, grouped + by the relevant `[DEV]` or `[UI]` implementation story that delivers + each artifact. Include only contributing story types that are + relevant — a feature with no `[UI]` stories should not reference UI + stories, and vice versa. At design time the DOCS story's scope + defines which inputs belong here. Capture them so the documentation + author (human or AI) gets a concrete starting point — rather than + reverse-engineering the user-visible surface from code diffs after + implementation. + + Group items under bold story headers (e.g., **Story 1.01 — {title}:**) + followed by bullet points listing each user-facing artifact: new or + changed API fields/resources, CLI output or behavior, configuration + options, status values and their meanings, error messages, and UI + elements. Give enough detail that the writer knows what to look for in + the implementation. + + **Fact/assumption distinction.** Mark each input's confidence level + using the design document's source markers: + - Items derived from the design document or PRD are **planned** (no + marker needed — they are the default). + - Items that fill gaps not specified by the PRD or design must be + marked `[Assumption]`. + - Items awaiting resolution of an open question must be marked + `[Open: §9.N]` referencing the design document's open question. + + Implementation may diverge from the design, so these inputs are + guidance, not authority. The documentation author should verify + against the actual implementation. + +6. **Contributing stories reference.** The Design Reference section must + include a `Contributing stories` line listing every `[DEV]` and/or + `[UI]` story whose user-facing artifacts this `[DOCS]` story covers. + This must be consistent with the stories referenced in Documentation + Inputs. Use the canonical `Story N.NN` format. + +7. **Stable identity.** Once a story file is created, its qualified + identity — epic number, story number, filename, and containing + path — is fixed. Story content (title, AC, scope) may change; the + file must not be renamed, renumbered, or moved to a different epic. + New stories append strictly above the highest existing number in + their epic, including retained removed files; gaps are allowed and + retired numbers must not be reused. When planning the initial + decomposition, prefer placing `[DOCS]` stories after their + referenced contributors as a best-effort optimization for first- + sync resolution, but this ordering is never a later gate or + permission to renumber. `/sync` verifies that every story + referenced in Contributing stories AND Dependencies has a Jira key + in the manifest before rendering a `[DOCS]` story's description; + if any key is missing, the write is deferred to the next explicit + `/sync` invocation. **Story quality checks:** - For `[DEV]` stories: does this story add a capability AND testing for that capability? For a justified behavior-preserving prerequisite, does it restructure existing code AND include tests demonstrating preserved behavior? @@ -341,22 +499,22 @@ the last epic. time and implementation time. Identifiers are robust. - If a story has more than ~8 acceptance criteria, examine whether it can be split along a natural boundary. -- For `[DOCS]` stories: does the Documentation Scope describe what the - reader needs to understand without prescribing how to write or organize - the documentation? -- For `[DOCS]` stories: does the Documentation Inputs section list every - user-facing artifact from the implementation stories, with enough detail - for a documentation author to know what to look for in the implementation? +- For `[DOCS]` stories: does the story satisfy all seven `[DOCS]` story + quality criteria above (searchable title, explicit reader/task, + reader-verifiable AC, documentation scope, complete documentation + inputs with fact/assumption distinction, contributing stories + reference, stable identity)? -**Filename stability:** Story filenames (e.g., `story-01-scaffold-build-pipeline.md`) -serve as idempotency keys in the sync manifest. Once stories have been synced -to Jira, their filenames must not change. When revising a decomposition, -append new stories with the next available number rather than inserting or -renumbering existing ones. +**Identity stability:** Story filenames (e.g., `story-01-scaffold-build-pipeline.md`) +serve as idempotency keys in the sync manifest. Once created, filenames +and story numbers are fixed — they must not be renamed, renumbered, or +moved regardless of whether `/sync` has been run. Append new stories with +the next available number (above any existing or removed file's number); +do not insert, reuse retired numbers, or renumber existing stories. -### Step 6: Sizing Consistency Check +### Step 5: Sizing Consistency Check -After sizing all epics, verify plausibility: +After sizing all active epics, verify plausibility: 1. Read the Feature's Size from the Jira Feature issue (`{issue-key}`). Check the Size field (or Story Points, if Size is not set). If the @@ -371,14 +529,14 @@ After sizing all epics, verify plausibility: epics. Flag any mismatch to the user. 3. Verify no epic is sized XXL. If any is, stop and require a split - before proceeding to Step 7. + before proceeding to Step 6. -### Step 7: Add Implementation References to Stories +### Step 6: Add Implementation References to Stories -For each non-`[DOCS]` story written in Step 5, populate the `Interface +For each non-`[DOCS]` story written in Step 4, populate the `Interface Changes` and `Validated by` fields in the Design Reference section. -#### 7a: Map Stories to Interface Changes +**Map stories to Interface Changes.** For each non-`[DOCS]` story, determine which Interface Changes (IC-N) from the design's §5 the story implements. Use the story's Design @@ -389,7 +547,7 @@ A story implements an IC when it delivers part or all of the behavior that IC describes. One story may implement multiple ICs, and one IC may be implemented across multiple stories. -#### 7b: Map Stories to Test Cases +**Map stories to test cases.** For each non-`[DOCS]` story, determine which test cases from the testplan (read in Step 1) validate the behavior the story delivers. @@ -408,9 +566,9 @@ For stories that implement ICs with no corresponding test cases story's `Validated by` line should read: `Validated by: None — {reason from testplan gaps}` -#### 7c: Write References +**Write references.** -Re-read each non-`[DOCS]` story file. Update the `Interface Changes` +Re-read each active non-`[DOCS]` story file (written in Step 4). Update the `Interface Changes` and `Validated by` lines in the Design Reference section: ```markdown @@ -433,7 +591,7 @@ Interface Changes: None — behavior-preserving prerequisite Validated by: None — behavior-preserving prerequisite; existing tests verify preserved behavior ``` -### Step 8: Write Coverage Matrix +### Step 7: Complete and Validate Artifacts Write `.artifacts/design/{issue-key}/07-coverage.md`: @@ -450,6 +608,15 @@ Write `.artifacts/design/{issue-key}/07-coverage.md`: | NFR-1: {description} | Epic 1 | Story 1.02 | Covered | | NFR-2: {description} | Epic 2 | Story 2.01 | Covered | +**Active-only coverage.** Only effectively active stories satisfy +coverage. A story is effectively active when it has no `status: +removed` marker and its parent epic is also active. Stories with +`status: removed`, and unmarked children of removed epics, do not +satisfy requirement coverage — they are retained for identity and +traceability audit only. Do not mask a coverage gap by counting +retired work, and do not silently drop requirements that lost their +only implementing story to retirement. + ## Gaps {For each uncovered requirement: why it's not covered and a recommendation. @@ -462,22 +629,29 @@ Write `.artifacts/design/{issue-key}/07-coverage.md`: If none: "All stories trace to PRD requirements."} ``` -### Step 9: Verify Artifact Structure +**Verify artifact structure.** Quick sanity check before invoking the +decomposition review. Verify: -Quick sanity check before invoking the decomposition review. Verify: - -1. `05-epics.md` exists and references all epic files +1. `05-epics.md` exists and references all active epic files 2. Each epic file referenced in `05-epics.md` exists (e.g., `06-stories/epic-1-{slug}.md`) -3. Each epic has a corresponding story directory with story files +3. Each active epic (no `status: removed`) has a corresponding + story directory containing at least one effectively active + child story (no own `status: removed` and parent epic active) + — a directory containing only `status: removed` files does not + satisfy this check. A retired epic from a partial run may + lack a child directory and is retained as an identity record 4. `07-coverage.md` exists and contains at least one mapping row -5. Testplan was loaded in Step 1 (confirmed — Step 1 blocks if missing) -6. Every non-`[DOCS]` story file has `Interface Changes` and `Validated by` lines in its Design Reference section +5. Testplan was loaded in Step 1 (confirmed — source loading blocks if missing) +6. Every active non-`[DOCS]` story file (no `status: removed`, + parent epic active) has `Interface Changes` and `Validated by` + lines in its Design Reference section — retired partial stories + are retained for identity audit only If structural issues are found, fix them before proceeding. Do not invoke a review on incomplete artifacts. -### Step 10: Review Decomposition +### Step 8: Review Decomposition Review the decomposition for structural quality and requirement coverage. This review operates independently from the design document — @@ -501,7 +675,7 @@ subagent for independence. Load it with: - NOT the design document (`03-design.md`) — the reviewer evaluates the artifacts on their own merits -Retain the subagent's ID for use in Step 12 — resuming the same +Retain the subagent's ID for use in the re-review sub-step — resuming the same reviewer gives it memory of its previous findings and concerns, producing more coherent follow-up reviews. @@ -516,64 +690,80 @@ severity definitions from the protocol. The subagent path provides stronger independence; the inline path still catches issues by forcing a perspective shift. -### Step 11: Validate and Assess Findings - -For each finding from Step 10: - -1. **Validate the reference.** Confirm the cited artifact file and - section exist. Discard any finding that references a file or section - that does not exist. - -2. **Cross-check PRD claims.** For findings that assert a missing - requirement or inadequate coverage, verify the claim against the - actual PRD. Discard findings based on misidentified requirements. - -3. **Assess on value.** Does this finding genuinely improve the - decomposition? Would the artifacts be meaningfully better (clearer - acceptance criteria, more complete coverage, better dependency - ordering, more appropriate sizing) if the change were made? - - - If yes: fix the issue immediately. Re-read the affected artifact - file before modifying it. Verify the fix after applying it. - - If no: note why the finding was dismissed (structural preference, - no real improvement, would add churn without value). - -Only fix findings that add real value. Do not make changes for -structural preferences not grounded in the evaluation criteria. - -### Step 12: Re-Review (if fixes were made) - -If Step 11 produced changes to the decomposition artifacts: - -1. Obtain a re-review of the updated artifacts: - - **If a subagent was used in Step 10 and the runtime supports agent - resumption:** Resume the same reviewer agent. Send it the updated - artifacts and a summary of fixes applied. This gives the reviewer - memory of its original findings and lets it verify they were - addressed correctly. - - **If resumption is not available:** Spawn a new subagent loaded with - the decomposition review protocol, the updated artifacts, the PRD - (but NOT the design document), and a summary of the previous round's - findings and fixes so it has full context. - - **If subagents are not available:** Re-read all decomposition artifact - files before re-reviewing — do not rely on your cached understanding - from the previous round. Evaluate the full artifact set (not just the - delta from the last round). - - The re-review should focus on: - - Whether fixes were applied correctly - - Whether fixes introduced new issues -2. If new issues are found, fix them following the same validate-and- - assess procedure from Step 11 -3. Cap at 2 review-fix rounds total. Decomposition fixes are structural - and less likely than code fixes to need multiple iterations. - -If no fixes were needed in Step 11, the review passes immediately. - -### Step 13: Report Review Summary +### Step 9: Validate Findings, Verify Provenance, and Re-Review + +This step runs a validation procedure with two mandatory actions +followed by a conditional re-review. Every validation cycle — including +the first, and including cycles where the review produced zero +findings — executes both actions before deciding whether re-review +is needed. Cap at 2 review-fix rounds total across the entire step. + +1. **Assess review findings.** For each finding from Step 8 (or from + a re-review iteration), apply these checks: + - **Validate the reference.** Confirm the cited artifact file and + section exist. Discard any finding that references a file or + section that does not exist. + - **Cross-check PRD claims.** For findings that assert a missing + requirement or inadequate coverage, verify the claim against the + actual PRD. Discard findings based on misidentified requirements. + - **Assess on value.** Does this finding genuinely improve the + decomposition? If yes: fix the issue immediately — re-read the + affected artifact file before modifying it, verify the fix, and + do not rename, renumber, or move story files (criterion 7). If + no: note why the finding was dismissed. + + Only fix findings that add real value. Do not make changes for + structural preferences not grounded in the evaluation criteria. + +2. **Verify `[DOCS]` input provenance.** This runs unconditionally — + even when the review produced zero findings or all findings were + dismissed. The independent reviewer intentionally lacks the design + document and cannot verify this; it is author-only verification. + + For each effectively active `[DOCS]` story (no own + `status: removed` and parent epic active), check every unmarked + item in Documentation Inputs against the design document + (`03-design.md`) and the PRD. Retired or parent-inactive + `[DOCS]` stories are retained for identity audit but their + historical markers are not rewritten against the current design. + An item that traces to a specific design section or PRD requirement + is correctly unmarked (planned). An item that fills a gap not + specified by either document must be marked `[Assumption]`. An item + that depends on an unresolved open question must be marked + `[Open: §9.N]` — use the existing open-question identifier from + the design document; do not invent new identifiers for this purpose. + Fix any incorrect markers. + +#### Re-review (if artifacts changed) + +If either finding fixes (action 1) or provenance corrections (action 2) +changed any decomposition artifacts, and the 2-round cap has not been +reached, obtain a re-review: + +**If a subagent was used in Step 8 and the runtime supports agent +resumption:** Resume the same reviewer agent. Send it the updated +artifacts and a summary of changes applied. + +**If resumption is not available:** Spawn a new subagent loaded with +the decomposition review protocol, the updated artifacts, the PRD, +the testplan (`04-testplan.md`), and a summary of the previous round's +changes (but NOT the design document) so it has full context. + +**If subagents are not available:** Re-read all decomposition artifact +files before re-reviewing — do not rely on your cached understanding +from the previous round. Evaluate the full artifact set (not just the +delta from the last round). + +The re-review should focus on whether changes were applied correctly +and whether they introduced new issues. If new issues are found, run +the full validation procedure (actions 1 and 2) again on the new +findings, then re-review again if more artifacts changed — subject to +the same 2-round cap. + +If no artifacts were changed by either action in a cycle, the +validation passes and Step 9 is complete. + +### Step 10: Summarize and Present ```markdown ## Decomposition Review Summary @@ -588,15 +778,13 @@ If no fixes were needed in Step 11, the review passes immediately. list them with their file, section, and issue description.} ``` -### Step 14: Present to User - -Present the decomposition and highlight: +**Present to the user.** Present the decomposition and highlight: - Number of epics and stories - Epic dependency order - Any coverage gaps - Stories that might need size adjustment (too large or too small) - Any assumptions or judgment calls in the decomposition -- Decomposition review summary (from Step 13) +- Decomposition review summary (from above) If the decomposition review gate reported FLAG, present the unfixed CRITICAL/HIGH findings and ask the user to decide how to handle them. diff --git a/design/skills/respond.md b/design/skills/respond.md index b53fa447..a1ba3f9d 100644 --- a/design/skills/respond.md +++ b/design/skills/respond.md @@ -522,6 +522,22 @@ references a TC ID that was removed), fix it before proceeding. If the decomposition is affected, flag it and recommend `/revise` or re-running `/decompose`. +**`[DOCS]` story re-evaluation:** If decomposition artifacts exist +(`06-stories/`), re-evaluate every affected `[DOCS]` story against +all seven criteria in `decompose.md`. A `[DOCS]` story is affected +when this round changed: (a) the `[DOCS]` file itself, (b) a listed, +newly relevant, removed, renamed, or reopened `[DEV]`/`[UI]` +contributor's artifacts, (c) design +content that alters the story's reader, scope, AC, assumptions, or +open-question markers — including resolved `[Open: §9.N]` items even +without API/CLI shape changes, or (d) ICs covered by the story. + +When contributors change, update Documentation Inputs, Contributing +stories, and Dependencies to reflect the current contributor set — +add dependencies for newly required contributors, remove obsolete +contribution-only entries, but preserve legitimate unrelated +prerequisite dependencies. Apply all fixes before proceeding. + ### Step 7: Report to User Summarize: diff --git a/design/skills/revise.md b/design/skills/revise.md index 08b6991f..d9543d07 100644 --- a/design/skills/revise.md +++ b/design/skills/revise.md @@ -22,8 +22,8 @@ multiple rounds of revision. - **Preserve traceability.** If new content is added, note its source. - **Show your changes.** After revising, summarize what changed so the user can verify. - **No scope reduction.** Do not silently simplify, even when revising. -- **Filename stability after sync.** If `sync-manifest.json` exists, epic and story filenames are locked — they serve as idempotency keys in Jira. Do not rename, renumber, or delete synced files. New stories must use the next available number. New epics must use the next available number (gaps are fine). -- **Logical deletion via marker.** When the user wants to remove a synced story or epic, do not delete the file. Instead, add `status: removed` to the file's YAML frontmatter. The file stays on disk so `/sync` can detect the removal and close the corresponding Jira issue. +- **Identity stability.** Epic and story filenames, numbers, and containing paths are fixed from creation — do not rename, renumber, or move files. New stories must use the next available number (above any existing or removed file's number in that epic; gaps are fine). New epics must likewise use the next available number. Do not rename or reparent an epic in a way that changes its existing stories' qualified identity or containing path. +- **Logical deletion via marker.** When the user wants to remove a story or epic, do not delete the file. Instead, add `status: removed` to the file's YAML frontmatter. The file stays on disk and its number is reserved. For already-synced items, `/sync` detects the marker and closes the corresponding Jira issue. For never-synced items, `/sync` skips them — no Jira issue exists to close, but the file is retained and its number remains reserved. ## Process @@ -44,8 +44,10 @@ Determine which artifacts exist and read them: - `.artifacts/design/{issue-key}/06-stories/` (epic and story files, if exist) - `.artifacts/design/{issue-key}/07-coverage.md` (coverage matrix, if exists) - `.artifacts/design/{issue-key}/04-testplan.md` (testplan, if exists) -- `.artifacts/design/{issue-key}/sync-manifest.json` (if exists — means - epics/stories have been synced to Jira and filenames are locked) +- `.artifacts/design/{issue-key}/sync-manifest.json` (if exists — + records Jira key mappings and content-hash checkpoints from + previous `/sync` runs; may be partial if sync was interrupted or + not all items were created) If the user edited `03-design.md` manually since the last workflow phase, read and follow `../../_shared/recipes/record-manual-edit.md` with `WORKFLOW=design` @@ -67,31 +69,34 @@ The user's feedback may target: - Sizing corrections ("Epic 2 should be L, not M") - Ordering changes ("Story 2.1 should come before 1.3") -**Pre-sync vs post-sync behavior:** - -If `sync-manifest.json` does **not** exist (pre-sync), all restructuring -operations are allowed — rename files, renumber, combine, split freely. - -If `sync-manifest.json` **exists** (post-sync), filenames are locked: -- **Combining epics:** Do not delete synced epic files or move stories - to different directories. Keep synced story files at their original - paths — `/sync` uses filenames as idempotency keys and path changes - cause duplicate Jira creation. Instead, update the surviving epic's - content to reflect the combined scope and add `status: removed` to - the absorbed epic's YAML frontmatter so `/sync` will close it in - Jira. Add any net-new work as new story files using the next - available number in the target epic. If the user wants to reorganize - epics and stories in Jira, that is a manual Jira operation (move - stories between epics in Jira's UI). Note: stories under the absorbed - epic remain tracked in the manifest under their original epic. - `/sync` will continue to update them if their content changes, but - their Jira parent link still points to the now-closed epic. Move - them to the surviving epic in Jira's UI. +**Identity stability:** Story and epic filenames, numbers, and +containing paths are fixed from creation — do not rename, renumber, +or move files regardless of whether `/sync` has been run. This +matches criterion 7 in `/decompose` and ensures `/sync`'s filename- +based idempotency is always safe. + +- **Combining epics:** Do not delete epic files or move stories to + different directories. If any child work under the absorbed epic is + intended to remain active, do **not** mark the absorbed epic + `status: removed` — keep it as an active container and update its + content, scope, and implementation notes to reflect the + consolidation. Marking an epic `status: removed` retires it and + all its children: `/sync` will cascade-close already-synced active + children and skip never-synced ones. Only mark the epic removed + when all its child work is intentionally retired too. Add any + net-new work as new story files using the next available number in + the target epic. Physical merge or reparenting of retained stories + to a different epic directory is not supported by the current + file-authoritative model — manual Jira reparenting does not update + local file/manifest ownership used by `/sync` and is not a + workaround for retaining children under a removed source epic. If + the user requests a physical merge rather than logical + consolidation, flag the constraint and request clarification. - **Splitting stories:** Keep the original story file. Add new stories - with the next available number (e.g., if story-03 is the last, add - story-04 and story-05). + with the next available number (above any existing or removed + file's number in that epic). - **Reordering:** Update `05-epics.md` implementation notes to reflect - the new order. Do not rename files — the numbering no longer implies order. + the new order. Do not rename files — numbering does not imply order. Clarify with the user if the feedback is ambiguous before making changes. @@ -129,6 +134,32 @@ After applying changes, verify: added, removed, or reassigned. - Do story dependencies still make sense? +**`[DOCS]` story re-evaluation (if any `[DOCS]` story is affected):** + +Re-evaluate every affected `[DOCS]` story against all seven criteria +in `decompose.md`. A `[DOCS]` story is affected when this revision +changed: (a) the `[DOCS]` file itself, (b) a listed or newly relevant +`[DEV]`/`[UI]` contributor's artifacts — including new, removed, +renamed, or reopened stories whose user-facing surface falls within the `[DOCS]` +story's scope, (c) design content that alters the story's reader, +scope, AC, assumptions, or open-question markers — including resolved +`[Open: §9.N]` items even without API/CLI shape changes, (d) ICs +covered by the story, (e) a listed contributor effectively retired +through its parent epic being marked `status: removed` — even when +the contributor's own story file is unchanged, its parent's removal +logically retires it, or (f) a parent epic explicitly restored +(approved removal of its `status: removed` marker) — children +without their own `status: removed` become effectively active +again and should be reconsidered as contributors, while children +with their own `status: removed` remain individually retired +regardless of parent restoration. + +For each affected `[DOCS]` story, re-read the file and update +Documentation Inputs, Contributing stories, and Dependencies to +reflect the current contributor set — but preserve any existing +prerequisite dependencies (e.g., infrastructure stories) that are +not part of the contributing-story inventory. + **If the testplan exists (`04-testplan.md`):** - Before modifying any test cases, record the IC and requirement mappings of any TCs that will be removed, reassigned, or have their @@ -189,7 +220,7 @@ updates above are complete on their own. - **Renamed IC:** Replace the old IC identifier with the new one in every story that lists it. - **Added IC:** Determine which stories implement the new IC using - the same criteria as `/decompose` Step 7a (the story delivers part + the same criteria as `/decompose`'s IC-mapping sub-step (the story delivers part or all of the behavior the IC describes). If ownership is unclear, ask the user which story should claim it. - If test cases were added, removed, or had their IC or requirement @@ -209,6 +240,22 @@ updates above are complete on their own. line. If a story loses all its test cases, write: `Validated by: None — no behavioral test cases after testplan revision`. +**`[DOCS]` reconciliation after cascades.** After the IC and testplan +story cascades above complete, recompute the affected `[DOCS]` set +from the resulting post-cascade state — do not rely on the initial +affected set computed before the cascades, which may miss `[DOCS]` +stories newly affected by IC reassignment, contributor retirement +(including through parent epic removal), contributor restoration +(including through parent epic restoration), or testplan changes +discovered during the cascade. For each `[DOCS]` story in the +recomputed set, re-read the file and reconcile its Documentation +Inputs, Contributing stories, and Dependencies against the current +contributor set. Preserve unrelated prerequisite Dependencies, +existing IDs/paths, and existing removal/restore approval semantics. +After all reconciliation edits, reapply the canonical seven `[DOCS]` +criteria from `decompose.md` to each edited `[DOCS]` story before +proceeding to Step 5. + ### Step 5: Update Artifacts Overwrite the affected artifact files. diff --git a/design/skills/sync.md b/design/skills/sync.md index 1af4d042..7417d0e5 100644 --- a/design/skills/sync.md +++ b/design/skills/sync.md @@ -36,12 +36,12 @@ your responsibility. - **Dry-run first.** Always show what would be created, updated, or closed before doing anything. - **Explicit approval required.** Never modify Jira without the user saying "yes." -- **Batch by operation.** Process epics first (confirm), then stories per epic (confirm). Not one giant batch. +- **Batch by operation.** Process epics first (confirm), then all new stories in the dependency-first order across epics (confirm the ordered cross-epic creation batch), then per-epic update and close passes (confirm). Explicit approval is required for: the overall plan (Step 3), epic operations (Step 5), the ordered cross-epic new-story creation batch (Step 6a), and per-epic update/close passes (Steps 6b/6c). - **Idempotent.** Track what was synced in a manifest with content hashes. If re-run with no changes, do nothing. - **Manifest gate.** Before proceeding past Step 1, you **must** state aloud to the user what the manifest contains (or that none exists). This is mandatory — do not silently skip this acknowledgment. If a manifest exists and you cannot read it, stop and report the error. - **Jira-side duplicate check.** Before creating each epic or story, query Jira for existing children under the parent with a matching summary. If a match is found, stop and present the match to the user — do not create a duplicate. This is a safety net independent of the manifest. - **Jira hierarchy is fixed.** Feature → Epic → Story. Every epic's parent is the Feature issue; every story's parent is its epic. No operation may violate this hierarchy — no re-parenting, no creating issues outside this structure, no closing a Feature. -- **Sync-owned fields.** Sync owns: summary, description, T-shirt size (epics only), parent link (creation only), and issue links (dependencies). These are set from artifact content on creation and overwritten on updates (except parent link). Sync never touches: assignee, sprint, comments, labels, or any other Jira-managed field. Status transitions are permitted **only** for gated lifecycle operations: reopening a previously closed item (Step 4b/5b), closing a removed or user-selected orphaned item (Step 4c/5c), and closing active child stories as part of an approved parent epic closure cascade (Step 4c — the user's approval of the parent close covers its children). No other status changes are allowed. +- **Sync-owned fields.** Sync owns: summary, description, T-shirt size (epics only), parent link (creation only), and issue links (dependencies). These are set from artifact content on creation and overwritten on updates (except parent link). Sync never touches: assignee, sprint, comments, labels, or any other Jira-managed field. Status transitions are permitted **only** for gated lifecycle operations: reopening a previously closed item (Step 5b/6b), closing a removed or user-selected orphaned item (Step 5c/6c), and closing active child stories as part of an approved parent epic closure cascade (Step 5c — the user's approval of the parent close covers its children). No other status changes are allowed. - **Logical deletion via marker.** Stories and epics are closed (not deleted) when their artifact file contains `status: removed` in the YAML frontmatter. The file stays on disk so the manifest can still track it. - **Link to source.** Every Jira issue description references the design document. - **Jira-native references.** Local identifiers (`Story 1.01`, `Epic 1`) have meaning only within the `.artifacts/` directory. When constructing Jira issue descriptions, resolve all local references (in Dependencies, Documentation Inputs, Design Reference, and any other cross-references) to Jira issue keys using the sync manifest. Jira is the source of truth — readers of a Jira issue should never need to decode a local artifact numbering scheme. @@ -193,25 +193,142 @@ Confirm with the user: - **Board / sprint:** Does the user want stories assigned to a specific board or sprint? (optional — can be done later in Jira) -### Step 3: Dry Run +### Step 3: Plan Creation Order and Dry Run -Present a preview of all planned operations, using the change plan items: +**Plan creation order.** Run the sync helper to plan dependency-first +creation order across the entire feature. Capture output and status +using the same fail-closed pattern as the Step 6a reference-availability +guard: + +```bash +PLAN_ERR=$(mktemp) || { echo "FATAL: cannot create temp file" >&2; exit 1; } +PLAN_EXIT=0 +PLAN_JSON=$(python3 "$SYNC_HELPER" plan-creation-order \ + .artifacts/design/{issue-key}/ 2>"$PLAN_ERR") || PLAN_EXIT=$? +PLAN_ERR_TEXT=$(cat "$PLAN_ERR") +rm -f "$PLAN_ERR" +``` + +**Interpret the result (fail-closed).** Only a valid structured +success may proceed to the preview. Apply this three-branch +interpretation **before** presenting the preview or proceeding to +Step 4: + +1. **Exit 0 + valid JSON containing all four required fields + (`ordered`, `blocked`, `errors`, `satisfied_keys`) with correct + types (lists/dict) + `errors` empty + `blocked` empty:** the plan + is valid — `ordered` may be empty when no new stories need + creation. Continue to the preflight check below. +2. **Exit 1 + valid JSON containing all four required fields with + non-empty `errors`:** structured planning errors. **STOP** — + present per-story diagnostics and recommend `/revise` to fix the + decomposition. Do not proceed to preview, approval, writes, or + checkpoints. +3. **Any other result** — empty output, malformed JSON, missing or + wrong-type required fields, unexpected exit code, or exit 1 + without parseable JSON: **STOP**. Report `PLAN_EXIT`, + `PLAN_ERR_TEXT`, and the raw `PLAN_JSON` (if any) as fatal + diagnostics. Do not proceed to preview, approval, writes, or + checkpoints. + +The helper reads Dependencies and Contributing stories from every new +story's artifact file, builds a dependency graph using existing +manifest keys to satisfy already-synced references, and produces a +topologically sorted creation plan. The output contains: + +- `ordered`: new stories in prerequisite-first creation order (stories + with no unsatisfied dependencies first, then their dependents). +- `blocked`: stories whose required references cannot be satisfied + (missing local targets, removed/retired sources with no usable key, + or cycle participants). Each entry lists the missing reference IDs. +- `errors`: human-readable diagnostics (cycles with qualified IDs, + references to nonexistent artifacts). +- `satisfied_keys`: references already satisfied by manifest keys. + +**Preflight check.** If `errors` is non-empty, the plan is invalid +— **stop**. Errors cover new stories with missing targets, cycles, +unreadable source files, AND changed/reopened stories whose edited +Dependencies or Contributing stories reference nonexistent or +unavailable items. Every `blocked` item has at least one root-cause +entry in `errors`, so a non-empty `errors` list is the single gate. +Present the diagnostics and recommend `/revise` to fix the +decomposition before re-running `/sync`. Do not proceed to any Jira +writes (including epic creation in Step 5) with an invalid plan. Do +not silently create extra issues to satisfy references or proceed +with `[unresolved]` placeholders. + +**Cascade closure preview.** If the change plan includes parent +epics being closed (category `"removed"` or `"orphaned"` where the +user chose closure in Step 1), compute cascade child closures so the +preview truthfully reflects all operations Step 5c will perform. +Capture output and status using the same fail-closed pattern as the +plan-creation-order call above: + +```bash +CASC_ERR=$(mktemp) || { echo "FATAL: cannot create temp file" >&2; exit 1; } +CASC_EXIT=0 +CASC_JSON=$(python3 "$SYNC_HELPER" compute-cascade-closures \ + .artifacts/design/{issue-key}/ 2>"$CASC_ERR") || CASC_EXIT=$? +CASC_ERR_TEXT=$(cat "$CASC_ERR") +rm -f "$CASC_ERR" +``` + +Add `--close-orphans` after the artifacts path only if the user +selected option (a) for orphaned items in Step 1. Omit it when the +user chose (b) ignore — ignored orphan parents must not produce +cascades. + +**Interpret the result (fail-closed).** Apply before the dry-run +preview: + +1. **Exit 0 + valid JSON with a `cascades` list (may be empty) and + no `errors` key (or `errors` empty):** cascade data is valid. + Continue to the preview. +2. **Exit 0 + valid JSON with non-empty `errors` list:** manifest + integrity problem (e.g., closing parent or child lacks a Jira + key). **STOP** — present the diagnostics. Do not proceed to + preview, approval, or writes. +3. **Any other result** — nonzero exit, empty output, malformed + JSON, or missing `cascades` field: **STOP**. Report `CASC_EXIT`, + `CASC_ERR_TEXT`, and raw `CASC_JSON` (if any) as fatal + diagnostics. + +Each cascade entry includes `file`, `jira_key`, `title`, +`parent_epic_file`, `parent_jira_key`, and `parent_reason`. + +Include cascade closures in the **To Close** section of the preview +below. Adjust the To Close and Unchanged totals: add the cascade +count to To Close, and subtract from Unchanged any cascade items +that were categorized as `"unchanged"` in the change plan. Do not +double-count items that appear both as individually removed and as +cascade targets. Ignored orphan parents (user chose (b) in Step 1) +must not produce cascades — this is enforced by omitting +`--close-orphans` in the helper call. + +**Dry-run preview.** Present all planned operations: ```markdown ## Jira Sync Preview — {issue-key} ### Feature: {feature-key} — {title} -### To Create ({N} items) +### Story Creation Order ({N} stories) + +Stories will be created in this dependency-first order so each +story's references resolve before it is written to Jira: + +| # | Story | Epic | Dependencies | +|---|-------|------|-------------| +| 1 | Story {N}.{NN}: [{prefix}] {title} | Epic {X} | None | +| 2 | Story {M}.{MM}: [{prefix}] {title} | Epic {Y} | Story {N}.{NN} | + +### Epics to Create ({N} items) #### Epic {X}: {title} (`epic-{X}-{slug}.md`) - **Type:** Epic - **Parent:** {feature-key} - **T-Shirt Size:** {size} - **Summary:** {1-2 sentences} -- **Stories:** - - Story {X}.01: [{prefix}] {title} (`story-01-{slug}.md`) - - Story {X}.02: [{prefix}] {title} (`story-02-{slug}.md`) ### To Update ({N} items) @@ -223,20 +340,23 @@ Present a preview of all planned operations, using the change plan items: For items with `category: "reopened"`, prefix with "Reopened (closed → open)" so the status transition is visible. -### To Close ({N} items) +### To Close ({N} items, including {C} cascade closures) | Jira Key | Local File | Title | |----------|------------|-------| | {jira_key} | `{file}` | {title} | | {jira_key} | *(orphaned — no artifact file)* | {title} | +| {jira_key} | `{file}` *(cascade: parent {parent_jira_key})* | {title} | -Include orphaned entries the user chose to close in Step 1 with the -note "*(orphaned — no artifact file)*" in the Local File column. +Cascade items are children of closing parent epics that Step 5c will +close before the parent. The parent's close approval covers them. +If no cascades exist, omit the cascade count and rows. ### Unchanged ({N} items — skipped) ### Totals -- To create: {N} +- To create (stories): {N} +- To create (epics): {N} - To update: {N} - To close: {N} - Unchanged: {N} @@ -247,12 +367,55 @@ note "*(orphaned — no artifact file)*" in the Local File column. If the user wants changes, recommend `/revise` to update the decomposition first — do not modify the decomposition during sync. -### Step 4: Sync Epics +### Step 4: Validate `[DOCS]` Stories Before Sync + +Before proceeding to Jira operations, validate `[DOCS]` stories +against the quality criteria below. Only stories whose source is +effectively active are eligible for the quality gate — check the +`source_active` field in the change plan items (from detect-changes). +A story with `source_active: false` has an inactive source (own +`status: removed` or parent epic removed/closed without approved +restoration) and is excluded from quality validation — it is +historical and will not be written to Jira as active content. If +`source_active` cannot be determined (no identifiable parent epic), +**stop the sync** and report the anomaly — do not silently exclude +the story from quality or proceed to writes. Restored parent +epics (category `"reopened"`) are active; their children are +eligible. Individually removed stories remain ineligible +regardless of parent status. + +Validate every eligible (source-active) `[DOCS]` story that is +**new**, **changed**, or **reopened** in the change plan against all +seven criteria in `decompose.md`. Also include any eligible +`[DOCS]` story whose own hash is unchanged but whose contributor set +changed: a listed contributor was modified, removed, renamed, or +reopened, or a new/changed/reopened `[DEV]`/`[UI]` story's user-facing +surface falls within the `[DOCS]` story's documented scope. Determine +this from the change plan's full item list (new, changed, removed, +reopened), not only from existing references. + +If any `[DOCS]` story fails validation, **stop the entire sync** — +do not proceed to Step 5 or any Jira writes (epic creates, story +creates, updates, reopens, closures), success checkpoints, or +content checkpoints for this plan. Present the findings, recommend +`/revise` to correct the source, and require a fresh +preflight/preview/approval cycle after correction. +Sync does not edit story source files. +Do not reuse a stale approval from before the correction. + +Note: reference-availability checks for Dependencies and Contributing +stories are handled by the Step 3 creation-order plan (preflight) and +the Step 6a/6b reference-availability guards (runtime). Step 4 +validates `[DOCS]` quality criteria only. This gate is distinct from +valid runtime missing-reference failures in Steps 6a/6b, which skip +only the affected story and continue independent eligible work. + +### Step 5: Sync Epics Process epics in three passes: create new epics, update changed epics, close removed epics. Use the change plan items filtered by `type: "epic"`. -#### 4a: Create New Epics +#### 5a: Create New Epics **Pre-creation duplicate check (per epic).** Before creating each epic, search Jira for existing children of {feature-key} whose summary matches @@ -313,7 +476,7 @@ failed, including the error. Offer to: (a) retry the failed epic, or (b) skip it and continue with the remaining epics. Do not silently continue past a failure. -#### 4b: Update Changed Epics +#### 5b: Update Changed Epics For each epic categorized as **Changed** or **Reopened**, prepare the updated description and hash using the epic `prepare` recipe from the @@ -331,8 +494,8 @@ Update only the sync-owned fields. Do not touch assignee or other Jira-managed fields. Status transitions are permitted only for lifecycle operations: transition **Reopened** items to an open status before updating content, and transition **Removed** or user-selected -**Orphaned** items to a closed status during the close passes (Steps 4c, -5c). Do not change status for any other reason. +**Orphaned** items to a closed status during the close passes (Steps 5c, +6c). Do not change status for any other reason. After updating, record the new hash using `update-manifest --operation update` from the helper reference. @@ -340,7 +503,25 @@ update` from the helper reference. **If update fails:** Report the error with the Jira key and offer to retry or skip. -#### 4c: Close Removed and Selected Orphaned Epics +#### 5c: Close Removed and Selected Orphaned Epics + +**Cascade scope guard.** Before starting child closures, verify +that each closing parent's manifest-active children are within the +approved scope. The approved scope for each parent is the **union** +of: (a) children individually listed as To Close in the Step 3 +preview (category `"removed"` or user-selected `"orphaned"` under +this parent), and (b) the +supplemental cascade closures from `compute-cascade-closures`. Build +this per-parent union from the approved change plan and cascade data. +Re-read the manifest and collect the set of `synced_status: "active"` +children under each closing parent. Verify that these remaining +active children are a **subset** of the approved union — children +whose manifest entry already has `synced_status: "closed"` (from a +resumed partial cascade) are no longer active and need not appear. +If any manifest-active child is **not** in the approved union (e.g., +a concurrent operation added a child between approval and execution), +**stop** and require a fresh preview/approval cycle — do not silently +expand the approved writes. For each epic categorized as **Removed** (file has `status: removed`) or **Orphaned** where the user chose to close it in Step 1: @@ -414,10 +595,10 @@ Present all epic operations to the user: **Wait for user confirmation before syncing stories.** -### Step 5: Sync Stories +### Step 6: Sync Stories **Refresh the change plan** before processing stories. Epic operations -in Step 4 may have changed the manifest (e.g., closing an epic closes +in Step 5 may have changed the manifest (e.g., closing an epic closes its children, reopening an epic enables its children). Re-run `detect-changes` to get an updated plan that reflects the current manifest state: @@ -428,53 +609,119 @@ STORY_PLAN=$(python3 "$SYNC_HELPER" detect-changes .artifacts/design/{issue-key} Use the refreshed plan's `type: "story"` items for story operations. This ensures: -- Children already closed by cascaded epic closure (Step 4c) are not +- Children already closed by cascaded epic closure (Step 5c) are not re-closed - Stories under a reopened epic are correctly categorized -- User orphan choices from Step 1 that were not acted on in Step 4 are +- User orphan choices from Step 1 that were not acted on in Step 5 are preserved (orphan entries the user chose to ignore remain in the manifest and will still appear as orphaned in the refreshed plan) If a parent epic's create or reopen operation failed/was skipped in -Step 4, do not create, update, or reopen stories under that epic — +Step 5, do not create, update, or reopen stories under that epic — skip them and report which stories were skipped due to the parent failure. -If an epic's close cascade was deferred in Step 4c (the parent was +If an epic's close cascade was deferred in Step 5c (the parent was left unclosed because a child closure or checkpoint failed — whether remaining children are open, Jira-closed but uncheckpointed, or unattempted), do not retry closing those remaining children here. They remain in their current manifest state. Report them as deferred and inform the user that the parent cascade must be retried from -Step 4c after remediation. +Step 5c after remediation. -Process stories per epic, in the same three passes: create, update, close. +Process stories in two phases: first create all new stories across +all epics in the planned dependency-first order from Step 3, then +run per-epic update and close passes. -#### 5a: Create New Stories +#### 6a: Create New Stories (Dependency-First Order) -**Pre-creation duplicate check (per story).** Before creating each story, -search Jira for existing children of the epic whose summary matches the -story title. Use a JQL query like: +The user's Step 5 confirmation ("Wait for user confirmation before +syncing stories") covers the ordered cross-epic creation batch below. + +Process the `ordered` list from the Step 3 creation plan. This list +spans all epics and is sorted so prerequisites are created before +their dependents. + +**For each story in the ordered list:** + +**Pre-creation duplicate check.** Before creating each story, search +Jira for existing children of its parent epic whose summary matches +the story title. Use a JQL query like: ``` parent = {epic-jira-key} AND issuetype = Story AND summary ~ "{story title}" ``` -If the query returns one or more matching issues, **do not create the -story.** Instead, stop and present the match to the user with the same -options as in Step 4a (skip and record, create anyway, or stop entirely). +If a match is found, stop and present it to the user with the same +options as in Step 5a (skip and record, create anyway, or stop). Wait for the user's choice before continuing. -For each new story, prepare the hash and description using the story -`prepare` recipe from the helper reference. `PREP` is JSON with -`content_hash`, `description`, `summary` (`[PREFIX] title` without -numbering), `story_type`, `dependencies` (resolved text), and `title`. +**Reference-availability guard.** Before calling `prepare` for any +story, run the `check-ref-availability` subcommand against the current +on-disk manifest with this story's qualified ID: + +```bash +REF_ERR=$(mktemp) || { echo "FATAL: cannot create temp file" >&2; exit 1; } +REF_EXIT=0 +REF_CHECK=$(python3 "$SYNC_HELPER" check-ref-availability \ + .artifacts/design/{issue-key}/ \ + "story-{N}.{NN}" 2>"$REF_ERR") || REF_EXIT=$? +REF_ERR_TEXT=$(cat "$REF_ERR") +rm -f "$REF_ERR" +``` + +The `|| REF_EXIT=$?` idiom captures a nonzero exit without aborting +under `set -e`. Stderr is captured to a per-guard temp file +(`$REF_ERR`) created fresh for this story. The two lines after +the helper call read the stderr text and remove the temp file +immediately — before branch interpretation — so cleanup cannot be +forgotten on any exit path. `REF_ERR_TEXT` is available for fatal +diagnostic reporting; on ready/blocked paths it is simply unused. + +The helper checks Dependencies (Story and Epic refs) and Contributing +stories (for `[DOCS]` stories), verifies the owning epic has a key, +and treats native Jira keys as always available. + +Interpret the result with fail-closed semantics. Exactly ONE of these +branches must match; any other outcome is a catch-all failure: + +1. **Ready:** `REF_EXIT` is 0, stdout is valid JSON, the `ready` list + contains an entry whose `qid` equals THIS story's qualified ID, and + the `blocked` list does NOT contain this QID. → Proceed to + `prepare`. +2. **Blocked:** `REF_EXIT` is 1, stdout is valid JSON, the `blocked` + list contains an entry whose `qid` equals THIS story's qualified ID + with `missing` (list of unresolved ref IDs) and `reason` (string). + → **Skip** this story — do not call `prepare`, do not render a + description, do not create the Jira issue, do not record a manifest + checkpoint. Record the story's qualified ID, the `missing` refs, + and the `reason` for the Step 8 report. Continue with the next + story in the ordered list — do not abort independent eligible work. +3. **Catch-all failure** (every other outcome — including exit 0 with + invalid JSON, exit 0 with this QID missing from `ready`, exit + nonzero with no JSON, malformed or missing `ready`/`blocked` fields, + this QID appearing in both lists, or unexpected exit codes): + **Stop the sync** and report `REF_ERR_TEXT` and any stdout. Do not + skip the story or continue with others — the manifest state may be + indeterminate. Do not call `prepare`, render, write to Jira, or + record a manifest checkpoint. + +Keys recorded by earlier stories in this run (via `update-manifest`) +are immediately visible to subsequent `check-ref-availability` calls +because the manifest is reloaded fresh from disk. Blocked stories +remain eligible for the next explicit `/sync` invocation. + +For each new story (non-blocked), prepare the hash and description +using the story `prepare` recipe from the helper reference. `PREP` +is JSON with `content_hash`, `description`, `summary` (`[PREFIX] +title` without numbering), `story_type`, `dependencies` (resolved +text), and `title`. Create the Jira issue using fields from `PREP`: - **Type:** Story - **Project:** {project key} -- **Parent:** {epic jira key} (from Step 4) — **set via the `fields` parameter: `{"parent": {"key": "{epic jira key}"}}`** +- **Parent:** {epic jira key} (from Step 5) — **set via the `fields` parameter: `{"parent": {"key": "{epic jira key}"}}`** - **Summary:** `summary` from PREP (e.g. "[DEV] Scaffold Build Pipeline" — without "Story 1.01:" prefix) - **Description:** `description` from PREP @@ -544,29 +791,43 @@ successfully and which one failed, including the error. Offer to: stories. The manifest already reflects all successfully created items, so re-running `/sync` later will only attempt uncreated items. -#### 5b: Update Changed Stories +#### 6b: Update Changed Stories -For each story categorized as **Changed** or **Reopened**, prepare the -updated description and hash using the story `prepare` recipe from the -helper reference. +Process changed and reopened stories per epic. + +**Reference-availability guard.** Before calling `prepare` for a +changed or reopened story, run the same `check-ref-availability` guard +as in Step 6a with this story's qualified ID: create a per-guard temp +(`REF_ERR=$(mktemp)`), capture stderr and exit status with the +`|| REF_EXIT=$?` idiom, read stderr text and remove the temp +immediately after the helper call (same two-line cleanup as 6a), then +apply the same three-branch result interpretation (ready → proceed; +blocked → skip; catch-all → stop and report `REF_ERR_TEXT`). Do not +call `prepare`, render a description, update or reopen the Jira issue, +or record a manifest checkpoint for a blocked or failed story. Do not +retry a blocked story in the current run. + +For each story categorized as **Changed** or **Reopened** (non-blocked), +prepare the updated description and hash using the story `prepare` +recipe from the helper reference. Update the Jira issue using fields from `PREP`: - **Summary:** `summary` from PREP - **Description:** `description` from PREP - **Issue links:** read the Jira issue's current issue links and filter - to only those matching the link type selected in Step 5a (either + to only those matching the link type selected in Step 6a (either "Dependency" or "Blocks", whichever was used during creation). Compare these filtered links against the artifact file's dependency list. Remove links whose dependency stories no longer appear in the artifact and create new links for added dependencies, using the same - link type and direction mapping as Step 5a. Do not touch issue links + link type and direction mapping as Step 6a. Do not touch issue links of other types. Update only the sync-owned fields. Do not touch assignee or other Jira-managed fields. For **Reopened** items, transition the Jira issue back to an open status before updating content (see the status rule in -Step 4b). Do not change status for any other reason. +Step 5b). Do not change status for any other reason. After updating, record the new hash using `update-manifest --operation update` with `--item-type story` and `--epic-file` from the helper @@ -575,7 +836,7 @@ reference. **If update fails:** Report the error with the Jira key and offer to retry or skip. -#### 5c: Close Removed and Selected Orphaned Stories +#### 6c: Close Removed and Selected Orphaned Stories For each story categorized as **Removed** (file has `status: removed`) or **Orphaned** where the user chose to close it in Step 1: @@ -602,9 +863,9 @@ Present the stories synced for each epic: | Closed | Story 1.03 | {EDM-CCCC} | [{prefix}] {title} | ``` -### Step 6: Verify Sync Manifest +### Step 7: Verify Sync Manifest -The manifest has been built/updated incrementally during Steps 4-5 (each +The manifest has been built/updated incrementally during Steps 5-6 (each operation was recorded via `update-manifest` immediately after success). Verify the manifest is complete and consistent. The final structure should be: @@ -644,16 +905,17 @@ Fields: - `synced_at` — top-level only, not per-entry. Updated to the current timestamp at the end of each sync run. -### Step 7: Report to User +### Step 8: Report to User Summarize: -- How many epics and stories were created, updated, closed, and unchanged -- Confirm the hierarchy was verified: every epic has parent = Feature (verified in Step 4), every story has parent = its epic (verified in Step 5) +- How many epics and stories were created, updated, closed, unchanged, and **blocked** +- For each blocked story: the story file, which required keys were missing, and the reason. Blocked new stories were not created; blocked changed/reopened stories were not updated. Because no checkpoint was recorded for blocked stories, they remain eligible on the next `/sync` run. Do not claim complete synchronization while blocked stories remain +- Confirm the hierarchy was verified for every issue actually created or updated: every epic has parent = Feature (verified in Step 5), every story has parent = its epic (verified in Step 6). Blocked stories were not written to Jira; list them separately as work not applied - Link to the Feature issue in Jira (which now shows the full hierarchy) **Do not suggest manual parent linking as a next step.** If any parent link was not set during creation, that is a failure that should have been -caught in Steps 4-5. +caught in Steps 5-6. Present a translation table mapping all local artifact identifiers to Jira keys. This table is the definitive reference for anyone working with the