From 8533ab22bf2191179d9dfac3a9e2714dbcd209b0 Mon Sep 17 00:00:00 2001 From: Maxim Svistunov Date: Wed, 6 May 2026 19:17:47 +0200 Subject: [PATCH 01/10] LCORE-2235: spike-template: introduce Epic-grouping for Proposed JIRAs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the LCORE-1736 review of the Epic-rework approach (Option D), the Proposed JIRAs section now uses heading hierarchy to group JIRAs by Epic: - ## Proposed JIRAs — section heading (H2, unchanged) - ### Epic: — Epic boundary (H3, NEW) - #### LCORE-???? — child JIRA stub (H4, demoted from H3) Each `### Epic:` heading is followed by prose (Goals, optional Scope and Success criteria) that becomes the filed Epic's description. The existing kickoff Story + step-definitions Task + third TODO stub are now H4 children under a default `### Epic: Implement TODO feature` block, demoted from their previous H3 level. The intro to the Proposed JIRAs section explains the new shape and notes that single-Epic features can keep one Epic block while larger features can group by aspect (Implementation, Docs, Tests, ...). A commented-out multi-Epic example (`### Epic: Documentation` with a child JIRA stub) sits at the bottom of the section to illustrate the multi-Epic shape; users uncomment and adapt as needed. The `## Proposed incidental JIRAs` section keeps its flat `### LCORE-` shape — incidentals are by nature ungrouped findings that don't belong under any one Epic. This commit only updates the template. The corresponding `file-jiras.sh` parser update (which actually consumes the new shape) follows in a separate commit. Branch will be renamed to lcore-XXXX-feature-design-tooling-improvements once the umbrella tooling JIRA is filed. --- docs/contributing/templates/spike-template.md | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/contributing/templates/spike-template.md b/docs/contributing/templates/spike-template.md index 75803a8e4..de5dc9ee3 100644 --- a/docs/contributing/templates/spike-template.md +++ b/docs/contributing/templates/spike-template.md @@ -84,9 +84,29 @@ The first two stubs below are required-by-default per project convention or remove if explicitly not applicable; document the removal rationale in `Out of scope` above. +JIRAs are grouped by Epic. Each `### Epic: <name>` H3 sub-section becomes +a JIRA Epic when filed; each `#### LCORE-???? <title>` H4 sub-sub-section +becomes a child of that Epic. For simple features, one Epic block covers +everything. For larger features, group by aspect (Implementation, Docs, +Tests, etc.). The prose under each `### Epic:` heading (Goals, Scope, +Success criteria) becomes the Epic's filed description. + +### Epic: Implement TODO feature + +TODO: Brief prose describing what this Epic groups. Convention: include +**Goals** (outcomes when the Epic is complete) and optionally **Scope** +(what's in / out) and **Success criteria** (observable outcomes). +Pattern follows existing LCORE Epics (e.g., LCORE-1623, LCORE-1631). + +**Goals**: +- TODO + +**Scope**: +- TODO + <!-- type: Story --> <!-- key: LCORE-???? --> -### LCORE-???? E2E feature files for TODO feature (no step implementation) +#### LCORE-???? E2E feature files for TODO feature (no step implementation) **User story**: As a Lightspeed Core e2e engineer, I want the behave feature files for TODO feature scenarios written before the feature @@ -125,7 +145,7 @@ additions to tests/e2e/test_list.txt. Do NOT create step definitions. <!-- type: Task --> <!-- key: LCORE-???? --> -### LCORE-???? Implement behave step definitions for TODO feature files +#### LCORE-???? Implement behave step definitions for TODO feature files **Description**: Implement Python step definitions under `tests/e2e/features/steps/` for the `.feature` files authored in @@ -149,7 +169,7 @@ To verify: `uv run make test-e2e` runs every new scenario green. <!-- type: Task --> <!-- key: LCORE-???? --> -### LCORE-???? TODO fill in title +#### LCORE-???? TODO fill in title TODO: Use the format from `docs/contributing/templates/jira-ticket-template.md`. Change type above to Story if user-facing. @@ -170,6 +190,22 @@ Read the "[section]" section in docs/design/<feature>/<feature>.md. Key files: [files]. ``` +<!-- Add another Epic block below by starting a new `### Epic: <name>` +heading; or keep all JIRAs under the single Epic above. + +Example multi-Epic shape (uncomment and adapt): + +### Epic: Documentation + +**Goals**: +- TODO + +#### LCORE-???? Migrate docs to TODO feature + +[body following the same pattern as the kickoff/step-defs stubs above] + +--> + ## Proposed incidental JIRAs REMOVE THIS WHOLE SECTION IF NOT APPLICABLE (controlled by From 8a61f2981e53fe3499cff0e4148b3695f673ba18 Mon Sep 17 00:00:00 2001 From: Maxim Svistunov <makssvistunov@gmail.com> Date: Fri, 8 May 2026 16:21:42 +0200 Subject: [PATCH 02/10] LCORE-2235: file-jiras.sh: Epic-grouping parser + multi-Epic filing routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Epic-rework agreed in LCORE-1736 review (Option D): spike doc carries `### Epic: <name>` H3 sub-sections with prose + `#### LCORE-???? <title>` H4 children. Script parses the heading hierarchy, generates one Epic file per group plus child files with `<!-- parent_epic_file: <stub> -->` metadata, and during filing routes each child to its parent Epic's filed key. Parser changes (the embedded Python heredoc): - Strips multi-line HTML comment blocks before parsing, so commented-out template examples (e.g., the multi-Epic example block at the bottom of the Proposed JIRAs section in spike-template.md) don't leak into the parsed output. - Locates the Proposed JIRAs section accepting both H1 and H2 headings (older spikes used H1 throughout — see conversation-compaction reference). - Locates an optional Proposed incidental JIRAs section (same H1/H2 flexibility). - Detects `### Epic: <name>` boundaries within Proposed JIRAs. For each Epic block, extracts prose (Goals/Scope) between the Epic heading and the first H4 child, and parses H4 children as `#### LCORE-???? title`. - Backward-compat: if no `### Epic:` boundaries are present, falls back to the legacy flat shape (H3 LCORE-???? children grouped under a single auto-generated Epic). The auto-Epic name is derived from the spike doc's parent directory name, as before. - Recognizes already-filed real keys in headings (e.g., `### LCORE-1569: Add token estimation`) alongside placeholders (`### LCORE-???? ...`), preserves real keys in the generated child files. Filing such files sends a PUT (update) instead of POST (create) — useful for re-syncing spike docs whose JIRAs are already filed. - Incidental tickets get `<!-- incidental: true -->` metadata; they are filed under FEATURE_TICKET directly, no Epic parent. - Metadata file `.meta.json` now records `epic_count`, `jira_count`, `incidental_count`, and `parse_mode` (`epic_grouped` | `legacy_flat` | `empty`). Bash-side filing changes: - New helpers `get_parent_epic_file` and `is_incidental` read the new metadata comments. - `show_summary` displays per-file parents: each child shows its parent Epic's filed key (or `(unfiled: <stub>)` if the Epic hasn't been filed yet); incidentals show `<feature-ticket> (incidental)`; Epics show the feature ticket as parent. - `file_ticket` routes children to their `parent_epic_file`'s filed key. If a child's parent Epic has not been filed yet, the script errors clearly and tells the user to file the Epic first. Incidentals go straight to FEATURE_TICKET. Legacy-mode children (no `parent_epic_file` metadata) still use the global EPIC_KEY flow with the existing `ensure_epic_key` prompt as fallback. - Spike-to-Epic "Informs" link is now created against the FIRST filed Epic (was: only Epic). This preserves existing single-Epic behavior while supporting multi-Epic filings. Smoke-tested against docs/design/conversation-compaction/conversation-compaction-spike.md (legacy flat shape with H1 headings, mixed real/placeholder keys): parser detects 7 real keys (LCORE-1569..LCORE-1575), 2 placeholders (kickoff + step-defs), 1 auto-generated Epic; backward-compat works. Subsequent commits will add a structure linter and update the /file-jiras skill text to document the new convention. Branch will be renamed to lcore-XXXX-feature-design-tooling-improvements once the umbrella tooling JIRA is filed. --- dev-tools/file-jiras.sh | 405 ++++++++++++++++++++++++++++++++-------- 1 file changed, 322 insertions(+), 83 deletions(-) diff --git a/dev-tools/file-jiras.sh b/dev-tools/file-jiras.sh index 2dabff636..71cda93ef 100755 --- a/dev-tools/file-jiras.sh +++ b/dev-tools/file-jiras.sh @@ -96,6 +96,20 @@ get_key() { grep -o '<!-- key: [A-Z]*-[0-9]* -->' "$f" 2>/dev/null | head -1 | sed 's/<!-- key: //;s/ -->//' || true } +get_parent_epic_file() { + # Returns the parent_epic_file slug (filename without .md) for a child + # ticket file, or empty string if not present (legacy / Epic / incidental). + local f="$1" + grep -o '<!-- parent_epic_file: [^ ]* -->' "$f" 2>/dev/null | head -1 | sed 's/<!-- parent_epic_file: //;s/ -->//' || true +} + +is_incidental() { + # Returns 0 if this ticket file is marked incidental (no Epic parent; + # files under FEATURE_TICKET directly). + local f="$1" + grep -q '<!-- incidental: true -->' "$f" 2>/dev/null +} + # Portable sed -i (macOS requires '' argument, GNU doesn't) _sed_i() { if sed --version 2>/dev/null | grep -q GNU; then @@ -133,6 +147,7 @@ rm -rf "$JIRA_DIR" mkdir -p "$JIRA_DIR" python3 - "$SPIKE_DOC" "$JIRA_DIR" "$FEATURE_TICKET" << 'PYEOF' +import json import re import sys from pathlib import Path @@ -141,87 +156,259 @@ spike_doc = Path(sys.argv[1]).read_text() out_dir = Path(sys.argv[2]) feature_ticket = sys.argv[3] -# --- Extract spike ticket key from metadata table or first paragraph --- -spike_key_match = re.search(r'\*\*Spike\*\*.*?(LCORE-\d+)', spike_doc) -if not spike_key_match: - # Try "deliverable for LCORE-XXXX" pattern - spike_key_match = re.search(r'deliverable for (LCORE-\d+)', spike_doc) -if not spike_key_match: - # Try first LCORE- reference in the first 500 chars - spike_key_match = re.search(r'(LCORE-\d+)', spike_doc[:500]) -spike_key = spike_key_match.group(1) if spike_key_match else "" -# --- Extract one-line problem statement for Epic description --- -problem_match = re.search(r'\*\*The problem\*\*:\s*(.+?)(?:\n\n|\n\*\*)', spike_doc, re.DOTALL) -problem_line = problem_match.group(1).strip().split('\n')[0] if problem_match else "" +def strip_multiline_comments(text): + """Strip HTML comment blocks that span multiple lines. -# --- Generate Epic stub --- -# Derive Epic title from the spike doc's parent directory name -spike_path = Path(sys.argv[1]) -feature_dir = spike_path.parent.name -if feature_dir and feature_dir not in ('design', 'docs', '.'): - epic_title = f"Implement {feature_dir.replace('-', ' ')}" -else: - epic_title = "TODO: Epic title" + Single-line metadata comments like <!-- type: Task --> or + <!-- key: LCORE-1234 --> are preserved (no newlines inside). + Multi-line commented-out examples in templates (e.g., the + `### Epic: Documentation` example block in the spike-template) + are stripped so the parser doesn't pick them up as real headings. + """ + def replace(m): + return '' if '\n' in m.group(0) else m.group(0) + return re.sub(r'<!--[\s\S]*?-->', replace, text) -epic_content = f"<!-- type: Epic -->\n<!-- key: LCORE-xxxx -->\n### {epic_title}\n" -(out_dir / "00-epic.md").write_text(epic_content) +def slugify(text, max_words=8): + """Convert text to lowercase dash-separated slug, truncated to max_words.""" + words = re.findall(r'[a-z0-9]+', text.lower()) + if not words: + return 'ticket' + return '-'.join(words[:max_words]) -# --- Parse JIRA sections --- -pattern = r'^### (LCORE-\?{4}.*?)$' -sections = re.split(pattern, spike_doc, flags=re.MULTILINE) -count = 0 -for i in range(1, len(sections), 2): - heading = sections[i].strip() - body = sections[i + 1].strip() if i + 1 < len(sections) else "" +def extract_type(preceding_text): + """Extract <!-- type: X --> from the last few lines of preceding text.""" + for line in preceding_text.strip().split('\n')[-5:]: + m = re.search(r'<!--\s*type:\s*(\w+)\s*-->', line) + if m: + return m.group(1) + return "Task" - if not heading.startswith("LCORE-"): - break - count += 1 +def strip_leaked_metadata(body): + """Remove trailing <!-- type/key/parent_epic_file --> that leaks into body + from the next ticket's heading area.""" + body = re.sub(r'\n<!--\s*type:\s*\w+\s*-->\s*$', '', body) + body = re.sub(r'\n<!--\s*key:\s*[\w-]+\s*-->\s*$', '', body) + body = re.sub(r'\n<!--\s*parent_epic_file:[^>]*-->\s*$', '', body) + return body.strip() - # Truncate body at the first # or ## heading (end of JIRAs section) - end_match = re.search(r'^#{1,2}\s', body, flags=re.MULTILINE) - if end_match: - body = body[:end_match.start()].strip() - - # Strip "LCORE-????: " prefix to get clean title - clean_title = re.sub(r'^LCORE-\?+:?\s*', '', heading).strip() - - # Extract type: look in the preceding section's last line (the comment - # sits on the line before the ### heading), then fall back to body. - ticket_type = "Task" - if i > 0: - preceding = sections[i - 1] if i - 1 >= 0 else "" - for line in preceding.strip().split('\n')[-3:]: - m = re.search(r'<!--\s*type:\s*(\w+)\s*-->', line) - if m: - ticket_type = m.group(1) - break - # Strip any <!-- type: ... --> that leaked into body from the next ticket - body = re.sub(r'\n<!--\s*type:\s*\w+\s*-->\s*$', '', body).strip() +# Pre-process: strip multi-line HTML comments +clean_doc = strip_multiline_comments(spike_doc) - # Extract short name for filename - short_name = re.sub(r'[^a-z0-9]+', '-', clean_title.lower()).strip('-') - if not short_name: - short_name = f"ticket-{count}" +# --- Extract spike ticket key (unchanged behavior) --- +spike_key_match = re.search(r'\*\*Spike\*\*.*?(LCORE-\d+)', clean_doc) +if not spike_key_match: + spike_key_match = re.search(r'deliverable for (LCORE-\d+)', clean_doc) +if not spike_key_match: + spike_key_match = re.search(r'(LCORE-\d+)', clean_doc[:500]) +spike_key = spike_key_match.group(1) if spike_key_match else "" - filename = f"{count:02d}-{short_name}.md" - # Write with type and key metadata at top - content = f"<!-- type: {ticket_type} -->\n<!-- key: LCORE-xxxx -->\n### {clean_title}\n\n{body}\n" +# --- Locate Proposed JIRAs section (accepts H1 or H2 — older spikes used H1) --- +proposed_match = re.search( + r'^#{1,2}\s+Proposed JIRAs\s*$\n(.*?)(?=^#{1,2}\s|\Z)', + clean_doc, + re.MULTILINE | re.DOTALL, +) +if not proposed_match: + print(f"Error: 'Proposed JIRAs' section not found in {sys.argv[1]}", file=sys.stderr) + sys.exit(1) +proposed_section = proposed_match.group(1) - (out_dir / filename).write_text(content) -# Write metadata file for the script to read -meta = {"spike_ticket": spike_key, "count": count} -import json +# --- Locate Proposed incidental JIRAs section (optional, H1 or H2) --- +incidental_match = re.search( + r'^#{1,2}\s+Proposed incidental JIRAs\s*$\n(.*?)(?=^#{1,2}\s|\Z)', + clean_doc, + re.MULTILINE | re.DOTALL, +) +incidental_section = incidental_match.group(1) if incidental_match else "" + + +def parse_proposed_section(section_text): + """Parse the Proposed JIRAs section into (epic_blocks, parse_mode). + + epic_blocks is a list of (epic_name, epic_prose, [(child_heading, + child_body, child_type), ...]). + + parse_mode is 'epic_grouped' (new shape: ### Epic + #### LCORE) or + 'legacy_flat' (old shape: ### LCORE flat) or 'empty'. + """ + epic_pattern = re.compile(r'^###\s+Epic:\s*(.+?)\s*$', re.MULTILINE) + epic_matches = list(epic_pattern.finditer(section_text)) + + if epic_matches: + epic_blocks = [] + for i, em in enumerate(epic_matches): + epic_name = em.group(1).strip() + start = em.end() + end = (epic_matches[i + 1].start() + if i + 1 < len(epic_matches) else len(section_text)) + epic_text = section_text[start:end] + + # Children at H4 — match both LCORE-???? (placeholder) and + # LCORE-NNNN (real key, for re-syncing already-filed tickets) + child_pattern = re.compile(r'^####\s+(LCORE-[\d?]+.*?)$', re.MULTILINE) + child_matches = list(child_pattern.finditer(epic_text)) + + epic_prose = ( + epic_text[:child_matches[0].start()].strip() + if child_matches else epic_text.strip() + ) + + children = [] + for j, cm in enumerate(child_matches): + child_heading = cm.group(1).strip() + cstart = cm.end() + cend = (child_matches[j + 1].start() + if j + 1 < len(child_matches) else len(epic_text)) + child_body = epic_text[cstart:cend].strip() + preceding = epic_text[:cm.start()] + ticket_type = extract_type(preceding[-300:]) + child_body = strip_leaked_metadata(child_body) + children.append((child_heading, child_body, ticket_type)) + + epic_blocks.append((epic_name, epic_prose, children)) + return epic_blocks, "epic_grouped" + + # Backward compat: flat ### LCORE-... children, no Epic boundaries. + # Match both LCORE-???? (placeholder) and LCORE-NNNN (real key). + legacy_pattern = re.compile(r'^###\s+(LCORE-[\d?]+.*?)$', re.MULTILINE) + legacy_matches = list(legacy_pattern.finditer(section_text)) + if not legacy_matches: + return [], "empty" + + children = [] + for i, m in enumerate(legacy_matches): + heading = m.group(1).strip() + cstart = m.end() + cend = (legacy_matches[i + 1].start() + if i + 1 < len(legacy_matches) else len(section_text)) + body = section_text[cstart:cend].strip() + preceding = section_text[:m.start()] + ticket_type = extract_type(preceding[-300:]) + body = strip_leaked_metadata(body) + children.append((heading, body, ticket_type)) + + # Auto-generate Epic name from spike-doc parent dir + spike_path = Path(sys.argv[1]) + feature_dir = spike_path.parent.name + if feature_dir and feature_dir not in ('design', 'docs', '.'): + epic_name = f"Implement {feature_dir.replace('-', ' ')}" + else: + epic_name = "TODO: Epic title" + + return [(epic_name, "", children)], "legacy_flat" + + +epic_blocks, parse_mode = parse_proposed_section(proposed_section) + + +def parse_incidental_section(section_text): + """Parse incidental section (always flat ### LCORE-).""" + if not section_text: + return [] + pattern = re.compile(r'^###\s+(LCORE-[\d?]+.*?)$', re.MULTILINE) + matches = list(pattern.finditer(section_text)) + out = [] + for i, m in enumerate(matches): + heading = m.group(1).strip() + cstart = m.end() + cend = (matches[i + 1].start() + if i + 1 < len(matches) else len(section_text)) + body = section_text[cstart:cend].strip() + preceding = section_text[:m.start()] + ticket_type = extract_type(preceding[-300:]) + body = strip_leaked_metadata(body) + out.append((heading, body, ticket_type)) + return out + + +incidental_tickets = parse_incidental_section(incidental_section) + + +# --- Write parsed files --- +file_count = 0 +total_jiras = 0 +total_epics = 0 + +for epic_name, epic_prose, children in epic_blocks: + epic_slug = slugify(epic_name) + epic_filename = f"{file_count:02d}-epic-{epic_slug}.md" + epic_content = ( + f"<!-- type: Epic -->\n" + f"<!-- key: LCORE-xxxx -->\n" + f"### {epic_name}\n" + f"\n" + f"{epic_prose}\n" + ) + (out_dir / epic_filename).write_text(epic_content) + parent_epic_stub = epic_filename.rsplit('.md', 1)[0] + file_count += 1 + total_epics += 1 + + for child_heading, child_body, ticket_type in children: + # Detect real ticket key in heading (e.g., "LCORE-1569: Add ...") + # vs placeholder ("LCORE-???? E2E ..."). Real keys are preserved + # so the script PUT-updates the existing ticket rather than POSTing + # a duplicate. + key_match = re.match(r'LCORE-(\d+)\s*:?\s*', child_heading) + ticket_key = f"LCORE-{key_match.group(1)}" if key_match else "LCORE-xxxx" + clean_title = re.sub(r'^LCORE-[\d?]+\s*:?\s*', '', child_heading).strip() + short_name = slugify(clean_title) + child_filename = f"{file_count:02d}-{short_name}.md" + child_content = ( + f"<!-- type: {ticket_type} -->\n" + f"<!-- key: {ticket_key} -->\n" + f"<!-- parent_epic_file: {parent_epic_stub} -->\n" + f"### {clean_title}\n" + f"\n" + f"{child_body}\n" + ) + (out_dir / child_filename).write_text(child_content) + file_count += 1 + total_jiras += 1 + +# Incidental tickets — file under FEATURE_TICKET directly (no Epic parent) +for heading, body, ticket_type in incidental_tickets: + key_match = re.match(r'LCORE-(\d+)\s*:?\s*', heading) + ticket_key = f"LCORE-{key_match.group(1)}" if key_match else "LCORE-xxxx" + clean_title = re.sub(r'^LCORE-[\d?]+\s*:?\s*', '', heading).strip() + short_name = slugify(clean_title) + inc_filename = f"{file_count:02d}-incidental-{short_name}.md" + inc_content = ( + f"<!-- type: {ticket_type} -->\n" + f"<!-- key: {ticket_key} -->\n" + f"<!-- incidental: true -->\n" + f"### {clean_title}\n" + f"\n" + f"{body}\n" + ) + (out_dir / inc_filename).write_text(inc_content) + file_count += 1 + total_jiras += 1 + + +# --- Metadata file --- +meta = { + "spike_ticket": spike_key, + "epic_count": total_epics, + "jira_count": total_jiras, + "incidental_count": len(incidental_tickets), + "parse_mode": parse_mode, +} (out_dir / ".meta.json").write_text(json.dumps(meta)) -print(f"Parsed {count} JIRAs + 1 Epic from {sys.argv[1]}") +inc_str = f", {len(incidental_tickets)} incidental" if incidental_tickets else "" +print( + f"Parsed {total_epics} Epic(s) + {total_jiras - len(incidental_tickets)} JIRA(s)" + f"{inc_str} from {sys.argv[1]} (mode: {parse_mode})" +) if spike_key: print(f"Spike ticket: {spike_key}") PYEOF @@ -261,17 +448,39 @@ show_summary() { fi if [ "$ttype" = "Epic" ]; then parent="$FEATURE_TICKET" - elif [ -n "$EPIC_KEY" ] && [ "$EPIC_KEY" != "__NONE__" ]; then - parent="$EPIC_KEY" + elif is_incidental "$f"; then + parent="$FEATURE_TICKET (incidental)" else - parent="Epic #0" + local parent_epic_file + parent_epic_file=$(get_parent_epic_file "$f") + if [ -n "$parent_epic_file" ]; then + local epic_path="$JIRA_DIR/${parent_epic_file}.md" + if [ -f "$epic_path" ]; then + local pk + pk=$(get_key "$epic_path") + if [ -n "$pk" ]; then + parent="$pk" + else + parent="(unfiled: $parent_epic_file)" + fi + else + parent="(missing: $parent_epic_file)" + fi + else + # Legacy / fallback (no parent_epic_file metadata) + if [ -n "$EPIC_KEY" ] && [ "$EPIC_KEY" != "__NONE__" ]; then + parent="$EPIC_KEY" + else + parent="(no epic)" + fi + fi fi printf " %-3d %-7s %-13s %-35s %s\n" "$i" "$ttype" "$status" "$title" "$parent" i=$((i + 1)) done echo "" if [ -n "$SPIKE_TICKET_KEY" ]; then - echo " Spike ticket $SPIKE_TICKET_KEY will be linked to Epic with \"Informs\"" + echo " Spike ticket $SPIKE_TICKET_KEY will be linked to first filed Epic with \"Informs\"" fi echo "" } @@ -577,26 +786,55 @@ file_ticket() { ttype=$(get_type "$ticket_file") if [ "$ttype" = "Epic" ]; then - # Check if Epic already has a key (pre-existing) - local epic_existing - epic_existing=$(get_key "$ticket_file") - if [ -n "$epic_existing" ]; then - EPIC_KEY="$epic_existing" - fi + # File Epic under FEATURE_TICKET. With multi-Epic support, each Epic + # is filed independently; we capture the FIRST filed Epic into + # EPIC_KEY so the spike-to-Epic "Informs" link uses it (per + # convention: link to the primary/first Epic). local filed_key filed_key=$(file_single_ticket "$ticket_file" "Epic" "$FEATURE_TICKET") - if [ -n "$filed_key" ]; then + if [ -z "$filed_key" ]; then + return 1 + fi + if [ -z "$EPIC_KEY" ] || [ "$EPIC_KEY" = "__NONE__" ]; then EPIC_KEY="$filed_key" + if [ -n "$SPIKE_TICKET_KEY" ]; then + link_spike_to_epic + fi fi - if [ -n "$EPIC_KEY" ] && [ -n "$SPIKE_TICKET_KEY" ]; then - link_spike_to_epic + echo "$filed_key" + return 0 + fi + + if is_incidental "$ticket_file"; then + # Incidental tickets file directly under FEATURE_TICKET (no Epic). + file_single_ticket "$ticket_file" "$ttype" "$FEATURE_TICKET" + return $? + fi + + # Regular child: route to its parent_epic_file's filed key. + local parent_epic_file + parent_epic_file=$(get_parent_epic_file "$ticket_file") + + local parent + if [ -n "$parent_epic_file" ]; then + local epic_path="$JIRA_DIR/${parent_epic_file}.md" + if [ ! -f "$epic_path" ]; then + echo " Error: parent_epic_file '$parent_epic_file' not found at $epic_path" >&2 + return 1 + fi + parent=$(get_key "$epic_path") + if [ -z "$parent" ]; then + echo " Error: parent epic '$parent_epic_file' has not been filed yet (no key)." >&2 + echo " File the Epic first, then retry filing this ticket." >&2 + return 1 fi - echo "$EPIC_KEY" else - # Need an Epic key for children — refresh from Epic file if not set + # Legacy fallback: no parent_epic_file metadata. Use single-Epic + # flow with the global EPIC_KEY (refresh from any epic file in the + # directory if not yet set). if [ -z "$EPIC_KEY" ] || [ "$EPIC_KEY" = "__NONE__" ]; then local epic_file - epic_file=$(find "$JIRA_DIR" -maxdepth 1 -name '00-epic.md' 2>/dev/null | head -1) + epic_file=$(find "$JIRA_DIR" -maxdepth 1 \( -name '*-epic-*.md' -o -name '00-epic.md' \) 2>/dev/null | sort | head -1) if [ -n "$epic_file" ]; then local ek ek=$(get_key "$epic_file") @@ -609,12 +847,13 @@ file_ticket() { ensure_epic_key || return 1 fi if [ "$EPIC_KEY" = "__NONE__" ]; then - # Fallback: Blocks link (filed as standalone Task linked to feature) - file_single_ticket "$ticket_file" "$ttype" "$FEATURE_TICKET" + parent="$FEATURE_TICKET" else - file_single_ticket "$ticket_file" "$ttype" "$EPIC_KEY" + parent="$EPIC_KEY" fi fi + + file_single_ticket "$ticket_file" "$ttype" "$parent" } # --- Interactive loop --- From e84157d092851e18209375cbc8631596f0a9e9e5 Mon Sep 17 00:00:00 2001 From: Maxim Svistunov <makssvistunov@gmail.com> Date: Fri, 8 May 2026 21:00:52 +0200 Subject: [PATCH 03/10] LCORE-2235: file-jiras.sh: structure linter for the Proposed JIRAs section Inline structure check that runs immediately after parsing. Emits warnings to stderr for likely mistakes; reserves error (non-zero exit) for the unparseable case (zero JIRAs). Checks implemented: - **Mixed shape** (warning): both `### Epic:` boundaries and flat `### LCORE-...` H3 stubs in the same Proposed JIRAs section. The flat ones won't be parsed under any Epic; either demote them to `#### LCORE-...` under an Epic heading or remove the Epic boundaries. - **Empty Epic** (warning): an `### Epic:` block with no `#### LCORE-...` H4 children. The Epic would be filed but have nothing under it. - **Duplicate JIRA title** (warning): two child JIRAs with the same title (case-insensitive, after stripping the LCORE-... prefix). The parser uses titles for filename generation and could collide. - **Zero JIRAs** (error): no children parsed at all from the section. Exits non-zero so the user sees the failure clearly rather than silently filing nothing. Smoke-tested: - conversation-compaction-spike.md (legacy flat, mixed real keys + 2 placeholders): no warnings, parses cleanly. - spike-template.md (Epic-grouped with multi-Epic example commented out): no warnings, parses cleanly (the comment block is stripped). - Synthetic spike with one Epic + one flat ### LCORE: warning fires. - Synthetic spike with empty Epic: warning fires. - Synthetic spike with no JIRAs: error fires, exit code 1. --- dev-tools/file-jiras.sh | 81 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/dev-tools/file-jiras.sh b/dev-tools/file-jiras.sh index 71cda93ef..025c73f52 100755 --- a/dev-tools/file-jiras.sh +++ b/dev-tools/file-jiras.sh @@ -309,6 +309,87 @@ def parse_proposed_section(section_text): epic_blocks, parse_mode = parse_proposed_section(proposed_section) +# --- Structure linter --- +# Warns about likely-mistakes in the spike doc's Proposed JIRAs shape. +# Errors (which exit non-zero) are reserved for unparseable structure; +# warnings (which print and continue) are for inconsistencies the user +# should know about. + +def lint_proposed_section(section_text, epic_blocks, parse_mode): + """Emit warnings to stderr; return True on success, False on error.""" + issues = [] # list of (severity, message) + + # Mixed shape: both `### Epic:` and `### LCORE-` at H3 level + epic_count = len(re.findall(r'^###\s+Epic:\s+', section_text, re.MULTILINE)) + h3_lcore_count = len(re.findall(r'^###\s+LCORE-[\d?]+', section_text, re.MULTILINE)) + if epic_count > 0 and h3_lcore_count > 0: + issues.append(( + "WARNING", + f"Mixed shape detected: {epic_count} `### Epic:` boundaries plus " + f"{h3_lcore_count} flat `### LCORE-...` H3 stubs. The flat ones " + f"will not be parsed under any Epic; demote them to `#### LCORE-...` " + f"under an `### Epic:` heading or remove the Epic boundaries." + )) + + # Epic with zero children + for epic_name, _, children in epic_blocks: + if not children: + issues.append(( + "WARNING", + f"Epic '{epic_name}' has no child JIRAs (no `#### LCORE-...` " + f"H4 sub-headings under it). Either add children or remove " + f"the empty Epic block." + )) + + # Duplicate child titles within or across Epics + all_titles = [] + for epic_name, _, children in epic_blocks: + for heading, _, _ in children: + clean = re.sub(r'^LCORE-[\d?]+\s*:?\s*', '', heading).strip().lower() + all_titles.append((epic_name, clean)) + seen = {} + for epic_name, title in all_titles: + if title in seen: + issues.append(( + "WARNING", + f"Duplicate JIRA title '{title}' (in Epic '{seen[title]}' and " + f"Epic '{epic_name}'). Each child JIRA should have a unique " + f"title; the parser uses titles for filename generation." + )) + else: + seen[title] = epic_name + + # No JIRAs at all + total_children = sum(len(c) for _, _, c in epic_blocks) + if total_children == 0: + issues.append(( + "ERROR", + "Proposed JIRAs section parsed zero JIRAs. Expected at least one " + "`### LCORE-...` (legacy) or `#### LCORE-...` under `### Epic:` (new)." + )) + + # Print issues + for severity, msg in issues: + prefix = " [LINT-{}]".format(severity) + # Wrap long messages for readability + words = msg.split(' ') + line = prefix + for w in words: + if len(line) + 1 + len(w) > 100: + print(line, file=sys.stderr) + line = " " + w + else: + line = line + " " + w + if line.strip(): + print(line, file=sys.stderr) + + return not any(s == "ERROR" for s, _ in issues) + + +if not lint_proposed_section(proposed_section, epic_blocks, parse_mode): + sys.exit(1) + + def parse_incidental_section(section_text): """Parse incidental section (always flat ### LCORE-).""" if not section_text: From 0772b56f5315d95d73014d7f8f1a277943e681ee Mon Sep 17 00:00:00 2001 From: Maxim Svistunov <makssvistunov@gmail.com> Date: Fri, 8 May 2026 21:38:42 +0200 Subject: [PATCH 04/10] LCORE-2235: docs: document Epic-grouping convention in skill + spike howto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /file-jiras skill (`.claude/commands/file-jiras.md`): adds a "Spike doc shape the parser expects" section showing the new `### Epic:` + `#### LCORE-` heading hierarchy, with examples. Documents: - The `parent_epic_file` metadata that routes children at filing time. - Backward-compat for the legacy flat shape (auto-generated Epic). - Real-key preservation (`### LCORE-1569: ...` → PUT/update). - Incidental tickets file under FEATURE_TICKET directly. - Linter behaviors (warnings vs error-with-non-zero-exit). - Multi-Epic filing order (Epic before its children, repeat per Epic). The `echo "quit"` hack instruction stays for now (will be replaced with `--parse-only` in a later commit). `howto-run-a-spike.md` step 5: adds a bullet documenting the Epic-grouping convention so authors know what shape the spike-template demonstrates and what the script consumes. --- .claude/commands/file-jiras.md | 61 +++++++++++++++++++++++++- docs/contributing/howto-run-a-spike.md | 10 +++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/.claude/commands/file-jiras.md b/.claude/commands/file-jiras.md index 6f22eded5..59bd122b2 100644 --- a/.claude/commands/file-jiras.md +++ b/.claude/commands/file-jiras.md @@ -15,10 +15,53 @@ with FIXMEs and exits — the user must fill in their credentials before re-running. API tokens can be created at https://id.atlassian.com/manage-profile/security/api-tokens +## Spike doc shape the parser expects + +`file-jiras.sh` parses the spike doc's `## Proposed JIRAs` section into +Epic-grouped tickets: + +``` +## Proposed JIRAs + +### Epic: <epic-name> + +<epic prose: Goals, optional Scope, Success criteria> + +#### LCORE-???? <child title> ← H4, child of the Epic above +<child body> + +#### LCORE-???? <another child> +<child body> + +### Epic: <another epic-name> ← optional second Epic +... +``` + +Each `### Epic: <name>` becomes a filed Epic; each `#### LCORE-????` +under it becomes a child of that Epic. Children carry a +`<!-- parent_epic_file: <stub> -->` metadata comment in their parsed +files; the script uses this to route each child to its parent Epic's +filed key at filing time. + +**Backward-compat**: spike docs without `### Epic:` boundaries (flat +`### LCORE-...` H3 stubs directly under `## Proposed JIRAs`) still +parse — they get a single auto-generated Epic derived from the spike +doc's parent directory name. + +**Already-filed keys**: if a heading reads `### LCORE-1569: <title>` +(or `#### LCORE-1569: ...`), the parser preserves the real key in the +output file. At filing time, the script sends a PUT (update) instead +of a POST (create), useful for re-syncing previously-filed tickets +with updated descriptions. + +**Incidental tickets** (under `## Proposed incidental JIRAs`) file +under the feature ticket directly, not under any Epic. + ## Process 1. Run `dev-tools/file-jiras.sh --spike-doc <path> --feature-ticket <key>` with `echo "quit"` piped in, so it parses and exits without filing. + (Once `--parse-only` lands as a script flag, switch to that.) 2. Read every file in the output directory (default: `docs/design/<feature>/jiras/`). For each, verify: @@ -27,8 +70,24 @@ https://id.atlassian.com/manage-profile/security/api-tokens - File size is reasonable (a single JIRA should be under ~3KB; if any file is much larger, the parser likely grabbed too much). - The `<!-- type: ... -->` metadata is correct (Epic/Story/Task). + - For children: `<!-- parent_epic_file: <stub> -->` points at an + existing Epic file in the same directory. -3. Report any issues to the user. If all files look correct, tell the user +3. Watch the parser's stderr for `[LINT-WARNING]` lines (mixed shape, + empty Epics, duplicate titles). `[LINT-ERROR]` causes the parser + to exit non-zero — fix the spike doc and re-run. + +4. Report any issues to the user. If all files look correct, tell the user to run the script interactively — provide the full command including `cd` to the repository root: `cd <repo-path> && sh dev-tools/file-jiras.sh --spike-doc <path> --feature-ticket <key>` + +## Filing order + +The script files Epics first, then their children. With multi-Epic +spike docs, this means Epic A is filed → its children land under Epic +A → Epic B is filed → its children land under Epic B. Children whose +parent Epic hasn't been filed yet error out clearly; file the Epic +first and retry. + +Incidental tickets file last (under FEATURE_TICKET, no Epic parent). diff --git a/docs/contributing/howto-run-a-spike.md b/docs/contributing/howto-run-a-spike.md index 519df8a18..1282aeb8c 100644 --- a/docs/contributing/howto-run-a-spike.md +++ b/docs/contributing/howto-run-a-spike.md @@ -114,6 +114,16 @@ Key principles: Description, Scope, Acceptance Criteria, and an Agentic tool instruction pointing to the spec doc. Use [jira-ticket-template.md](templates/jira-ticket-template.md). +- **JIRAs are grouped by Epic.** Each `### Epic: <name>` H3 sub-section + becomes a JIRA Epic when filed; each `#### LCORE-???? <title>` H4 + sub-sub-section becomes a child of that Epic. The prose under the + Epic heading (Goals, optional Scope and Success criteria) becomes + the Epic's filed description. Single-Epic features keep one Epic + block; larger features group by aspect (Implementation, Docs, + Tests, ...). The spike-template demonstrates the shape; `file-jiras.sh` + parses and files accordingly. Backward-compat: the old flat shape + (no `### Epic:` boundaries; `### LCORE-...` H3 stubs directly under + Proposed JIRAs) still parses, with a single auto-generated Epic. - **The first proposed JIRA is the e2e kickoff Story** — write the behave `.feature` files for the feature, no step implementation. This kicks off the work *before* implementation lands so the test shape isn't shaped by From fdfae926cf27da82958b631f0b6f8597d5e32b65 Mon Sep 17 00:00:00 2001 From: Maxim Svistunov <makssvistunov@gmail.com> Date: Sun, 10 May 2026 07:47:51 +0200 Subject: [PATCH 05/10] LCORE-2235: file-jiras.sh: --parse-only flag + /file-jiras skill update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous `echo "quit"` hack the /file-jiras skill relied on to get parse-and-exit behavior. The new flag is a clean short-circuit: - `--parse-only` (alias `--dry-run`) — skip the interactive filing loop and skip the Jira credentials check. Parser writes the parsed files to the output directory, prints a one-line summary listing them, and exits 0. - `ensure_jira_credentials` is now skipped when `--parse-only` is set, so the script can run in CI, in pre-commit hooks, or via an agent that doesn't have credentials configured. - Help text updated to mention the new flag and its use cases. - /file-jiras skill (`.claude/commands/file-jiras.md`) step 1 updated to use `--parse-only` instead of the `echo "quit"` hack. Smoke-tested against docs/design/conversation-compaction/conversation-compaction-spike.md: parses 1 Epic + 9 JIRAs (legacy flat mode), writes 10 files, exits 0 with no Jira API calls attempted. --- .claude/commands/file-jiras.md | 8 +++++--- dev-tools/file-jiras.sh | 29 +++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.claude/commands/file-jiras.md b/.claude/commands/file-jiras.md index 59bd122b2..65879a765 100644 --- a/.claude/commands/file-jiras.md +++ b/.claude/commands/file-jiras.md @@ -59,9 +59,11 @@ under the feature ticket directly, not under any Epic. ## Process -1. Run `dev-tools/file-jiras.sh --spike-doc <path> --feature-ticket <key>` - with `echo "quit"` piped in, so it parses and exits without filing. - (Once `--parse-only` lands as a script flag, switch to that.) +1. Run `dev-tools/file-jiras.sh --spike-doc <path> --feature-ticket <key> --parse-only` + to parse the spike doc into ticket files and exit. `--parse-only` skips + the interactive filing loop and the credentials check, so it works + even on machines without Jira credentials configured (CI, agent + inspection, pre-commit hooks). 2. Read every file in the output directory (default: `docs/design/<feature>/jiras/`). For each, verify: diff --git a/dev-tools/file-jiras.sh b/dev-tools/file-jiras.sh index 025c73f52..2182b7c75 100755 --- a/dev-tools/file-jiras.sh +++ b/dev-tools/file-jiras.sh @@ -27,12 +27,16 @@ SPIKE_TICKET_KEY="" # --- Argument parsing --- show_help() { - echo "Usage: file-jiras.sh --spike-doc <path> --feature-ticket <key> [--output-dir <path>]" + echo "Usage: file-jiras.sh --spike-doc <path> --feature-ticket <key> [--output-dir <path>] [--parse-only]" echo "" echo "Options:" echo " --spike-doc Path to the spike doc containing proposed JIRAs" echo " --feature-ticket Parent feature ticket (e.g., LCORE-1311 or 1311)" echo " --output-dir Directory for parsed ticket files (default: <spike-doc-dir>/jiras/)" + echo " --parse-only Parse the spike doc into ticket files and exit;" + echo " skip the interactive filing loop and credentials check." + echo " Useful for inspecting parsed output, pre-commit hooks," + echo " and CI validation." echo " --help Show this help" echo "" echo "Example:" @@ -42,6 +46,7 @@ show_help() { SPIKE_DOC="" FEATURE_TICKET="" JIRA_DIR="" +PARSE_ONLY=0 while [ $# -gt 0 ]; do case "$1" in @@ -54,6 +59,8 @@ while [ $# -gt 0 ]; do --output-dir) [ $# -ge 2 ] || { echo "Error: --output-dir requires a value"; exit 1; } JIRA_DIR="$2"; shift 2 ;; + --parse-only|--dry-run) + PARSE_ONLY=1; shift ;; --help|-h) show_help; exit 0 ;; *) echo "Unknown argument: $1"; show_help; exit 1 ;; esac @@ -80,7 +87,13 @@ if [ -z "$JIRA_DIR" ]; then JIRA_DIR="$SPIKE_DIR/jiras" fi -ensure_jira_credentials +# Credentials are only required for actual filing (POST/PUT to JIRA). +# In --parse-only mode the script writes parsed files locally and exits; +# skip the credential prompt so the parser can run without a configured +# Jira account (useful for CI / pre-commit hooks / agent inspection). +if [ "$PARSE_ONLY" -eq 0 ]; then + ensure_jira_credentials +fi PROJECT_KEY="${FEATURE_TICKET%%-*}" @@ -937,6 +950,18 @@ file_ticket() { file_single_ticket "$ticket_file" "$ttype" "$parent" } +# --- Parse-only short-circuit --- +# In --parse-only mode the parsed files are already written by the parse +# step above. Skip the interactive loop and exit cleanly. +if [ "$PARSE_ONLY" -eq 1 ]; then + echo "" + echo "Parsed files in $JIRA_DIR/" + ls -1 "$JIRA_DIR" 2>/dev/null + echo "" + echo "(--parse-only: skipping interactive filing loop and exiting.)" + exit 0 +fi + # --- Interactive loop --- show_summary From cd971cefb9af5d9a4e140df53a3de33c7291a57a Mon Sep 17 00:00:00 2001 From: Maxim Svistunov <makssvistunov@gmail.com> Date: Sun, 10 May 2026 07:51:02 +0200 Subject: [PATCH 06/10] LCORE-2235: fetch-jira.sh: --comments flag + skill/doc updates Adds an optional `--comments` flag to fetch-jira.sh that also fetches and prints the ticket's comment thread. Comments often carry decisions or context that the description doesn't capture (e.g., scope negotiations in standup recap comments); having the script surface them avoids the manual JIRA web-UI step. Implementation: - New flag `--comments` parsed at the front of the argument list (before any positional ticket key), so existing positional invocations keep working unchanged. - When set, `fetch_ticket` makes a second curl call to /rest/api/3/issue/<key>/comment and passes both responses to the Python heredoc. - The ADF text extractor used for ticket descriptions has been hoisted to top-level in the Python heredoc so it can be reused for comment bodies (which are also ADF documents). - Comments are printed after subtasks, with author display name + date (YYYY-MM-DD) header and body indented under it. Plain-string comment bodies (rare) are printed verbatim. Show-help text updated to document the new flag and its purpose. /spike skill: notes that `--comments` should be passed when scope decisions or context likely live in the ticket's comment thread. contributing_guide.md: updates the CLI tools listing to show the `[--comments]` flag. Smoke-tested against LCORE-1311 (which has 2 real comments by Ondrej Metelka and Anxhela Coba): comments rendered with author+date headers, body extracted from ADF, full thread visible inline. Without the flag, output is unchanged from the prior behavior. --- .claude/commands/spike.md | 5 ++ dev-tools/fetch-jira.sh | 155 ++++++++++++++++++++++++++----------- docs/contributing_guide.md | 2 +- 3 files changed, 115 insertions(+), 47 deletions(-) diff --git a/.claude/commands/spike.md b/.claude/commands/spike.md index c967cec38..cf3c87919 100644 --- a/.claude/commands/spike.md +++ b/.claude/commands/spike.md @@ -36,6 +36,11 @@ fetch the ticket content by running `sh dev-tools/fetch-jira.sh <number>`. The output includes child issues — decide which linked tickets to fetch for additional context. +Pass `--comments` (e.g., `sh dev-tools/fetch-jira.sh --comments 1234`) +when ticket comments may carry decisions or context not present in the +description (common for older feature tickets where the spike scope was +negotiated in comments). Off by default to keep output short. + Otherwise, the user will provide context about the feature directly. ## Branch and working tree diff --git a/dev-tools/fetch-jira.sh b/dev-tools/fetch-jira.sh index b0f9d37a3..c7407dd93 100755 --- a/dev-tools/fetch-jira.sh +++ b/dev-tools/fetch-jira.sh @@ -17,17 +17,43 @@ set -euo pipefail # shellcheck disable=SC1091 . "$(dirname "$0")/jira-common.sh" -if [ $# -lt 1 ] || [ "$1" = "--help" ] || [ "$1" = "-h" ]; then - echo "Usage: fetch-jira.sh <ticket> [additional-tickets...]" +show_help() { + echo "Usage: fetch-jira.sh [--comments] <ticket> [additional-tickets...]" echo "" echo "Fetches JIRA ticket content including description, status, and child issues." echo "Bare numbers default to LCORE- prefix." echo "" + echo "Options:" + echo " --comments Also fetch and print the ticket's comment thread." + echo " Comments often contain critical decisions ('we decided" + echo " in standup to defer X') that the description doesn't" + echo " capture. Off by default." + echo " --help Show this help" + echo "" echo "Examples:" echo " fetch-jira.sh 1234 Fetch LCORE-1234" echo " fetch-jira.sh LCORE-1234 Same" echo " fetch-jira.sh 836 509 777 Fetch multiple tickets" - if [ $# -lt 1 ]; then exit 1; else exit 0; fi + echo " fetch-jira.sh --comments 1234 Fetch LCORE-1234 with comments" +} + +if [ $# -lt 1 ]; then + show_help; exit 1 +fi + +# Parse flags (must come before any positional ticket arg) +FETCH_COMMENTS=0 +while [ $# -gt 0 ]; do + case "$1" in + --comments) FETCH_COMMENTS=1; shift ;; + --help|-h) show_help; exit 0 ;; + --*) echo "Unknown flag: $1"; show_help; exit 1 ;; + *) break ;; # first positional → ticket key + esac +done + +if [ $# -lt 1 ]; then + echo "Error: no ticket specified"; show_help; exit 1 fi ensure_jira_credentials @@ -47,12 +73,22 @@ fetch_ticket() { -u "$JIRA_EMAIL:$JIRA_TOKEN" \ "$JIRA_INSTANCE/rest/api/3/issue/$key?fields=summary,status,issuetype,description,issuelinks,subtasks,parent" 2>/dev/null) + # Optional: fetch comments (only if --comments was passed). Empty + # JSON object signals "no comments fetched" to the Python printer. + local comments_data='{}' + if [ "$FETCH_COMMENTS" -eq 1 ]; then + comments_data=$(curl -sS --connect-timeout 10 --max-time 30 \ + -u "$JIRA_EMAIL:$JIRA_TOKEN" \ + "$JIRA_INSTANCE/rest/api/3/issue/$key/comment" 2>/dev/null) || comments_data='{}' + fi + if echo "$data" | python3 -c "import sys,json; json.load(sys.stdin)['key']" >/dev/null 2>&1; then python3 -c " import json, sys, textwrap data = json.loads(sys.argv[1]) indent = sys.argv[2] +comments_data = json.loads(sys.argv[3]) if len(sys.argv) > 3 else {} key = data['key'] fields = data['fields'] summary = fields['summary'] @@ -67,51 +103,54 @@ if parent_key: print(f'{indent}Parent: {parent_key}') print() + +# ADF (Atlassian Document Format) → markdown-ish text extractor. +# Hoisted to top-level so both description and comments can use it. +def extract_text(node, depth=0): + lines = [] + if isinstance(node, dict): + ntype = node.get('type', '') + if ntype == 'text': + text = node.get('text', '') + marks = node.get('marks', []) + for m in marks: + if m.get('type') == 'strong': + text = f'**{text}**' + elif m.get('type') == 'code': + text = f'\`{text}\`' + return [text] + if ntype == 'hardBreak': + return ['\n'] + if ntype == 'listItem': + child_text = [] + for c in node.get('content', []): + child_text.extend(extract_text(c, depth)) + return [' ' * depth + '- ' + ''.join(child_text).strip()] + if ntype in ('bulletList', 'orderedList'): + for c in node.get('content', []): + lines.extend(extract_text(c, depth + 1)) + return lines + if ntype == 'heading': + level = node.get('attrs', {}).get('level', 1) + child_text = [] + for c in node.get('content', []): + child_text.extend(extract_text(c, depth)) + return ['#' * level + ' ' + ''.join(child_text).strip()] + if ntype == 'codeBlock': + child_text = [] + for c in node.get('content', []): + child_text.extend(extract_text(c, depth)) + return ['\`\`\`\n' + ''.join(child_text) + '\n\`\`\`'] + for c in node.get('content', []): + lines.extend(extract_text(c, depth)) + if ntype == 'paragraph' and lines: + lines.append('') + return lines + + # Description desc = fields.get('description') if desc and isinstance(desc, dict): - # ADF format — extract text - def extract_text(node, depth=0): - lines = [] - if isinstance(node, dict): - ntype = node.get('type', '') - if ntype == 'text': - text = node.get('text', '') - marks = node.get('marks', []) - for m in marks: - if m.get('type') == 'strong': - text = f'**{text}**' - elif m.get('type') == 'code': - text = f'\`{text}\`' - return [text] - if ntype == 'hardBreak': - return ['\n'] - if ntype == 'listItem': - child_text = [] - for c in node.get('content', []): - child_text.extend(extract_text(c, depth)) - return [' ' * depth + '- ' + ''.join(child_text).strip()] - if ntype in ('bulletList', 'orderedList'): - for c in node.get('content', []): - lines.extend(extract_text(c, depth + 1)) - return lines - if ntype == 'heading': - level = node.get('attrs', {}).get('level', 1) - child_text = [] - for c in node.get('content', []): - child_text.extend(extract_text(c, depth)) - return ['#' * level + ' ' + ''.join(child_text).strip()] - if ntype == 'codeBlock': - child_text = [] - for c in node.get('content', []): - child_text.extend(extract_text(c, depth)) - return ['\`\`\`\n' + ''.join(child_text) + '\n\`\`\`'] - for c in node.get('content', []): - lines.extend(extract_text(c, depth)) - if ntype == 'paragraph' and lines: - lines.append('') - return lines - text_lines = extract_text(desc) desc_text = '\n'.join(text_lines).strip() if desc_text: @@ -149,7 +188,31 @@ if subtasks: sstatus = st['fields']['status']['name'] print(f'{indent} {skey} — {ssummary} [{sstatus}]') print() -" "$data" "$indent" + +# Comments (only when --comments was requested upstream; otherwise +# comments_data is the empty {} sentinel.) +comments = comments_data.get('comments', []) if isinstance(comments_data, dict) else [] +if comments: + print(f'{indent}Comments ({len(comments)}):') + for c in comments: + author = c.get('author', {}).get('displayName') or c.get('author', {}).get('emailAddress') or 'unknown' + created = c.get('created', '')[:10] # YYYY-MM-DD + body = c.get('body') + print(f'{indent} --- {author} ({created}) ---') + if isinstance(body, dict): + # Reuse the same ADF extractor used for descriptions. + text_lines = extract_text(body) + text = '\n'.join(text_lines).strip() + if not text: + text = '(comment body in ADF format; no text extracted)' + elif isinstance(body, str): + text = body + else: + text = '(comment has no body)' + for line in text.split('\n'): + print(f'{indent} {line}') + print() +" "$data" "$indent" "$comments_data" else echo "${indent}Error fetching $key" echo "$data" | head -3 diff --git a/docs/contributing_guide.md b/docs/contributing_guide.md index e38121f80..96a29c84c 100644 --- a/docs/contributing_guide.md +++ b/docs/contributing_guide.md @@ -173,7 +173,7 @@ Personal overrides go in `.feature-design.config.local` at the repo root at the start of each session and announce which files were loaded. **CLI tools** (work without Claude Code): -- `dev-tools/fetch-jira.sh <ticket>` — fetch JIRA ticket content and child issues +- `dev-tools/fetch-jira.sh [--comments] <ticket>` — fetch JIRA ticket content and child issues; `--comments` also includes the comment thread (useful when scope decisions live in comments rather than the description) - `dev-tools/file-jiras.sh --spike-doc <path> --feature-ticket <key>` — parse and file JIRAs ## AI assistants From 638fe3e2ee04ae6a8ba56e299075359cbe0491e4 Mon Sep 17 00:00:00 2001 From: Maxim Svistunov <makssvistunov@gmail.com> Date: Sun, 10 May 2026 07:54:26 +0200 Subject: [PATCH 07/10] LCORE-2235: fetch-jira.sh: --linked-depth flag + skill/doc updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional `--linked-depth N` flag that recurses N levels into the ticket's subtasks, linked issues, and parent= JQL children. Default 0 (unchanged behavior: lists related-ticket keys/summaries, doesn't fetch them). Capped at 3 to prevent runaway fetches. Implementation: - Flag parsing accepts non-negative integers up to 3, validated up front with clear error messages. - `fetch_ticket` accepts a depth parameter; after printing the main ticket body it extracts subtask + linked-issue keys from the parsed data and calls `parent= JQL` for parent-relation children, then recurses into each at `depth - 1`. - Module-level `FETCHED_KEYS` tracks already-seen keys (space-delimited with substring matching). Tickets reachable via multiple paths are fetched at most once. Cycle-safe. - Output uses indentation (`indent` parameter, two spaces per level) so nested ticket sections are visually distinguished. - At depth 0 the bottom-of-script JQL search runs as before (legacy behavior: print the children list as a flat summary). At depth > 0 the recursive fetch_ticket already pulled them in, so the bottom search is skipped to avoid duplication. /spike skill: mentions `--linked-depth N` for spike kickoff context gathering — typically `--linked-depth 1` is the right value at the start of a spike, fetching the feature ticket plus all immediate relations in one call. contributing_guide.md: updates the CLI tools listing. Smoke-tested against LCORE-1311: - depth 0: 1 ticket fetched (matches prior behavior). - depth 1: 3 tickets fetched (LCORE-1311 + LCORE-1314 spike (linked) + LCORE-1631 epic (parent-relation child)). Cycle protection confirmed: no duplicates. --- .claude/commands/spike.md | 8 +++ dev-tools/fetch-jira.sh | 118 +++++++++++++++++++++++++++++++------ docs/contributing_guide.md | 2 +- 3 files changed, 108 insertions(+), 20 deletions(-) diff --git a/.claude/commands/spike.md b/.claude/commands/spike.md index cf3c87919..d0487d81a 100644 --- a/.claude/commands/spike.md +++ b/.claude/commands/spike.md @@ -41,6 +41,14 @@ when ticket comments may carry decisions or context not present in the description (common for older feature tickets where the spike scope was negotiated in comments). Off by default to keep output short. +Pass `--linked-depth N` (e.g., +`sh dev-tools/fetch-jira.sh --linked-depth 1 1234`) to recurse N levels +into subtasks, linked issues, and parent-relation children. `N=1` is +typically what you want at spike kickoff: fetches the requested feature +ticket plus all its immediate Epics, subtasks, and blocking/blocked-by +issues in one call. Capped at 3. Cycle-safe (keys seen via multiple +paths are fetched once). Off by default (depth 0). + Otherwise, the user will provide context about the feature directly. ## Branch and working tree diff --git a/dev-tools/fetch-jira.sh b/dev-tools/fetch-jira.sh index c7407dd93..af1790246 100755 --- a/dev-tools/fetch-jira.sh +++ b/dev-tools/fetch-jira.sh @@ -18,7 +18,7 @@ set -euo pipefail . "$(dirname "$0")/jira-common.sh" show_help() { - echo "Usage: fetch-jira.sh [--comments] <ticket> [additional-tickets...]" + echo "Usage: fetch-jira.sh [--comments] [--linked-depth N] <ticket> [additional-tickets...]" echo "" echo "Fetches JIRA ticket content including description, status, and child issues." echo "Bare numbers default to LCORE- prefix." @@ -28,13 +28,20 @@ show_help() { echo " Comments often contain critical decisions ('we decided" echo " in standup to defer X') that the description doesn't" echo " capture. Off by default." + echo " --linked-depth N Recurse N levels deep into subtasks, linked issues," + echo " and parent-relation children. Default 0 (no recursion;" + echo " just lists related-ticket keys/summaries). N=1 fetches" + echo " the full content of immediate relations; N=2 fetches" + echo " their relations too. Capped at 3 to avoid runaway" + echo " fetches. Already-seen keys are skipped (cycle-safe)." echo " --help Show this help" echo "" echo "Examples:" - echo " fetch-jira.sh 1234 Fetch LCORE-1234" - echo " fetch-jira.sh LCORE-1234 Same" - echo " fetch-jira.sh 836 509 777 Fetch multiple tickets" - echo " fetch-jira.sh --comments 1234 Fetch LCORE-1234 with comments" + echo " fetch-jira.sh 1234 Fetch LCORE-1234" + echo " fetch-jira.sh LCORE-1234 Same" + echo " fetch-jira.sh 836 509 777 Fetch multiple tickets" + echo " fetch-jira.sh --comments 1234 Fetch LCORE-1234 with comments" + echo " fetch-jira.sh --linked-depth 1 1311 Fetch LCORE-1311 + immediate relations" } if [ $# -lt 1 ]; then @@ -43,9 +50,20 @@ fi # Parse flags (must come before any positional ticket arg) FETCH_COMMENTS=0 +LINKED_DEPTH=0 while [ $# -gt 0 ]; do case "$1" in --comments) FETCH_COMMENTS=1; shift ;; + --linked-depth) + [ $# -ge 2 ] || { echo "Error: --linked-depth requires a value"; exit 1; } + LINKED_DEPTH="$2" + if ! echo "$LINKED_DEPTH" | grep -qE '^[0-9]+$'; then + echo "Error: --linked-depth must be a non-negative integer"; exit 1 + fi + if [ "$LINKED_DEPTH" -gt 3 ]; then + echo "Error: --linked-depth capped at 3 to avoid runaway fetches"; exit 1 + fi + shift 2 ;; --help|-h) show_help; exit 0 ;; --*) echo "Unknown flag: $1"; show_help; exit 1 ;; *) break ;; # first positional → ticket key @@ -64,9 +82,20 @@ if echo "$TICKET" | grep -qE '^[0-9]+$'; then TICKET="LCORE-$TICKET" fi +# Tracks already-fetched keys across recursion (space-delimited, with +# leading and trailing spaces so substring matching works cleanly). +FETCHED_KEYS=" " + fetch_ticket() { local key="$1" local indent="${2:-}" + local depth="${3:-0}" + + # Cycle / dup protection + case "$FETCHED_KEYS" in + *" $key "*) return 0 ;; + esac + FETCHED_KEYS="$FETCHED_KEYS$key " local data data=$(curl -sS --connect-timeout 10 --max-time 30 \ @@ -216,17 +245,67 @@ if comments: else echo "${indent}Error fetching $key" echo "$data" | head -3 + return 1 + fi + + # Recurse into related tickets if depth > 0 + if [ "$depth" -gt 0 ]; then + # Extract subtask + linked-issue keys from already-fetched data + local related_keys + related_keys=$(echo "$data" | python3 -c " +import json, sys +try: + d = json.load(sys.stdin) + fields = d.get('fields', {}) + out = [] + for st in fields.get('subtasks', []): + out.append(st['key']) + for link in fields.get('issuelinks', []): + if 'outwardIssue' in link: + out.append(link['outwardIssue']['key']) + elif 'inwardIssue' in link: + out.append(link['inwardIssue']['key']) + print(' '.join(out)) +except Exception: + pass +" 2>/dev/null) + + # Also fetch JQL parent= children + local jql_kids + jql_kids=$(curl -sS --connect-timeout 10 --max-time 30 \ + -u "$JIRA_EMAIL:$JIRA_TOKEN" \ + "$JIRA_INSTANCE/rest/api/3/search/jql?jql=parent%3D${key}&fields=key&maxResults=20" 2>/dev/null | \ + python3 -c " +import json, sys +try: + d = json.load(sys.stdin) + for issue in d.get('issues', []): + print(issue['key']) +except Exception: + pass +" 2>/dev/null | tr '\n' ' ') + + local rk + for rk in $related_keys $jql_kids; do + [ -z "$rk" ] && continue + echo + fetch_ticket "$rk" "${indent} " $((depth - 1)) + done fi } -# Fetch main ticket -fetch_ticket "$TICKET" +# Fetch main ticket (with depth recursion if requested) +fetch_ticket "$TICKET" "" "$LINKED_DEPTH" -# Search for child issues via parent= JQL (Jira Cloud hierarchy) -CHILD_KEYS=$(curl -sS --connect-timeout 10 --max-time 30 \ - -u "$JIRA_EMAIL:$JIRA_TOKEN" \ - "$JIRA_INSTANCE/rest/api/3/search/jql?jql=parent%3D${TICKET}&fields=key,summary,status,issuetype&maxResults=20" 2>/dev/null | \ - python3 -c " +# At depth 0, also list JQL parent= children as a flat summary (legacy +# behavior — useful as a quick "what's underneath" overview without +# fetching each one). At depth > 0, the recursive fetch_ticket already +# pulled them in, so skip this listing to avoid duplication. +if [ "$LINKED_DEPTH" -eq 0 ]; then + CHILD_KEYS=$(curl -sS --connect-timeout 10 --max-time 30 \ + -u "$JIRA_EMAIL:$JIRA_TOKEN" \ + "$JIRA_INSTANCE/rest/api/3/search/jql?jql=parent%3D${TICKET}&fields=key,summary,status,issuetype&maxResults=20" 2>/dev/null | \ + python3 -c " import json, sys try: data = json.load(sys.stdin) @@ -240,12 +319,13 @@ except Exception: pass " 2>/dev/null) -if [ -n "$CHILD_KEYS" ]; then - echo "Child issues:" - echo "$CHILD_KEYS" | while read -r line; do - echo " $line" - done - echo "" + if [ -n "$CHILD_KEYS" ]; then + echo "Child issues:" + echo "$CHILD_KEYS" | while read -r line; do + echo " $line" + done + echo "" + fi fi # If additional ticket keys are passed as arguments, fetch those too @@ -256,5 +336,5 @@ for extra in "$@"; do fi echo "────────────────────────────────────────────────────────" echo "" - fetch_ticket "$extra" + fetch_ticket "$extra" "" "$LINKED_DEPTH" done diff --git a/docs/contributing_guide.md b/docs/contributing_guide.md index 96a29c84c..9f8bf3ade 100644 --- a/docs/contributing_guide.md +++ b/docs/contributing_guide.md @@ -173,7 +173,7 @@ Personal overrides go in `.feature-design.config.local` at the repo root at the start of each session and announce which files were loaded. **CLI tools** (work without Claude Code): -- `dev-tools/fetch-jira.sh [--comments] <ticket>` — fetch JIRA ticket content and child issues; `--comments` also includes the comment thread (useful when scope decisions live in comments rather than the description) +- `dev-tools/fetch-jira.sh [--comments] [--linked-depth N] <ticket>` — fetch JIRA ticket content and child issues; `--comments` also includes the comment thread; `--linked-depth N` (capped at 3) recurses through subtasks, linked issues, and parent-relation children, indented under the requesting ticket. Cycle-safe. - `dev-tools/file-jiras.sh --spike-doc <path> --feature-ticket <key>` — parse and file JIRAs ## AI assistants From 31706bd42e25a765ba33490d6696c6f0065ef718 Mon Sep 17 00:00:00 2001 From: Maxim Svistunov <makssvistunov@gmail.com> Date: Sun, 10 May 2026 07:58:58 +0200 Subject: [PATCH 08/10] LCORE-2235: file-jiras.sh: ADF table emission for markdown tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds markdown-table → ADF-table conversion in the parsed-doc → ADF builder. Previously, markdown tables in spike-doc JIRA bodies were flattened to plain-text paragraphs with literal `|` and `---` characters in the JIRA description — readable but unstructured. Now they render as native ADF tables in JIRA. Implementation: - New `is_table_paragraph(para)` — detects GitHub-flavored markdown tables: paragraph starts with `|` and the second line is a separator matching `^\|[\s\-:|]+\|?\s*$` (i.e., `|---|---|` or `|:---|---:|`). - New `parse_table_row(line, cell_kind)` — splits a `| a | b | c |` line into a `tableRow` ADF node, with each cell wrapping a paragraph. - New `parse_table(para)` — full table parser. First line is header row (cells of type `tableHeader`); separator line is dropped; remaining lines are body rows (cells of type `tableCell`). Returns an ADF table node with the standard `attrs` (isNumberColumnEnabled=false, layout=default). - `parse_block(para)` checks `is_table_paragraph` first and dispatches to `parse_table` when a table is detected. Falls through to existing heading / list / codeblock / paragraph parsing otherwise. Edge cases handled: - Empty cells produce `{"type": "paragraph", "content": []}` rather than a malformed empty cell. - Inline markdown inside cells (bold / code / links) goes through `parse_inline` like any other paragraph. Smoke-tested with a 4-row table (3 cols, 1 header + 3 body, including an empty cell): generated ADF JSON structurally matches the Atlassian ADF spec — header row uses `tableHeader`, body rows use `tableCell`, all wrap paragraphs, all required `attrs` present. Real-JIRA round-trip test (update a sandbox ticket, verify rendering, revert) intentionally NOT done in this commit — the user explicitly authorized that test and will perform it when first using the feature. If JIRA rejects the payload, the bug is in this commit. --- dev-tools/file-jiras.sh | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/dev-tools/file-jiras.sh b/dev-tools/file-jiras.sh index 2182b7c75..c748456df 100755 --- a/dev-tools/file-jiras.sh +++ b/dev-tools/file-jiras.sh @@ -754,7 +754,66 @@ def make_paragraph(text): return {"type": "paragraph", "content": parse_inline(text)} +def is_table_paragraph(para): + """Detect a GitHub-flavored markdown table. + + A markdown table is at least 2 contiguous lines: + Line 1: starts with `|` and has cell content + Line 2: separator with only `|`, `-`, `:`, and whitespace + (e.g., `|---|---|` or `|:---|---:|`) + """ + lines = para.strip().split('\n') + if len(lines) < 2: + return False + if not lines[0].lstrip().startswith('|'): + return False + sep = lines[1].strip() + return bool(re.match(r'^\|[\s\-:|]+\|?\s*$', sep)) + + +def parse_table_row(line, cell_kind): + """Parse a single `| a | b | c |` line into a tableRow ADF node.""" + s = line.strip() + if s.startswith('|'): + s = s[1:] + if s.endswith('|'): + s = s[:-1] + cells_text = s.split('|') + cells = [] + for ct in cells_text: + text = ct.strip() + # ADF cells require at least one block-level node — use an empty + # paragraph for empty cells. + if text: + content = [make_paragraph(text)] + else: + content = [{"type": "paragraph", "content": []}] + cells.append({"type": cell_kind, "attrs": {}, "content": content}) + return {"type": "tableRow", "content": cells} + + +def parse_table(para): + """Convert a markdown-table paragraph into an ADF table node.""" + lines = para.strip().split('\n') + header_line = lines[0] + # lines[1] is the separator — skipped (no semantic content) + body_lines = lines[2:] if len(lines) > 2 else [] + + rows = [parse_table_row(header_line, cell_kind="tableHeader")] + for bl in body_lines: + if bl.strip(): + rows.append(parse_table_row(bl, cell_kind="tableCell")) + + return { + "type": "table", + "attrs": {"isNumberColumnEnabled": False, "layout": "default"}, + "content": rows, + } + + def parse_block(para): + if is_table_paragraph(para): + return parse_table(para) m = re.match(r'^(#{1,6})\s+(.*)', para) if m: level = len(m.group(1)) From 95372ee6901ca5e87e85ec08cc2298de22a6c244 Mon Sep 17 00:00:00 2001 From: Maxim Svistunov <makssvistunov@gmail.com> Date: Mon, 11 May 2026 15:24:09 +0200 Subject: [PATCH 09/10] LCORE-2235: file-jiras.sh: fix invisible prompts + persist EPIC_KEY across subshells Two pre-existing bugs in ensure_epic_key() that bit a user filing tickets under an existing Epic via the option-2 prompt path: 1. **Invisible prompts.** ensure_epic_key writes the "Choice (1/2/3):" and "Epic key:" prompts to stdout via `echo`/`printf`. The interactive `file` command calls file_ticket inside a `key=$(file_ticket "$f")` capture, which swallows stdout into the `$key` variable. Net effect: the user types blindly into the `read -r choice < /dev/tty` call without seeing the prompt. They guess "2" + their Epic key correctly and filing works, but the captured prompt text spills out under "Done:" at the end of the file-loop. Fix: route all user-facing prompt echoes to stderr. The `read` still reads from /dev/tty (controlling terminal), unaffected. Same fix applied to the "Invalid choice" error. 2. **EPIC_KEY doesn't survive subshells.** The same `$(file_ticket ...)` capture means any variable assignment (including `EPIC_KEY="LCORE-1631"` from the option-2 prompt) is scoped to the subshell and discarded when it exits. The parent shell still sees `EPIC_KEY=""`. The next file in the file-loop re-runs ensure_epic_key and re-prompts the user for the SAME Epic. Tedious and error-prone. Fix: persist EPIC_KEY to a small state file at `$JIRA_DIR/.epic-key` after the option-2 prompt OR after option-1 files an Epic. ensure_epic_key reads from the state file on entry, skipping the prompt if a value is present. The parent shell's file-loop tail also reads from the state file so show_summary reflects the right parent in the Parent column. The state file gets auto-cleaned when re-parse happens (rm -rf $JIRA_DIR removes it), so cross-session staleness is not a concern for the typical flow (one feature per JIRA_DIR). Also applies to file_ticket's direct-Epic-filing path (when a file has `<!-- type: Epic -->`): EPIC_KEY captured from file_single_ticket's return value is now persisted to the state file in the same way. Smoke-tested: bash syntax OK; --parse-only still works (3+9 parse against conversation-compaction-spike.md unchanged). End-to-end interactive flow (option-2 path) verified manually in the LCORE-1673 amend + LCORE-2230 step-defs filing that motivated this fix. --- dev-tools/file-jiras.sh | 53 +++++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/dev-tools/file-jiras.sh b/dev-tools/file-jiras.sh index c748456df..509554568 100755 --- a/dev-tools/file-jiras.sh +++ b/dev-tools/file-jiras.sh @@ -584,17 +584,32 @@ get_file_by_number() { } ensure_epic_key() { - # If we already have an Epic key, nothing to do + # If we already have an Epic key in this shell, nothing to do if [ -n "$EPIC_KEY" ]; then return 0 fi - echo "" - echo " No Epic filed yet. Children need an Epic parent." - echo " 1. File Epic #0 first, then continue" - echo " 2. Enter an existing Epic key (e.g., LCORE-1600)" - echo " 3. File without Epic (Blocks link to $FEATURE_TICKET instead)" - printf " Choice (1/2/3): " + # Try to restore from state file (set in a previous subshell that + # had `key=$(file_ticket ...)` capture, so its EPIC_KEY didn't + # propagate back to the parent shell). + if [ -f "$JIRA_DIR/.epic-key" ]; then + EPIC_KEY=$(cat "$JIRA_DIR/.epic-key") + if [ -n "$EPIC_KEY" ]; then + return 0 + fi + fi + + # All user-facing prompts go to stderr so they're visible even when + # this function is called from inside a `$(...)` capture context + # (which would otherwise swallow stdout into a variable). + { + echo "" + echo " No Epic filed yet. Children need an Epic parent." + echo " 1. File Epic #0 first, then continue" + echo " 2. Enter an existing Epic key (e.g., LCORE-1600)" + echo " 3. File without Epic (Blocks link to $FEATURE_TICKET instead)" + printf " Choice (1/2/3): " + } >&2 read -r choice < /dev/tty case "$choice" in @@ -618,17 +633,23 @@ ensure_epic_key() { fi ;; 2) - printf " Epic key: " + printf " Epic key: " >&2 read -r EPIC_KEY < /dev/tty ;; 3) EPIC_KEY="__NONE__" ;; *) - echo " Invalid choice." + echo " Invalid choice." >&2 return 1 ;; esac + + # Persist for subsequent subshells so the user isn't re-prompted for + # every ticket in the file loop. + if [ -n "$EPIC_KEY" ]; then + echo "$EPIC_KEY" > "$JIRA_DIR/.epic-key" + fi } link_spike_to_epic() { @@ -950,6 +971,9 @@ file_ticket() { fi if [ -z "$EPIC_KEY" ] || [ "$EPIC_KEY" = "__NONE__" ]; then EPIC_KEY="$filed_key" + # Persist for subsequent subshells (file_ticket runs inside + # `key=$(...)` capture; assignments don't propagate back). + echo "$EPIC_KEY" > "$JIRA_DIR/.epic-key" if [ -n "$SPIKE_TICKET_KEY" ]; then link_spike_to_epic fi @@ -1123,10 +1147,13 @@ while true; do echo "" echo "Done:$created_keys" fi - # Refresh EPIC_KEY from file (subshell can't propagate variable changes) - _epic_file=$(find "$JIRA_DIR" -maxdepth 1 -name '00-epic.md' 2>/dev/null | head -1) - if [ -n "$_epic_file" ]; then - _ek=$(get_key "$_epic_file") + # Refresh EPIC_KEY from the state file. Subshells (`key=$(file_ticket ...)`) + # cannot propagate variable assignments back to the parent shell, so + # ensure_epic_key and the Epic-filing path persist EPIC_KEY to + # $JIRA_DIR/.epic-key. Re-read it here so the parent shell sees the + # current value for show_summary and subsequent commands. + if [ -f "$JIRA_DIR/.epic-key" ]; then + _ek=$(cat "$JIRA_DIR/.epic-key") if [ -n "$_ek" ]; then EPIC_KEY="$_ek" fi From 388ca481a05420d5602a52a21fb45a0e5f2c8830 Mon Sep 17 00:00:00 2001 From: Maxim Svistunov <makssvistunov@gmail.com> Date: Tue, 12 May 2026 12:41:41 +0200 Subject: [PATCH 10/10] LCORE-2235: /file-jiras skill: add markdown language identifier to fenced code block Addresses CodeRabbit feedback on PR #1725 (`.claude/commands/file-jiras.md` line 23): the fenced code block showing the spike-doc Proposed-JIRAs shape was missing a language identifier, triggering markdownlint MD040 (fenced-code-language). Adds `markdown` after the opening triple backticks. No content change inside the block. --- .claude/commands/file-jiras.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/commands/file-jiras.md b/.claude/commands/file-jiras.md index 65879a765..15b384dc4 100644 --- a/.claude/commands/file-jiras.md +++ b/.claude/commands/file-jiras.md @@ -20,7 +20,7 @@ https://id.atlassian.com/manage-profile/security/api-tokens `file-jiras.sh` parses the spike doc's `## Proposed JIRAs` section into Epic-grouped tickets: -``` +```markdown ## Proposed JIRAs ### Epic: <epic-name>