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.
Files changed (148) hide show
  1. package/package.json +3 -2
  2. package/skills-registry.yaml +4 -0
  3. package/src/bmm-skills/3-solutioning/wizz-architecture/scripts/__pycache__/lint_spine.cpython-313.pyc +0 -0
  4. package/src/bmm-skills/3-solutioning/wizz-architecture/scripts/tests/__pycache__/test_lint_spine.cpython-313-pytest-9.1.1.pyc +0 -0
  5. package/src/bmm-skills/4-implementation/wizz-code-review/SKILL.md +3 -5
  6. package/src/bmm-skills/4-implementation/wizz-code-review/customize.toml +71 -0
  7. package/src/bmm-skills/4-implementation/wizz-code-review/references/claims-check.md +14 -0
  8. package/src/bmm-skills/4-implementation/wizz-code-review/references/deletion-check.md +14 -0
  9. package/src/bmm-skills/4-implementation/wizz-code-review/review-prompts/edge-case-hunter.md +110 -0
  10. package/src/bmm-skills/4-implementation/wizz-code-review/review-prompts/verification-gap.md +113 -0
  11. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-01-gather-context.md +32 -23
  12. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-02-review.md +10 -13
  13. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-03-triage.md +16 -22
  14. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-04-present.md +4 -2
  15. package/src/bmm-skills/4-implementation/wizz-quick-dev/customize.toml +76 -0
  16. package/src/bmm-skills/4-implementation/wizz-quick-dev/references/claims-check.md +14 -0
  17. package/src/bmm-skills/4-implementation/wizz-quick-dev/references/deletion-check.md +14 -0
  18. package/src/bmm-skills/4-implementation/wizz-quick-dev/review-prompts/edge-case-hunter.md +110 -0
  19. package/src/bmm-skills/4-implementation/wizz-quick-dev/review-prompts/verification-gap.md +113 -0
  20. package/src/bmm-skills/4-implementation/wizz-quick-dev/step-04-review.md +38 -16
  21. package/src/bmm-skills/4-implementation/wizz-retrospective/SKILL.md +56 -1488
  22. package/src/bmm-skills/4-implementation/wizz-retrospective/customize.toml +3 -5
  23. package/src/bmm-skills/4-implementation/wizz-retrospective/references/acceptance-verdict.md +55 -0
  24. package/src/bmm-skills/4-implementation/wizz-retrospective/references/aggregate-views.md +17 -0
  25. package/src/bmm-skills/4-implementation/wizz-retrospective/references/evidence-gathering.md +30 -0
  26. package/src/bmm-skills/4-implementation/wizz-retrospective/references/retro-document.md +84 -0
  27. package/src/bmm-skills/4-implementation/wizz-retrospective/references/team-discussion.md +22 -0
  28. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/__pycache__/sprint_status.cpython-313.pyc +0 -0
  29. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/git_evidence.py +304 -0
  30. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/sprint_status.py +746 -0
  31. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/__pycache__/test_git_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
  32. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/__pycache__/test_sprint_status.cpython-313-pytest-9.1.1.pyc +0 -0
  33. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/fixtures/sprint-status-template.yaml +71 -0
  34. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/test_git_evidence.py +750 -0
  35. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/test_sprint_status.py +1579 -0
  36. package/src/bmm-skills/4-implementation/wizz-sprint-planning/SKILL.md +39 -296
  37. package/src/bmm-skills/4-implementation/wizz-sprint-planning/customize.toml +1 -3
  38. package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/fix-sprint-status.md +30 -0
  39. package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/generate-tracking.md +25 -0
  40. package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/readiness-gate.md +20 -0
  41. package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/status-view.md +14 -0
  42. package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/validate.md +10 -0
  43. package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/__pycache__/sprint_plan.cpython-313.pyc +0 -0
  44. package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/sprint_plan.py +697 -0
  45. package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/tests/__pycache__/test_sprint_plan.cpython-313-pytest-9.1.1.pyc +0 -0
  46. package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/tests/test_sprint_plan.py +524 -0
  47. package/src/bmm-skills/4-implementation/wizz-sprint-planning/sprint-status-template.yaml +9 -7
  48. package/src/bmm-skills/module-help.csv +3 -3
  49. package/src/core-skills/_shared/handoff-protocol.md +7 -8
  50. package/src/core-skills/module-help.csv +1 -0
  51. package/src/core-skills/wizz-advanced-elicitation/SKILL.md +36 -114
  52. package/src/core-skills/wizz-advanced-elicitation/{methods.csv → assets/methods.csv} +29 -27
  53. package/src/core-skills/wizz-advanced-elicitation/customize.toml +54 -0
  54. package/src/core-skills/wizz-advanced-elicitation/scripts/__pycache__/pick_methods.cpython-313.pyc +0 -0
  55. package/src/core-skills/wizz-advanced-elicitation/scripts/pick_methods.py +233 -0
  56. package/src/core-skills/wizz-advanced-elicitation/scripts/tests/__pycache__/test_pick_methods.cpython-313-pytest-9.1.1.pyc +0 -0
  57. package/src/core-skills/wizz-advanced-elicitation/scripts/tests/test_pick_methods.py +228 -0
  58. package/src/core-skills/wizz-brainstorming/SKILL.md +2 -2
  59. package/src/core-skills/wizz-brainstorming/assets/brain-selector.html +2 -0
  60. package/src/core-skills/wizz-brainstorming/references/converge.md +1 -1
  61. package/src/core-skills/wizz-brainstorming/references/finalize.md +1 -1
  62. package/src/core-skills/wizz-brainstorming/references/headless.md +1 -1
  63. package/src/core-skills/wizz-brainstorming/references/mode-autonomous.md +1 -1
  64. package/src/core-skills/wizz-brainstorming/scripts/__pycache__/brain.cpython-313.pyc +0 -0
  65. package/src/core-skills/wizz-brainstorming/scripts/brain.py +36 -6
  66. package/src/core-skills/wizz-brainstorming/scripts/tests/__pycache__/test_brain.cpython-313-pytest-9.1.1.pyc +0 -0
  67. package/src/core-skills/wizz-brainstorming/scripts/tests/__pycache__/test_brain.cpython-314.pyc +0 -0
  68. package/src/core-skills/wizz-brainstorming/scripts/tests/test_brain.py +24 -2
  69. package/src/core-skills/wizz-customize/scripts/__pycache__/list_customizable_skills.cpython-313.pyc +0 -0
  70. package/src/core-skills/wizz-customize/scripts/tests/__pycache__/test_list_customizable_skills.cpython-313-pytest-9.1.1.pyc +0 -0
  71. package/src/core-skills/wizz-forge-idea/SKILL.md +107 -0
  72. package/src/core-skills/wizz-forge-idea/customize.toml +41 -0
  73. package/src/core-skills/wizz-forge-idea/scripts/__pycache__/resolve_personas.cpython-313.pyc +0 -0
  74. package/src/core-skills/wizz-forge-idea/scripts/__pycache__/resolve_personas.cpython-314.pyc +0 -0
  75. package/src/core-skills/wizz-forge-idea/scripts/resolve_personas.py +275 -0
  76. package/src/core-skills/wizz-forge-idea/scripts/tests/__pycache__/test_resolve_personas.cpython-313-pytest-9.1.1.pyc +0 -0
  77. package/src/core-skills/wizz-forge-idea/scripts/tests/__pycache__/test_resolve_personas.cpython-314.pyc +0 -0
  78. package/src/core-skills/wizz-forge-idea/scripts/tests/test_resolve_personas.py +138 -0
  79. package/src/core-skills/wizz-party-mode/SKILL.md +37 -54
  80. package/src/core-skills/wizz-party-mode/customize.toml +61 -2
  81. package/src/core-skills/wizz-party-mode/references/create-party.md +8 -3
  82. package/src/core-skills/wizz-party-mode/references/mode-agent-team.md +3 -1
  83. package/src/core-skills/wizz-party-mode/references/mode-subagent.md +16 -4
  84. package/src/core-skills/wizz-party-mode/references/party-memory.md +51 -0
  85. package/src/core-skills/wizz-party-mode/scripts/__pycache__/resolve_party.cpython-313.pyc +0 -0
  86. package/src/core-skills/wizz-party-mode/scripts/__pycache__/resolve_party.cpython-314.pyc +0 -0
  87. package/src/core-skills/wizz-party-mode/scripts/resolve_party.py +22 -7
  88. package/src/core-skills/wizz-party-mode/scripts/tests/__pycache__/test_resolve_party.cpython-313-pytest-9.1.1.pyc +0 -0
  89. package/src/core-skills/wizz-party-mode/scripts/tests/__pycache__/test_resolve_party.cpython-314.pyc +0 -0
  90. package/src/core-skills/wizz-party-mode/scripts/tests/{test-resolve_party.py → test_resolve_party.py} +8 -0
  91. package/src/core-skills/wizz-review-edge-case-hunter/SKILL.md +17 -3
  92. package/src/modules/wizz/README.md +1 -1
  93. package/src/modules/wizz/_shared/model-ladder.md +22 -0
  94. package/src/modules/wizz/_shared/token-economy.md +6 -2
  95. package/src/modules/wizz/agents/wizz-ads/customize.toml +1 -1
  96. package/src/modules/wizz/agents/wizz-copy/customize.toml +1 -1
  97. package/src/modules/wizz/agents/wizz-designer/customize.toml +1 -1
  98. package/src/modules/wizz/agents/wizz-growth/customize.toml +11 -1
  99. package/src/modules/wizz/agents/wizz-maestro/customize.toml +1 -1
  100. package/src/modules/wizz/agents/wizz-memoria/customize.toml +1 -1
  101. package/src/modules/wizz/agents/wizz-qa/customize.toml +1 -1
  102. package/src/modules/wizz/agents/wizz-seo/customize.toml +1 -1
  103. package/src/modules/wizz/agents/wizz-social/customize.toml +1 -1
  104. package/src/modules/wizz/overrides/wizz-agent-analyst.toml +1 -1
  105. package/src/modules/wizz/overrides/wizz-agent-architect.toml +1 -1
  106. package/src/modules/wizz/overrides/wizz-agent-dev.toml +1 -1
  107. package/src/modules/wizz/overrides/wizz-agent-pm.toml +1 -1
  108. package/src/modules/wizz/overrides/wizz-agent-tech-writer.toml +1 -1
  109. package/src/modules/wizz/overrides/wizz-agent-ux-designer.toml +1 -1
  110. package/src/modules/wizz/subagents/codex/wizz-exec-haiku.toml +16 -0
  111. package/src/modules/wizz/subagents/codex/wizz-exec-opus.toml +18 -0
  112. package/src/modules/wizz/subagents/codex/wizz-exec-review.toml +17 -0
  113. package/src/modules/wizz/subagents/codex/wizz-exec-sonnet.toml +17 -0
  114. package/src/modules/wizz/subagents/gemini/wizz-exec-haiku.md +15 -0
  115. package/src/modules/wizz/subagents/gemini/wizz-exec-opus.md +17 -0
  116. package/src/modules/wizz/subagents/gemini/wizz-exec-review.md +15 -0
  117. package/src/modules/wizz/subagents/gemini/wizz-exec-sonnet.md +16 -0
  118. package/src/modules/wizz/subagents/opencode/wizz-exec-haiku.md +14 -0
  119. package/src/modules/wizz/subagents/opencode/wizz-exec-opus.md +16 -0
  120. package/src/modules/wizz/subagents/opencode/wizz-exec-review.md +16 -0
  121. package/src/modules/wizz/subagents/opencode/wizz-exec-sonnet.md +15 -0
  122. package/src/modules/wizz/subagents/wizz-exec-opus.md +16 -0
  123. package/src/modules/wizz/subagents/wizz-exec-review.md +15 -0
  124. package/src/scripts/__pycache__/memlog.cpython-313.pyc +0 -0
  125. package/src/scripts/tests/__pycache__/test_memlog.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/src/scripts/tests/__pycache__/test_resolve_customization.cpython-313-pytest-9.1.1.pyc +0 -0
  127. package/src/skills-lib/pesquisa-de-publico-do-piva/README.md +83 -0
  128. package/src/skills-lib/pesquisa-de-publico-do-piva/SKILL.md +65 -0
  129. package/src/skills-lib/pesquisa-de-publico-do-piva/assets/template.html +414 -0
  130. package/src/skills-lib/pesquisa-de-publico-do-piva/prompts.md +131 -0
  131. package/src/skills-lib/wizz-offer-forge/README.md +13 -0
  132. package/src/skills-lib/wizz-offer-forge/SKILL.md +74 -0
  133. package/src/skills-lib/wizz-offer-forge/references/empilhamento-valor.md +25 -0
  134. package/src/skills-lib/wizz-offer-forge/references/garantia-reversa.md +22 -0
  135. package/src/skills-lib/wizz-offer-forge/references/mecanismo-unico.md +28 -0
  136. package/src/skills-lib/wizz-offer-forge/references/ponte-trafego.md +21 -0
  137. package/src/skills-lib/wizz-router/references/routing-table-flat.md +2 -0
  138. package/tools/installer/core/installer.js +57 -0
  139. package/tools/installer/ide/_config-driven.js +44 -30
  140. package/tools/installer/ide/platform-codes.yaml +42 -3
  141. package/tools/installer/modules/external-manager.js +140 -2
  142. package/tools/installer/modules/official-modules.js +68 -21
  143. package/tools/installer/prompts.js +45 -103
  144. package/tools/installer/ui.js +64 -7
  145. package/wizz-modules.yaml +65 -11
  146. package/src/bmm-skills/4-implementation/wizz-sprint-planning/checklist.md +0 -34
  147. package/src/core-skills/wizz-brainstorming/scripts/memlog.py +0 -202
  148. 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()