wizz-method 1.14.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/skills-registry.yaml +4 -0
- package/src/bmm-skills/3-solutioning/wizz-architecture/scripts/__pycache__/lint_spine.cpython-313.pyc +0 -0
- package/src/bmm-skills/3-solutioning/wizz-architecture/scripts/tests/__pycache__/test_lint_spine.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/bmm-skills/4-implementation/wizz-code-review/SKILL.md +3 -5
- package/src/bmm-skills/4-implementation/wizz-code-review/customize.toml +71 -0
- package/src/bmm-skills/4-implementation/wizz-code-review/references/claims-check.md +14 -0
- package/src/bmm-skills/4-implementation/wizz-code-review/references/deletion-check.md +14 -0
- package/src/bmm-skills/4-implementation/wizz-code-review/review-prompts/edge-case-hunter.md +110 -0
- package/src/bmm-skills/4-implementation/wizz-code-review/review-prompts/verification-gap.md +113 -0
- package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-01-gather-context.md +32 -23
- package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-02-review.md +10 -13
- package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-03-triage.md +16 -22
- package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-04-present.md +4 -2
- package/src/bmm-skills/4-implementation/wizz-quick-dev/customize.toml +76 -0
- package/src/bmm-skills/4-implementation/wizz-quick-dev/references/claims-check.md +14 -0
- package/src/bmm-skills/4-implementation/wizz-quick-dev/references/deletion-check.md +14 -0
- package/src/bmm-skills/4-implementation/wizz-quick-dev/review-prompts/edge-case-hunter.md +110 -0
- package/src/bmm-skills/4-implementation/wizz-quick-dev/review-prompts/verification-gap.md +113 -0
- package/src/bmm-skills/4-implementation/wizz-quick-dev/step-04-review.md +38 -16
- package/src/bmm-skills/4-implementation/wizz-retrospective/SKILL.md +56 -1488
- package/src/bmm-skills/4-implementation/wizz-retrospective/customize.toml +3 -5
- package/src/bmm-skills/4-implementation/wizz-retrospective/references/acceptance-verdict.md +55 -0
- package/src/bmm-skills/4-implementation/wizz-retrospective/references/aggregate-views.md +17 -0
- package/src/bmm-skills/4-implementation/wizz-retrospective/references/evidence-gathering.md +30 -0
- package/src/bmm-skills/4-implementation/wizz-retrospective/references/retro-document.md +84 -0
- package/src/bmm-skills/4-implementation/wizz-retrospective/references/team-discussion.md +22 -0
- package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/__pycache__/sprint_status.cpython-313.pyc +0 -0
- package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/git_evidence.py +304 -0
- package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/sprint_status.py +746 -0
- package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/__pycache__/test_git_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/__pycache__/test_sprint_status.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/fixtures/sprint-status-template.yaml +71 -0
- package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/test_git_evidence.py +750 -0
- package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/test_sprint_status.py +1579 -0
- package/src/bmm-skills/4-implementation/wizz-sprint-planning/SKILL.md +39 -296
- package/src/bmm-skills/4-implementation/wizz-sprint-planning/customize.toml +1 -3
- package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/fix-sprint-status.md +30 -0
- package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/generate-tracking.md +25 -0
- package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/readiness-gate.md +20 -0
- package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/status-view.md +14 -0
- package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/validate.md +10 -0
- package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/__pycache__/sprint_plan.cpython-313.pyc +0 -0
- package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/sprint_plan.py +697 -0
- package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/tests/__pycache__/test_sprint_plan.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/tests/test_sprint_plan.py +524 -0
- package/src/bmm-skills/4-implementation/wizz-sprint-planning/sprint-status-template.yaml +9 -7
- package/src/bmm-skills/module-help.csv +3 -3
- package/src/core-skills/_shared/handoff-protocol.md +7 -8
- package/src/core-skills/module-help.csv +1 -0
- package/src/core-skills/wizz-advanced-elicitation/SKILL.md +36 -114
- package/src/core-skills/wizz-advanced-elicitation/{methods.csv → assets/methods.csv} +29 -27
- package/src/core-skills/wizz-advanced-elicitation/customize.toml +54 -0
- package/src/core-skills/wizz-advanced-elicitation/scripts/__pycache__/pick_methods.cpython-313.pyc +0 -0
- package/src/core-skills/wizz-advanced-elicitation/scripts/pick_methods.py +233 -0
- package/src/core-skills/wizz-advanced-elicitation/scripts/tests/__pycache__/test_pick_methods.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/core-skills/wizz-advanced-elicitation/scripts/tests/test_pick_methods.py +228 -0
- package/src/core-skills/wizz-brainstorming/SKILL.md +2 -2
- package/src/core-skills/wizz-brainstorming/assets/brain-selector.html +2 -0
- package/src/core-skills/wizz-brainstorming/references/converge.md +1 -1
- package/src/core-skills/wizz-brainstorming/references/finalize.md +1 -1
- package/src/core-skills/wizz-brainstorming/references/headless.md +1 -1
- package/src/core-skills/wizz-brainstorming/references/mode-autonomous.md +1 -1
- package/src/core-skills/wizz-brainstorming/scripts/__pycache__/brain.cpython-313.pyc +0 -0
- package/src/core-skills/wizz-brainstorming/scripts/brain.py +36 -6
- package/src/core-skills/wizz-brainstorming/scripts/tests/__pycache__/test_brain.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/core-skills/wizz-brainstorming/scripts/tests/__pycache__/test_brain.cpython-314.pyc +0 -0
- package/src/core-skills/wizz-brainstorming/scripts/tests/test_brain.py +24 -2
- package/src/core-skills/wizz-customize/scripts/__pycache__/list_customizable_skills.cpython-313.pyc +0 -0
- package/src/core-skills/wizz-customize/scripts/tests/__pycache__/test_list_customizable_skills.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/core-skills/wizz-forge-idea/SKILL.md +107 -0
- package/src/core-skills/wizz-forge-idea/customize.toml +41 -0
- package/src/core-skills/wizz-forge-idea/scripts/__pycache__/resolve_personas.cpython-313.pyc +0 -0
- package/src/core-skills/wizz-forge-idea/scripts/__pycache__/resolve_personas.cpython-314.pyc +0 -0
- package/src/core-skills/wizz-forge-idea/scripts/resolve_personas.py +275 -0
- package/src/core-skills/wizz-forge-idea/scripts/tests/__pycache__/test_resolve_personas.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/core-skills/wizz-forge-idea/scripts/tests/__pycache__/test_resolve_personas.cpython-314.pyc +0 -0
- package/src/core-skills/wizz-forge-idea/scripts/tests/test_resolve_personas.py +138 -0
- package/src/core-skills/wizz-party-mode/SKILL.md +37 -54
- package/src/core-skills/wizz-party-mode/customize.toml +61 -2
- package/src/core-skills/wizz-party-mode/references/create-party.md +8 -3
- package/src/core-skills/wizz-party-mode/references/mode-agent-team.md +3 -1
- package/src/core-skills/wizz-party-mode/references/mode-subagent.md +16 -4
- package/src/core-skills/wizz-party-mode/references/party-memory.md +51 -0
- package/src/core-skills/wizz-party-mode/scripts/__pycache__/resolve_party.cpython-313.pyc +0 -0
- package/src/core-skills/wizz-party-mode/scripts/__pycache__/resolve_party.cpython-314.pyc +0 -0
- package/src/core-skills/wizz-party-mode/scripts/resolve_party.py +22 -7
- package/src/core-skills/wizz-party-mode/scripts/tests/__pycache__/test_resolve_party.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/core-skills/wizz-party-mode/scripts/tests/__pycache__/test_resolve_party.cpython-314.pyc +0 -0
- package/src/core-skills/wizz-party-mode/scripts/tests/{test-resolve_party.py → test_resolve_party.py} +8 -0
- package/src/core-skills/wizz-review-edge-case-hunter/SKILL.md +17 -3
- package/src/modules/wizz/README.md +1 -1
- package/src/modules/wizz/_shared/model-ladder.md +22 -0
- package/src/modules/wizz/_shared/token-economy.md +6 -2
- package/src/modules/wizz/agents/wizz-ads/customize.toml +1 -1
- package/src/modules/wizz/agents/wizz-copy/customize.toml +1 -1
- package/src/modules/wizz/agents/wizz-designer/customize.toml +1 -1
- package/src/modules/wizz/agents/wizz-growth/customize.toml +11 -1
- package/src/modules/wizz/agents/wizz-maestro/customize.toml +1 -1
- package/src/modules/wizz/agents/wizz-memoria/customize.toml +1 -1
- package/src/modules/wizz/agents/wizz-qa/customize.toml +1 -1
- package/src/modules/wizz/agents/wizz-seo/customize.toml +1 -1
- package/src/modules/wizz/agents/wizz-social/customize.toml +1 -1
- package/src/modules/wizz/overrides/wizz-agent-analyst.toml +1 -1
- package/src/modules/wizz/overrides/wizz-agent-architect.toml +1 -1
- package/src/modules/wizz/overrides/wizz-agent-dev.toml +1 -1
- package/src/modules/wizz/overrides/wizz-agent-pm.toml +1 -1
- package/src/modules/wizz/overrides/wizz-agent-tech-writer.toml +1 -1
- package/src/modules/wizz/overrides/wizz-agent-ux-designer.toml +1 -1
- package/src/modules/wizz/subagents/codex/wizz-exec-haiku.toml +16 -0
- package/src/modules/wizz/subagents/codex/wizz-exec-opus.toml +18 -0
- package/src/modules/wizz/subagents/codex/wizz-exec-review.toml +17 -0
- package/src/modules/wizz/subagents/codex/wizz-exec-sonnet.toml +17 -0
- package/src/modules/wizz/subagents/gemini/wizz-exec-haiku.md +15 -0
- package/src/modules/wizz/subagents/gemini/wizz-exec-opus.md +17 -0
- package/src/modules/wizz/subagents/gemini/wizz-exec-review.md +15 -0
- package/src/modules/wizz/subagents/gemini/wizz-exec-sonnet.md +16 -0
- package/src/modules/wizz/subagents/opencode/wizz-exec-haiku.md +14 -0
- package/src/modules/wizz/subagents/opencode/wizz-exec-opus.md +16 -0
- package/src/modules/wizz/subagents/opencode/wizz-exec-review.md +16 -0
- package/src/modules/wizz/subagents/opencode/wizz-exec-sonnet.md +15 -0
- package/src/modules/wizz/subagents/wizz-exec-opus.md +16 -0
- package/src/modules/wizz/subagents/wizz-exec-review.md +15 -0
- package/src/scripts/__pycache__/memlog.cpython-313.pyc +0 -0
- package/src/scripts/tests/__pycache__/test_memlog.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/scripts/tests/__pycache__/test_resolve_customization.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/skills-lib/pesquisa-de-publico-do-piva/README.md +83 -0
- package/src/skills-lib/pesquisa-de-publico-do-piva/SKILL.md +65 -0
- package/src/skills-lib/pesquisa-de-publico-do-piva/assets/template.html +414 -0
- package/src/skills-lib/pesquisa-de-publico-do-piva/prompts.md +131 -0
- package/src/skills-lib/wizz-offer-forge/README.md +13 -0
- package/src/skills-lib/wizz-offer-forge/SKILL.md +74 -0
- package/src/skills-lib/wizz-offer-forge/references/empilhamento-valor.md +25 -0
- package/src/skills-lib/wizz-offer-forge/references/garantia-reversa.md +22 -0
- package/src/skills-lib/wizz-offer-forge/references/mecanismo-unico.md +28 -0
- package/src/skills-lib/wizz-offer-forge/references/ponte-trafego.md +21 -0
- package/src/skills-lib/wizz-router/references/routing-table-flat.md +2 -0
- package/tools/installer/core/installer.js +57 -0
- package/tools/installer/ide/_config-driven.js +44 -30
- package/tools/installer/ide/platform-codes.yaml +42 -3
- package/tools/installer/modules/external-manager.js +140 -2
- package/tools/installer/modules/official-modules.js +68 -21
- package/tools/installer/prompts.js +45 -103
- package/tools/installer/ui.js +64 -7
- package/wizz-modules.yaml +65 -11
- package/src/bmm-skills/4-implementation/wizz-sprint-planning/checklist.md +0 -34
- package/src/core-skills/wizz-brainstorming/scripts/memlog.py +0 -202
- package/src/core-skills/wizz-brainstorming/scripts/tests/test_memlog.py +0 -265
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = ["ruamel.yaml>=0.18"]
|
|
4
|
+
# ///
|
|
5
|
+
"""Parse epic files and deterministically generate or refresh sprint-status.yaml.
|
|
6
|
+
|
|
7
|
+
Prints ONLY JSON to stdout — argparse failures included. Errors are emitted as
|
|
8
|
+
JSON to stdout with a non-zero exit code. Writes are atomic (temp file, fsync,
|
|
9
|
+
``os.replace``) and the original file bytes are restored atomically if
|
|
10
|
+
post-write validation fails.
|
|
11
|
+
|
|
12
|
+
Subcommands:
|
|
13
|
+
generate Parse epics, merge with any existing status file, write the result.
|
|
14
|
+
--dry-run reports (including drift: in_sync, illegal, orphans)
|
|
15
|
+
without writing. --fresh ignores existing statuses for a pristine
|
|
16
|
+
rebuild; --set key=status applies explicit, user-confirmed statuses
|
|
17
|
+
on top — the repair path is allowed to downgrade.
|
|
18
|
+
status Summarize an existing status file: counts, risks, open action
|
|
19
|
+
items, and the next recommended action. No writes.
|
|
20
|
+
validate Report whether an existing status file is structurally valid:
|
|
21
|
+
parseable, recognized keys, legal statuses, well-formed
|
|
22
|
+
action_items. No writes; exit 0 whether valid or not.
|
|
23
|
+
|
|
24
|
+
The LLM decides *which* files are epics (discovery is judgment); this script
|
|
25
|
+
owns everything after that decision: parsing, key derivation, ordering, status
|
|
26
|
+
preservation, story-file detection, action-item carry-over, and validation.
|
|
27
|
+
Legacy v6 statuses (drafted, contexted) are normalized on read everywhere, so
|
|
28
|
+
they merge and count by their modern meaning and are reported, never reset.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import hashlib
|
|
33
|
+
import io
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import sys
|
|
38
|
+
import tempfile
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from ruamel.yaml import YAML
|
|
42
|
+
from ruamel.yaml.comments import CommentedMap
|
|
43
|
+
|
|
44
|
+
EPIC_RE = re.compile(r"^#{1,3}\s*Epic\s+(\d+)\s*:?\s*(.*?)\s*#*\s*$", re.IGNORECASE)
|
|
45
|
+
STORY_RE = re.compile(
|
|
46
|
+
r"^#{2,4}\s*Story\s+(\d+)\.(\d+[a-z]?)\s*:?\s*(.*?)\s*#*\s*$", re.IGNORECASE
|
|
47
|
+
)
|
|
48
|
+
# Heading lines that mention Epic/Story but failed the strict patterns above.
|
|
49
|
+
SUSPECT_RE = re.compile(r"^#{1,4}\s.*\b(?:epic|story)\b", re.IGNORECASE)
|
|
50
|
+
FENCE_RE = re.compile(r"^\s{0,3}(?:```|~~~)")
|
|
51
|
+
|
|
52
|
+
# The key grammar for sprint-status.yaml. The trailing [a-z]? matches
|
|
53
|
+
# split-story keys like 2-6a-...; wizz-retrospective's sprint_status.py reads
|
|
54
|
+
# the same file with the same grammar.
|
|
55
|
+
EPIC_KEY_RE = re.compile(r"^epic-(\d+)$")
|
|
56
|
+
RETRO_KEY_RE = re.compile(r"^epic-(\d+)-retrospective$")
|
|
57
|
+
STORY_KEY_RE = re.compile(r"^(\d+)-(\d+)([a-z]?)-.+")
|
|
58
|
+
|
|
59
|
+
STORY_RANK = {"backlog": 0, "ready-for-dev": 1, "in-progress": 2, "review": 3, "done": 4}
|
|
60
|
+
EPIC_RANK = {"backlog": 0, "in-progress": 1, "done": 2}
|
|
61
|
+
RETRO_RANK = {"optional": 0, "done": 1}
|
|
62
|
+
RANKS = {"epic": EPIC_RANK, "story": STORY_RANK, "retro": RETRO_RANK}
|
|
63
|
+
ACTION_STATUSES = ("open", "in-progress", "done")
|
|
64
|
+
|
|
65
|
+
# v6 wrote these; they still exist in the wild (v6-shims/wizz-create-story
|
|
66
|
+
# actively writes 'contexted'). Normalized on every read so no subcommand ever
|
|
67
|
+
# treats a valid legacy file as illegal or resets its progress.
|
|
68
|
+
LEGACY_STATUS = {"drafted": "ready-for-dev", "contexted": "in-progress"}
|
|
69
|
+
|
|
70
|
+
STALE_DAYS_DEFAULT = 7
|
|
71
|
+
DATE_FORMAT = "%m-%d-%Y %H:%M"
|
|
72
|
+
# Hand-edited files drift toward ISO stamps; accept them rather than silently
|
|
73
|
+
# disabling the staleness check.
|
|
74
|
+
STAMP_FORMATS = (DATE_FORMAT, "%Y-%m-%d %H:%M", "%Y-%m-%d")
|
|
75
|
+
|
|
76
|
+
# Kept byte-identical (modulo the leading "# ") with the STATUS DEFINITIONS
|
|
77
|
+
# block in sprint-status-template.yaml; test_sprint_plan.py asserts the two
|
|
78
|
+
# never drift.
|
|
79
|
+
HEADER_COMMENT = """\
|
|
80
|
+
STATUS DEFINITIONS:
|
|
81
|
+
==================
|
|
82
|
+
Epic Status:
|
|
83
|
+
- backlog: Epic not yet started
|
|
84
|
+
- in-progress: Epic actively being worked on
|
|
85
|
+
- done: All stories in epic completed
|
|
86
|
+
|
|
87
|
+
Story Status:
|
|
88
|
+
- backlog: Story only exists in epic file
|
|
89
|
+
- ready-for-dev: Story file created, ready for development
|
|
90
|
+
- in-progress: Developer actively working on implementation
|
|
91
|
+
- review: Implementation complete, ready for review
|
|
92
|
+
- done: Story completed
|
|
93
|
+
|
|
94
|
+
Retrospective Status:
|
|
95
|
+
- optional: Can be completed but not required
|
|
96
|
+
- done: Retrospective has been completed
|
|
97
|
+
|
|
98
|
+
Action Item Status:
|
|
99
|
+
- open: Committed during a retrospective, not yet addressed
|
|
100
|
+
- in-progress: Actively being worked on
|
|
101
|
+
- done: Completed
|
|
102
|
+
|
|
103
|
+
WORKFLOW NOTES:
|
|
104
|
+
===============
|
|
105
|
+
- Epic transitions to 'in-progress' automatically when its first story starts (via build's sprint sync)
|
|
106
|
+
- Stories can be worked in parallel if team capacity allows
|
|
107
|
+
- Developer typically creates the next story after the previous one is 'done' to incorporate learnings
|
|
108
|
+
- Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
|
|
109
|
+
- Retrospective appends its action items to action_items; the status view surfaces open ones
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _fail(message, **extra):
|
|
114
|
+
print(json.dumps({"ok": False, "error": message, **extra}, default=str))
|
|
115
|
+
sys.exit(1)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class JsonArgumentParser(argparse.ArgumentParser):
|
|
119
|
+
"""Emit argparse failures on the JSON-only stdout contract, not usage text.
|
|
120
|
+
|
|
121
|
+
Built with ``add_help=False`` everywhere: the built-in help action prints
|
|
122
|
+
plain usage to stdout with exit 0, which would break the machine consumer
|
|
123
|
+
this script serves. ``-h`` therefore routes through ``error()`` as an
|
|
124
|
+
ordinary unrecognized argument; the skill's SKILL.md carries the usage a
|
|
125
|
+
human needs.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def error(self, message):
|
|
129
|
+
print(json.dumps({"ok": False, "error": f"argument error: {message}"}))
|
|
130
|
+
sys.exit(2)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _slug(text, maxlen=60):
|
|
134
|
+
# Unicode-aware: a non-Latin title must keep its own characters in the key
|
|
135
|
+
# rather than every such story collapsing onto one shared placeholder.
|
|
136
|
+
slug = re.sub(r"[^\w]+", "-", str(text).lower(), flags=re.UNICODE).strip("-")
|
|
137
|
+
slug = slug[:maxlen].strip("-")
|
|
138
|
+
if not slug:
|
|
139
|
+
# Nothing sluggable (punctuation/emoji only): a short content hash keeps
|
|
140
|
+
# the key deterministic and distinct instead of a bare "untitled".
|
|
141
|
+
slug = hashlib.sha256(str(text).encode("utf-8")).hexdigest()[:8]
|
|
142
|
+
return slug
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def classify_key(key):
|
|
146
|
+
"""Return (kind, epic_num) for a recognized key, else None."""
|
|
147
|
+
m = RETRO_KEY_RE.match(key)
|
|
148
|
+
if m:
|
|
149
|
+
return "retro", int(m.group(1))
|
|
150
|
+
m = EPIC_KEY_RE.match(key)
|
|
151
|
+
if m:
|
|
152
|
+
return "epic", int(m.group(1))
|
|
153
|
+
m = STORY_KEY_RE.match(key)
|
|
154
|
+
if m:
|
|
155
|
+
return "story", int(m.group(1))
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _story_sort_key(key):
|
|
160
|
+
m = STORY_KEY_RE.match(key)
|
|
161
|
+
if not m:
|
|
162
|
+
return (10**9, 10**9, "", key)
|
|
163
|
+
return (int(m.group(1)), int(m.group(2)), m.group(3), key)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _normalize(raw):
|
|
167
|
+
"""Map a raw status through the legacy vocabulary. Returns (status, was_legacy)."""
|
|
168
|
+
status = LEGACY_STATUS.get(raw, raw)
|
|
169
|
+
return status, raw in LEGACY_STATUS
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def parse_epics(paths):
|
|
173
|
+
"""Return (entries, warnings). Entries are (key, kind, epic_num) in file order."""
|
|
174
|
+
epics = {} # epic_num -> [story keys in order]
|
|
175
|
+
warnings = []
|
|
176
|
+
for path in paths:
|
|
177
|
+
try:
|
|
178
|
+
lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
179
|
+
except OSError as exc:
|
|
180
|
+
_fail(f"cannot read epic file {path}: {exc}")
|
|
181
|
+
in_fence = False
|
|
182
|
+
for lineno, line in enumerate(lines, 1):
|
|
183
|
+
if FENCE_RE.match(line):
|
|
184
|
+
in_fence = not in_fence
|
|
185
|
+
continue
|
|
186
|
+
if in_fence:
|
|
187
|
+
continue
|
|
188
|
+
epic_m = EPIC_RE.match(line)
|
|
189
|
+
if epic_m:
|
|
190
|
+
epics.setdefault(int(epic_m.group(1)), [])
|
|
191
|
+
continue
|
|
192
|
+
story_m = STORY_RE.match(line)
|
|
193
|
+
if story_m:
|
|
194
|
+
epic_num = int(story_m.group(1))
|
|
195
|
+
story_num = story_m.group(2)
|
|
196
|
+
key = f"{epic_num}-{story_num}-{_slug(story_m.group(3))}"
|
|
197
|
+
stories = epics.setdefault(epic_num, [])
|
|
198
|
+
if key in stories:
|
|
199
|
+
warnings.append(f"duplicate story heading '{key}' at {path}:{lineno}")
|
|
200
|
+
else:
|
|
201
|
+
stories.append(key)
|
|
202
|
+
continue
|
|
203
|
+
if SUSPECT_RE.match(line):
|
|
204
|
+
warnings.append(f"unparsed Epic/Story-like heading at {path}:{lineno}: {line.strip()}")
|
|
205
|
+
entries = []
|
|
206
|
+
for epic_num in sorted(epics):
|
|
207
|
+
entries.append((f"epic-{epic_num}", "epic", epic_num))
|
|
208
|
+
for story_key in epics[epic_num]:
|
|
209
|
+
entries.append((story_key, "story", epic_num))
|
|
210
|
+
entries.append((f"epic-{epic_num}-retrospective", "retro", epic_num))
|
|
211
|
+
return entries, warnings
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _make_yaml():
|
|
215
|
+
yaml = YAML(typ="rt")
|
|
216
|
+
yaml.preserve_quotes = True
|
|
217
|
+
# Pin the emitter to the indentation the sprint-status template ships with.
|
|
218
|
+
# Without this, ruamel re-dumps block sequences at its own default offset and
|
|
219
|
+
# every write silently de-indents pre-existing, untouched action_items.
|
|
220
|
+
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
221
|
+
yaml.encoding = "utf-8"
|
|
222
|
+
return yaml
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _load_existing(path):
|
|
226
|
+
yaml = _make_yaml()
|
|
227
|
+
if not Path(path).exists():
|
|
228
|
+
return yaml, None
|
|
229
|
+
try:
|
|
230
|
+
with io.open(path, "r", encoding="utf-8") as fh:
|
|
231
|
+
data = yaml.load(fh)
|
|
232
|
+
except Exception as exc:
|
|
233
|
+
_fail(f"existing status file is not valid YAML: {exc}", status_file=str(path))
|
|
234
|
+
if data is not None and not isinstance(data, dict):
|
|
235
|
+
_fail(
|
|
236
|
+
f"existing status file is valid YAML but not a mapping (got {type(data).__name__})",
|
|
237
|
+
status_file=str(path),
|
|
238
|
+
)
|
|
239
|
+
return yaml, data
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _merge_status(kind, computed, existing_raw, key, warnings, report):
|
|
243
|
+
"""Return the higher-ranked of computed/existing; never downgrade."""
|
|
244
|
+
rank = RANKS[kind]
|
|
245
|
+
if existing_raw is None:
|
|
246
|
+
return computed
|
|
247
|
+
existing, was_legacy = _normalize(existing_raw)
|
|
248
|
+
if was_legacy:
|
|
249
|
+
report["legacy_mapped"].append({"key": key, "from": existing_raw, "to": existing})
|
|
250
|
+
if existing not in rank:
|
|
251
|
+
warnings.append(f"illegal status '{existing_raw}' on '{key}' replaced with '{computed}'")
|
|
252
|
+
report["illegal"].append({"key": key, "status": existing_raw})
|
|
253
|
+
return computed
|
|
254
|
+
return existing if rank[existing] >= rank[computed] else computed
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def build_status(entries, existing_data, stories_dir, warnings):
|
|
258
|
+
"""Return (development_status CommentedMap, merge report dict)."""
|
|
259
|
+
existing_status = {}
|
|
260
|
+
if existing_data is not None:
|
|
261
|
+
existing_status = dict(existing_data.get("development_status") or {})
|
|
262
|
+
report = {
|
|
263
|
+
"new_entries": [],
|
|
264
|
+
"preserved": 0,
|
|
265
|
+
"changed": 0,
|
|
266
|
+
"upgraded_from_disk": [],
|
|
267
|
+
"dropped_orphans": [],
|
|
268
|
+
"legacy_mapped": [],
|
|
269
|
+
"illegal": [],
|
|
270
|
+
}
|
|
271
|
+
# One directory scan instead of a stat() per story.
|
|
272
|
+
story_files = set()
|
|
273
|
+
if stories_dir and Path(stories_dir).is_dir():
|
|
274
|
+
story_files = {p.name for p in Path(stories_dir).glob("*.md")}
|
|
275
|
+
dev = CommentedMap()
|
|
276
|
+
first_epic = True
|
|
277
|
+
for key, kind, _epic_num in entries:
|
|
278
|
+
default = {"epic": "backlog", "story": "backlog", "retro": "optional"}[kind]
|
|
279
|
+
computed = default
|
|
280
|
+
if kind == "story" and f"{key}.md" in story_files:
|
|
281
|
+
computed = "ready-for-dev"
|
|
282
|
+
merged = _merge_status(kind, computed, existing_status.get(key), key, warnings, report)
|
|
283
|
+
if key not in existing_status:
|
|
284
|
+
report["new_entries"].append(key)
|
|
285
|
+
elif merged == _normalize(existing_status[key])[0]:
|
|
286
|
+
report["preserved"] += 1
|
|
287
|
+
else:
|
|
288
|
+
report["changed"] += 1
|
|
289
|
+
if kind == "story" and computed == "ready-for-dev" and existing_status.get(key) in (None, "backlog"):
|
|
290
|
+
report["upgraded_from_disk"].append(key)
|
|
291
|
+
dev[key] = merged
|
|
292
|
+
if kind == "epic" and not first_epic:
|
|
293
|
+
dev.yaml_set_comment_before_after_key(key, before="\n")
|
|
294
|
+
if kind == "epic":
|
|
295
|
+
first_epic = False
|
|
296
|
+
computed_keys = {key for key, _, _ in entries}
|
|
297
|
+
# Old statuses ride along so the LLM can transplant them after a rename —
|
|
298
|
+
# the values would otherwise be destroyed by the write.
|
|
299
|
+
report["dropped_orphans"] = [
|
|
300
|
+
{"key": k, "status": existing_status[k]}
|
|
301
|
+
for k in existing_status
|
|
302
|
+
if k not in computed_keys
|
|
303
|
+
]
|
|
304
|
+
report["in_sync"] = (
|
|
305
|
+
not report["new_entries"]
|
|
306
|
+
and not report["dropped_orphans"]
|
|
307
|
+
and not report["illegal"]
|
|
308
|
+
and not report["legacy_mapped"]
|
|
309
|
+
and report["changed"] == 0
|
|
310
|
+
)
|
|
311
|
+
return dev, report
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _counts(dev):
|
|
315
|
+
counts = {}
|
|
316
|
+
for value in dev.values():
|
|
317
|
+
counts[value] = counts.get(value, 0) + 1
|
|
318
|
+
return counts
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _dump_bytes(yaml, doc):
|
|
322
|
+
"""Serialize before any file is touched, so a dump failure cannot leave a
|
|
323
|
+
partial file anywhere."""
|
|
324
|
+
buf = io.BytesIO()
|
|
325
|
+
yaml.dump(doc, buf)
|
|
326
|
+
return buf.getvalue()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _atomic_write(path, payload, mode=None):
|
|
330
|
+
"""Replace ``path``'s contents with ``payload`` atomically.
|
|
331
|
+
|
|
332
|
+
Temp file alongside the target, fsynced, taking the target's permission
|
|
333
|
+
bits (mkstemp creates 0600, which would silently narrow the file), then
|
|
334
|
+
renamed over it. ``path`` is resolved through symlinks first: renaming onto
|
|
335
|
+
a symlink would detach the link and leave the real file stale.
|
|
336
|
+
"""
|
|
337
|
+
path = os.path.realpath(path)
|
|
338
|
+
directory = os.path.dirname(path) or "."
|
|
339
|
+
os.makedirs(directory, exist_ok=True)
|
|
340
|
+
fd, tmp = tempfile.mkstemp(prefix=".sprint-status-", suffix=".tmp", dir=directory)
|
|
341
|
+
try:
|
|
342
|
+
with os.fdopen(fd, "wb") as fh:
|
|
343
|
+
fh.write(payload)
|
|
344
|
+
fh.flush()
|
|
345
|
+
os.fsync(fh.fileno())
|
|
346
|
+
if mode is not None:
|
|
347
|
+
os.chmod(tmp, mode)
|
|
348
|
+
os.replace(tmp, path)
|
|
349
|
+
except BaseException:
|
|
350
|
+
try:
|
|
351
|
+
os.unlink(tmp)
|
|
352
|
+
except OSError:
|
|
353
|
+
pass
|
|
354
|
+
raise
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _parse_sets(pairs, valid_keys):
|
|
358
|
+
"""Validate --set key=status pairs against the generated plan and vocabulary."""
|
|
359
|
+
parsed = []
|
|
360
|
+
for pair in pairs:
|
|
361
|
+
key, sep, status = pair.partition("=")
|
|
362
|
+
if not sep or not key or not status:
|
|
363
|
+
_fail(f"--set expects key=status, got '{pair}'")
|
|
364
|
+
if key not in valid_keys:
|
|
365
|
+
_fail(f"--set key '{key}' is not in the generated plan", valid_keys=sorted(valid_keys))
|
|
366
|
+
kind, _ = classify_key(key)
|
|
367
|
+
if status not in RANKS[kind]:
|
|
368
|
+
_fail(
|
|
369
|
+
f"--set status '{status}' is not legal for {kind} '{key}'",
|
|
370
|
+
legal=sorted(RANKS[kind]),
|
|
371
|
+
)
|
|
372
|
+
parsed.append((key, status))
|
|
373
|
+
return parsed
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def cmd_generate(args):
|
|
377
|
+
entries, warnings = parse_epics(args.epic_file)
|
|
378
|
+
if not entries:
|
|
379
|
+
_fail("no epics or stories parsed from the given epic files", epic_files=args.epic_file)
|
|
380
|
+
yaml, existing = _load_existing(args.status_file)
|
|
381
|
+
status_path = Path(args.status_file)
|
|
382
|
+
original_bytes = status_path.read_bytes() if status_path.exists() else None
|
|
383
|
+
original_mode = (os.stat(status_path).st_mode & 0o777) if status_path.exists() else None
|
|
384
|
+
|
|
385
|
+
merge_source = None if args.fresh else existing
|
|
386
|
+
dev, report = build_status(entries, merge_source, args.stories_dir, warnings)
|
|
387
|
+
|
|
388
|
+
# Explicit, user-confirmed statuses (the fix flow). Applied last: repair is
|
|
389
|
+
# the one path allowed to downgrade.
|
|
390
|
+
explicit = _parse_sets(args.set or [], set(dev.keys()))
|
|
391
|
+
for key, status in explicit:
|
|
392
|
+
dev[key] = status
|
|
393
|
+
report["explicit_set"] = [f"{k}={s}" for k, s in explicit]
|
|
394
|
+
|
|
395
|
+
def _meta(field, arg_value, default):
|
|
396
|
+
if arg_value is not None:
|
|
397
|
+
return arg_value
|
|
398
|
+
if existing is not None and existing.get(field):
|
|
399
|
+
return str(existing[field])
|
|
400
|
+
return default
|
|
401
|
+
|
|
402
|
+
generated = args.date
|
|
403
|
+
if existing is not None and existing.get("generated"):
|
|
404
|
+
generated = str(existing["generated"])
|
|
405
|
+
|
|
406
|
+
if existing is not None and not args.fresh:
|
|
407
|
+
# Round-trip the existing document: unknown top-level keys and their
|
|
408
|
+
# comments survive; only the managed fields and development_status are
|
|
409
|
+
# replaced.
|
|
410
|
+
doc = existing
|
|
411
|
+
else:
|
|
412
|
+
doc = CommentedMap()
|
|
413
|
+
doc.yaml_set_start_comment(HEADER_COMMENT)
|
|
414
|
+
doc["generated"] = generated
|
|
415
|
+
doc["last_updated"] = args.date
|
|
416
|
+
doc["project"] = args.project
|
|
417
|
+
doc["project_key"] = _meta("project_key", args.project_key, "NOKEY")
|
|
418
|
+
doc["tracking_system"] = _meta("tracking_system", args.tracking_system, "file-system")
|
|
419
|
+
doc["story_location"] = _meta("story_location", args.story_location, args.stories_dir)
|
|
420
|
+
doc["development_status"] = dev
|
|
421
|
+
if "action_items" not in doc and existing is not None and existing.get("action_items") is not None:
|
|
422
|
+
doc["action_items"] = existing["action_items"]
|
|
423
|
+
doc.yaml_set_comment_before_after_key(
|
|
424
|
+
"action_items",
|
|
425
|
+
before="\nAction items committed during retrospectives (section created by the retrospective workflow)",
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
result = {
|
|
429
|
+
"ok": True,
|
|
430
|
+
"action": "generate",
|
|
431
|
+
"status_file": str(args.status_file),
|
|
432
|
+
"dry_run": bool(args.dry_run),
|
|
433
|
+
"fresh": bool(args.fresh),
|
|
434
|
+
"epics": sum(1 for _, kind, _ in entries if kind == "epic"),
|
|
435
|
+
"stories": sum(1 for _, kind, _ in entries if kind == "story"),
|
|
436
|
+
"counts": _counts(dev),
|
|
437
|
+
"generated": generated,
|
|
438
|
+
"last_updated": args.date,
|
|
439
|
+
"warnings": warnings,
|
|
440
|
+
**report,
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if args.dry_run:
|
|
444
|
+
print(json.dumps(result, default=str))
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
payload = _dump_bytes(yaml, doc)
|
|
449
|
+
_atomic_write(args.status_file, payload, original_mode)
|
|
450
|
+
verify_yaml = _make_yaml()
|
|
451
|
+
with io.open(args.status_file, "r", encoding="utf-8") as fh:
|
|
452
|
+
reread = verify_yaml.load(fh)
|
|
453
|
+
if dict(reread.get("development_status") or {}) != {k: v for k, v in dev.items()}:
|
|
454
|
+
raise ValueError("development_status mismatch after write")
|
|
455
|
+
for field in ("generated", "last_updated", "project"):
|
|
456
|
+
if str(reread.get(field)) != str(doc[field]):
|
|
457
|
+
raise ValueError(f"{field} mismatch after write")
|
|
458
|
+
except Exception as exc:
|
|
459
|
+
if original_bytes is not None:
|
|
460
|
+
try:
|
|
461
|
+
_atomic_write(args.status_file, original_bytes, original_mode)
|
|
462
|
+
restored = True
|
|
463
|
+
except Exception:
|
|
464
|
+
restored = False
|
|
465
|
+
else:
|
|
466
|
+
Path(args.status_file).unlink(missing_ok=True)
|
|
467
|
+
restored = True
|
|
468
|
+
_fail(f"write or validation failed, original {'restored' if restored else 'NOT restored'}: {exc}",
|
|
469
|
+
restored=restored)
|
|
470
|
+
print(json.dumps(result, default=str))
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _parse_stamp(value):
|
|
474
|
+
from datetime import datetime
|
|
475
|
+
|
|
476
|
+
for fmt in STAMP_FORMATS:
|
|
477
|
+
try:
|
|
478
|
+
return datetime.strptime(str(value), fmt)
|
|
479
|
+
except ValueError:
|
|
480
|
+
continue
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def cmd_status(args):
|
|
485
|
+
from datetime import timedelta
|
|
486
|
+
|
|
487
|
+
_, data = _load_existing(args.status_file)
|
|
488
|
+
if data is None:
|
|
489
|
+
_fail("status file does not exist — run sprint planning to generate it",
|
|
490
|
+
status_file=str(args.status_file))
|
|
491
|
+
dev = dict(data.get("development_status") or {})
|
|
492
|
+
if not dev:
|
|
493
|
+
_fail("development_status missing or empty — re-run sprint planning",
|
|
494
|
+
status_file=str(args.status_file))
|
|
495
|
+
|
|
496
|
+
warnings = []
|
|
497
|
+
counts = {"story": {}, "epic": {}, "retro": {}}
|
|
498
|
+
by_status = {}
|
|
499
|
+
legacy_mapped, illegal, unrecognized = [], [], []
|
|
500
|
+
epic_nums, story_epic_nums = set(), set()
|
|
501
|
+
epic_status, retro_status = {}, {}
|
|
502
|
+
for key, raw in dev.items():
|
|
503
|
+
key = str(key)
|
|
504
|
+
parsed = classify_key(key)
|
|
505
|
+
if parsed is None:
|
|
506
|
+
unrecognized.append({"key": key, "status": raw})
|
|
507
|
+
continue
|
|
508
|
+
kind, epic_num = parsed
|
|
509
|
+
status, was_legacy = _normalize(raw)
|
|
510
|
+
if was_legacy:
|
|
511
|
+
legacy_mapped.append({"key": key, "from": raw, "to": status})
|
|
512
|
+
if status not in RANKS[kind]:
|
|
513
|
+
illegal.append({"key": key, "status": raw})
|
|
514
|
+
continue
|
|
515
|
+
counts[kind][status] = counts[kind].get(status, 0) + 1
|
|
516
|
+
if kind == "story":
|
|
517
|
+
by_status.setdefault(status, []).append(key)
|
|
518
|
+
story_epic_nums.add(epic_num)
|
|
519
|
+
elif kind == "epic":
|
|
520
|
+
epic_nums.add(epic_num)
|
|
521
|
+
epic_status[epic_num] = status
|
|
522
|
+
else:
|
|
523
|
+
retro_status[epic_num] = status
|
|
524
|
+
for stories in by_status.values():
|
|
525
|
+
stories.sort(key=_story_sort_key)
|
|
526
|
+
|
|
527
|
+
action_items = data.get("action_items") or []
|
|
528
|
+
open_items = []
|
|
529
|
+
for i, item in enumerate(action_items):
|
|
530
|
+
if not isinstance(item, dict):
|
|
531
|
+
warnings.append(f"action_items[{i}] is not a mapping and was skipped: {item!r}")
|
|
532
|
+
continue
|
|
533
|
+
status = item.get("status")
|
|
534
|
+
if status not in ACTION_STATUSES:
|
|
535
|
+
warnings.append(f"action_items[{i}] has a missing or unknown status ({status!r})")
|
|
536
|
+
continue
|
|
537
|
+
if status in ("open", "in-progress"):
|
|
538
|
+
open_items.append({k: item.get(k) for k in ("epic", "action", "owner", "status")})
|
|
539
|
+
|
|
540
|
+
risks = []
|
|
541
|
+
stamp = data.get("last_updated") or data.get("generated")
|
|
542
|
+
if args.date and stamp:
|
|
543
|
+
now, then = _parse_stamp(args.date), _parse_stamp(stamp)
|
|
544
|
+
if now is None or then is None:
|
|
545
|
+
warnings.append(
|
|
546
|
+
f"timestamp format not recognized (--date {args.date!r}, file {stamp!r}); "
|
|
547
|
+
"staleness check skipped"
|
|
548
|
+
)
|
|
549
|
+
elif now - then > timedelta(days=args.stale_days):
|
|
550
|
+
risks.append(f"sprint-status.yaml may be stale (last updated {stamp})")
|
|
551
|
+
for stories in by_status.values():
|
|
552
|
+
for key in stories:
|
|
553
|
+
num = classify_key(key)[1]
|
|
554
|
+
if num not in epic_nums:
|
|
555
|
+
risks.append(f"orphaned story '{key}' has no epic-{num} entry")
|
|
556
|
+
for num, status in epic_status.items():
|
|
557
|
+
if status == "in-progress" and num not in story_epic_nums:
|
|
558
|
+
risks.append(f"in-progress epic 'epic-{num}' has no stories")
|
|
559
|
+
if by_status.get("review"):
|
|
560
|
+
risks.append(f"{len(by_status['review'])} story(ies) in review — run wizz-code-review")
|
|
561
|
+
if unrecognized:
|
|
562
|
+
risks.append(f"{len(unrecognized)} unrecognized key(s) in development_status — run validate")
|
|
563
|
+
|
|
564
|
+
recommendation = None
|
|
565
|
+
if by_status.get("in-progress"):
|
|
566
|
+
recommendation = {"skill": "wizz-quick-dev", "story_key": by_status["in-progress"][0],
|
|
567
|
+
"reason": "resume the in-progress story"}
|
|
568
|
+
elif by_status.get("review"):
|
|
569
|
+
recommendation = {"skill": "wizz-code-review", "story_key": by_status["review"][0],
|
|
570
|
+
"reason": "review the completed implementation"}
|
|
571
|
+
elif by_status.get("ready-for-dev"):
|
|
572
|
+
recommendation = {"skill": "wizz-quick-dev", "story_key": by_status["ready-for-dev"][0],
|
|
573
|
+
"reason": "start the next ready story"}
|
|
574
|
+
elif by_status.get("backlog"):
|
|
575
|
+
recommendation = {"skill": "wizz-quick-dev", "story_key": by_status["backlog"][0],
|
|
576
|
+
"reason": "start the first backlog story"}
|
|
577
|
+
else:
|
|
578
|
+
optional_retros = sorted(num for num, status in retro_status.items() if status == "optional")
|
|
579
|
+
if optional_retros:
|
|
580
|
+
recommendation = {"skill": "wizz-retrospective", "story_key": None,
|
|
581
|
+
"reason": f"all stories done — epic-{optional_retros[0]}-retrospective is still open"}
|
|
582
|
+
|
|
583
|
+
print(json.dumps({
|
|
584
|
+
"ok": True, "action": "status", "status_file": str(args.status_file),
|
|
585
|
+
"project": data.get("project"), "project_key": data.get("project_key"),
|
|
586
|
+
"tracking_system": data.get("tracking_system"),
|
|
587
|
+
"generated": data.get("generated"), "last_updated": data.get("last_updated"),
|
|
588
|
+
"stories": counts["story"], "epics": counts["epic"], "retrospectives": counts["retro"],
|
|
589
|
+
"legacy_mapped": legacy_mapped, "illegal": illegal, "unrecognized": unrecognized,
|
|
590
|
+
"open_action_items": open_items, "risks": risks, "warnings": warnings,
|
|
591
|
+
"recommendation": recommendation,
|
|
592
|
+
"all_done": recommendation is None,
|
|
593
|
+
}, default=str))
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def cmd_validate(args):
|
|
597
|
+
problems = []
|
|
598
|
+
legacy_mapped = []
|
|
599
|
+
path = Path(args.status_file)
|
|
600
|
+
if not path.exists():
|
|
601
|
+
print(json.dumps({
|
|
602
|
+
"ok": True, "action": "validate", "status_file": str(args.status_file),
|
|
603
|
+
"valid": False, "problems": ["status file does not exist"], "legacy_mapped": [],
|
|
604
|
+
}))
|
|
605
|
+
return
|
|
606
|
+
yaml = _make_yaml()
|
|
607
|
+
try:
|
|
608
|
+
with io.open(args.status_file, "r", encoding="utf-8") as fh:
|
|
609
|
+
data = yaml.load(fh)
|
|
610
|
+
except Exception as exc:
|
|
611
|
+
print(json.dumps({
|
|
612
|
+
"ok": True, "action": "validate", "status_file": str(args.status_file),
|
|
613
|
+
"valid": False, "problems": [f"not valid YAML: {exc}"], "legacy_mapped": [],
|
|
614
|
+
}, default=str))
|
|
615
|
+
return
|
|
616
|
+
if not isinstance(data, dict):
|
|
617
|
+
problems.append(f"top level is not a mapping (got {type(data).__name__})")
|
|
618
|
+
else:
|
|
619
|
+
for field in ("generated", "last_updated", "project", "development_status"):
|
|
620
|
+
if data.get(field) is None:
|
|
621
|
+
problems.append(f"missing required key '{field}'")
|
|
622
|
+
for field in ("generated", "last_updated"):
|
|
623
|
+
value = data.get(field)
|
|
624
|
+
if value is not None and _parse_stamp(value) is None:
|
|
625
|
+
problems.append(f"'{field}' timestamp {str(value)!r} does not match '{DATE_FORMAT}'")
|
|
626
|
+
dev = data.get("development_status")
|
|
627
|
+
if dev is not None and not isinstance(dev, dict):
|
|
628
|
+
problems.append("development_status is not a mapping")
|
|
629
|
+
elif dev:
|
|
630
|
+
for key, raw in dev.items():
|
|
631
|
+
parsed = classify_key(str(key))
|
|
632
|
+
if parsed is None:
|
|
633
|
+
problems.append(f"unrecognized key '{key}' (expected epic-N, N-M-slug, or epic-N-retrospective)")
|
|
634
|
+
continue
|
|
635
|
+
kind, _ = parsed
|
|
636
|
+
status, was_legacy = _normalize(raw)
|
|
637
|
+
if was_legacy:
|
|
638
|
+
legacy_mapped.append({"key": str(key), "from": raw, "to": status})
|
|
639
|
+
if status not in RANKS[kind]:
|
|
640
|
+
problems.append(f"illegal {kind} status {str(raw)!r} on '{key}'")
|
|
641
|
+
elif isinstance(data.get("development_status"), dict):
|
|
642
|
+
problems.append("development_status is empty")
|
|
643
|
+
items = data.get("action_items")
|
|
644
|
+
if items is not None:
|
|
645
|
+
if not isinstance(items, list):
|
|
646
|
+
problems.append("action_items is not a list")
|
|
647
|
+
else:
|
|
648
|
+
for i, item in enumerate(items):
|
|
649
|
+
if not isinstance(item, dict):
|
|
650
|
+
problems.append(f"action_items[{i}] is not a mapping")
|
|
651
|
+
elif item.get("status") not in ACTION_STATUSES:
|
|
652
|
+
problems.append(
|
|
653
|
+
f"action_items[{i}] has a missing or unknown status ({item.get('status')!r})"
|
|
654
|
+
)
|
|
655
|
+
print(json.dumps({
|
|
656
|
+
"ok": True, "action": "validate", "status_file": str(args.status_file),
|
|
657
|
+
"valid": not problems, "problems": problems, "legacy_mapped": legacy_mapped,
|
|
658
|
+
}, default=str))
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def build_parser():
|
|
662
|
+
parser = JsonArgumentParser(prog="sprint_plan.py", add_help=False)
|
|
663
|
+
sub = parser.add_subparsers(dest="command", required=True, parser_class=JsonArgumentParser)
|
|
664
|
+
|
|
665
|
+
gen = sub.add_parser("generate", add_help=False)
|
|
666
|
+
gen.add_argument("--epic-file", action="append", required=True)
|
|
667
|
+
gen.add_argument("--status-file", required=True)
|
|
668
|
+
gen.add_argument("--stories-dir", required=True)
|
|
669
|
+
gen.add_argument("--project", required=True)
|
|
670
|
+
gen.add_argument("--date", required=True)
|
|
671
|
+
gen.add_argument("--project-key", default=None)
|
|
672
|
+
gen.add_argument("--tracking-system", default=None)
|
|
673
|
+
gen.add_argument("--story-location", default=None)
|
|
674
|
+
gen.add_argument("--dry-run", action="store_true")
|
|
675
|
+
gen.add_argument("--fresh", action="store_true")
|
|
676
|
+
gen.add_argument("--set", action="append", metavar="KEY=STATUS")
|
|
677
|
+
gen.set_defaults(func=cmd_generate)
|
|
678
|
+
|
|
679
|
+
st = sub.add_parser("status", add_help=False)
|
|
680
|
+
st.add_argument("--status-file", required=True)
|
|
681
|
+
st.add_argument("--date", default=None)
|
|
682
|
+
st.add_argument("--stale-days", type=int, default=STALE_DAYS_DEFAULT)
|
|
683
|
+
st.set_defaults(func=cmd_status)
|
|
684
|
+
|
|
685
|
+
val = sub.add_parser("validate", add_help=False)
|
|
686
|
+
val.add_argument("--status-file", required=True)
|
|
687
|
+
val.set_defaults(func=cmd_validate)
|
|
688
|
+
return parser
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def main(argv=None):
|
|
692
|
+
args = build_parser().parse_args(argv)
|
|
693
|
+
args.func(args)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
if __name__ == "__main__":
|
|
697
|
+
main()
|