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,746 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = ["ruamel.yaml>=0.18"]
4
+ # ///
5
+ """Detect the current retrospective epic and surgically update sprint-status.yaml.
6
+
7
+ Prints ONLY JSON to stdout. Errors are emitted as JSON to stdout with a non-zero
8
+ exit code. The ``update`` subcommand round-trips the YAML to preserve all comments
9
+ and formatting, writes atomically (temp file + ``os.replace``), and restores the
10
+ original file bytes on any validation failure.
11
+ """
12
+
13
+ import argparse
14
+ import hashlib
15
+ import io
16
+ import json
17
+ import os
18
+ import re
19
+ import stat
20
+ import sys
21
+ import tempfile
22
+ from collections import Counter
23
+ from collections.abc import Mapping
24
+ from datetime import datetime
25
+
26
+ from ruamel.yaml import YAML
27
+ from ruamel.yaml.scalarstring import DoubleQuotedScalarString
28
+
29
+ STORY_RE = re.compile(r"^(\d+)-\d+[a-z]?-") # trailing [a-z]? matches split-story keys like 2-6a-...
30
+ DATE_FORMAT = "%m-%d-%Y %H:%M"
31
+ # The authoritative action-item vocabulary, mirrored from wizz-sprint-planning's
32
+ # SKILL.md. Anything outside it would render as unknown in the status dashboard.
33
+ ACTION_STATUSES = ("open", "in-progress", "done")
34
+ # The retro-document frontmatter vocabulary. --verdict is only echoed back, but
35
+ # orchestrators branch on the echo, so a free-spelled value ("accepted with open
36
+ # items") would silently fall through every branch they write.
37
+ VERDICTS = ("accepted", "accepted-with-open-items", "rejected")
38
+
39
+
40
+ def _load_yaml(path):
41
+ yaml = YAML(typ="rt")
42
+ yaml.preserve_quotes = True
43
+ # Pin the emitter to the indentation the sprint-status template ships with.
44
+ # Without this, ruamel re-dumps block sequences at its own default offset and
45
+ # every write silently de-indents pre-existing, untouched action_items.
46
+ yaml.indent(mapping=2, sequence=4, offset=2)
47
+ # Pin the dump encoding too: `_dump_bytes` serializes into a BytesIO, so the
48
+ # emitter -- not this module -- encodes the bytes that land in the user's
49
+ # file. utf-8 is ruamel's current default, but the file is read back as
50
+ # utf-8 unconditionally, so state it rather than inherit it.
51
+ yaml.encoding = "utf-8"
52
+ with open(path, "r", encoding="utf-8") as fh:
53
+ data = yaml.load(fh)
54
+ return yaml, data
55
+
56
+
57
+ def _emit(obj, code=0):
58
+ sys.stdout.write(json.dumps(obj))
59
+ sys.exit(code)
60
+
61
+
62
+ def _emit_error(message, code=1, restored=None):
63
+ """Emit a failure on the JSON-only contract.
64
+
65
+ ``restored`` is included only when the caller can speak to the state of the
66
+ target file; ``retro-document.md`` teaches callers to read it, so a write-path
67
+ failure must never omit it and a read-only subcommand must never invent it.
68
+ """
69
+ payload = {"ok": False, "error": message}
70
+ if restored is not None:
71
+ payload["restored"] = restored
72
+ _emit(payload, code)
73
+
74
+
75
+ class JsonArgumentParser(argparse.ArgumentParser):
76
+ """Emit argparse failures on the JSON-only stdout contract, not usage text.
77
+
78
+ Every parser built from this class is constructed with ``add_help=False``.
79
+ The override below covers ``error()``, but ``-h`` never reaches it: the
80
+ built-in help action calls ``print_help()`` and ``exit(0)`` directly, which
81
+ would put plain usage text on stdout with a zero exit and break the
82
+ JSON-only contract for the machine consumer this script exists to serve.
83
+ Removing the action instead of intercepting it keeps the fix to one keyword
84
+ per parser and routes ``-h`` through the already-tested ``error()`` path as
85
+ an ordinary unrecognized argument. The cost is that the ``help=`` strings
86
+ are unreachable from the CLI; the skill's references carry the usage a
87
+ human needs.
88
+ """
89
+
90
+ def error(self, message):
91
+ _emit({"ok": False, "error": f"argument error: {message}"}, 2)
92
+
93
+
94
+ def _slugify(text, maxlen=40):
95
+ text = str(text)
96
+ # Unicode-aware: a non-Latin action must keep its own characters in the id
97
+ # rather than collapsing to a single placeholder shared by every item.
98
+ slug = re.sub(r"[^\w]+", "-", text.lower(), flags=re.UNICODE).strip("-")
99
+ slug = slug[:maxlen].strip("-")
100
+ if not slug:
101
+ # Nothing sluggable (punctuation/emoji only): a short content hash keeps
102
+ # the id deterministic and distinct instead of a bare "item".
103
+ slug = hashlib.sha256(text.encode("utf-8")).hexdigest()[:8]
104
+ return slug
105
+
106
+
107
+ def _selector_label(entry):
108
+ """Human-readable form of a --set-action-status selector, for error text.
109
+
110
+ An entry carrying both forms is described by its ``id``, because that is the
111
+ form resolution actually uses.
112
+ """
113
+ if isinstance(entry.get("id"), str):
114
+ return f"id={entry['id']!r}"
115
+ return f"epic={entry.get('epic')!r} action={entry.get('action')!r}"
116
+
117
+
118
+ def _match_action_items(entry, items):
119
+ """Indices in ``items`` that the selector ``entry`` resolves to.
120
+
121
+ ``id`` wins whenever it is present: the epic/action pair is the fallback for
122
+ legacy items written before ids existed, so a caller that copied a whole item
123
+ through gets the precise match rather than a text comparison. Matching is
124
+ exact equality -- no normalization -- so a file that spells its epic as a
125
+ string simply does not match and the caller gets a "no match" error instead of
126
+ a silent write to the wrong item. ``bool`` is excluded on the file side too,
127
+ since ``True == 1`` in Python.
128
+ """
129
+ item_id = entry.get("id")
130
+ if isinstance(item_id, str):
131
+ return [
132
+ idx
133
+ for idx, item in enumerate(items)
134
+ if isinstance(item, Mapping) and item.get("id") == item_id
135
+ ]
136
+ epic_value = entry.get("epic")
137
+ action_value = entry.get("action")
138
+ return [
139
+ idx
140
+ for idx, item in enumerate(items)
141
+ if isinstance(item, Mapping)
142
+ and not isinstance(item.get("epic"), bool)
143
+ and item.get("epic") == epic_value
144
+ and item.get("action") == action_value
145
+ ]
146
+
147
+
148
+ def _comment_counts(text):
149
+ """Multiset of the comment lines in ``text``, indentation included.
150
+
151
+ Keyed by the whole line so that a re-indented comment counts as a loss too:
152
+ the guarantee callers are given is comments *and formatting*, and ruamel
153
+ re-emits comments at their original column even when the block around them
154
+ is re-indented, so an exact key costs nothing in practice.
155
+ """
156
+ return Counter(
157
+ line for line in text.splitlines() if line.lstrip().startswith("#")
158
+ )
159
+
160
+
161
+ def _load_document(path, restored=None):
162
+ """Load and shape-check the document, reporting every failure as JSON.
163
+
164
+ Returns ``(yaml, data, dev)``. ``dev`` is the live ``development_status``
165
+ mapping when the key exists, otherwise a detached empty mapping -- the key is
166
+ never inserted into the document as a side effect of loading.
167
+ """
168
+ try:
169
+ yaml, data = _load_yaml(path)
170
+ except UnicodeDecodeError as exc:
171
+ _emit_error(f"{path} is not valid UTF-8: {exc}", 1, restored)
172
+ except OSError as exc:
173
+ _emit_error(str(exc), 1, restored)
174
+ except Exception as exc: # noqa: BLE001 - report any parse error as JSON
175
+ _emit_error(str(exc), 1, restored)
176
+
177
+ if data is not None and not isinstance(data, Mapping):
178
+ _emit_error("root document is not a mapping", 1, restored)
179
+
180
+ dev = data.get("development_status") if data is not None else None
181
+ if dev is None:
182
+ dev = {}
183
+ elif not isinstance(dev, Mapping):
184
+ _emit_error("development_status is not a mapping", 1, restored)
185
+
186
+ return yaml, data, dev
187
+
188
+
189
+ def _retro_status(dev, retro_key, restored=None):
190
+ status_value = dev.get(retro_key)
191
+ if status_value is not None and not isinstance(status_value, str):
192
+ _emit_error(
193
+ f"{retro_key} status must be a string or null",
194
+ 1,
195
+ restored,
196
+ )
197
+ return status_value
198
+
199
+
200
+ def _dump_bytes(yaml, data):
201
+ """Serialize the document to bytes before any file is touched, so a dump
202
+ failure cannot leave a partial file anywhere."""
203
+ buf = io.BytesIO()
204
+ yaml.dump(data, buf)
205
+ return buf.getvalue()
206
+
207
+
208
+ def _atomic_write(path, payload, mode=None):
209
+ """Replace ``path``'s contents with ``payload`` atomically.
210
+
211
+ The bytes land in a temp file alongside the target, are fsynced, take the
212
+ target's permission bits (mkstemp creates 0600, which would silently narrow
213
+ the file), and only then rename over it -- so a kill or a full disk leaves
214
+ the original file intact rather than truncated. ``path`` is resolved through
215
+ symlinks first: renaming onto a symlink would detach the link and leave the
216
+ real file stale while reporting success. The directory is fsynced too --
217
+ best-effort, see below -- so the rename survives a power loss and not just
218
+ the bytes.
219
+ """
220
+ path = os.path.realpath(path)
221
+ directory = os.path.dirname(path) or "."
222
+ fd, tmp_path = tempfile.mkstemp(
223
+ prefix=".sprint-status-", suffix=".tmp", dir=directory
224
+ )
225
+ try:
226
+ with os.fdopen(fd, "wb") as fh:
227
+ fh.write(payload)
228
+ fh.flush()
229
+ os.fsync(fh.fileno())
230
+ if mode is not None:
231
+ os.chmod(tmp_path, mode)
232
+ os.replace(tmp_path, path)
233
+ except BaseException:
234
+ try:
235
+ os.unlink(tmp_path)
236
+ except OSError:
237
+ pass
238
+ raise
239
+ # The directory sync sits outside the try because once os.replace has
240
+ # returned, the new bytes ARE the file: a failure past that point must not
241
+ # propagate as a write failure, or the caller would report the original
242
+ # "restored" about a write that in fact landed. Skipping it only risks the
243
+ # rename not surviving a hard power loss.
244
+ try:
245
+ dir_fd = os.open(directory, os.O_RDONLY)
246
+ try:
247
+ os.fsync(dir_fd)
248
+ finally:
249
+ os.close(dir_fd)
250
+ except OSError:
251
+ pass
252
+
253
+
254
+ def cmd_detect_epic(args):
255
+ # detect-epic never writes, so it reports no "restored" key.
256
+ _, _, dev = _load_document(args.file)
257
+
258
+ done_stories = []
259
+ max_epic = None
260
+ # Every story key with its epic, in document order, so the pending list can
261
+ # be scoped to whichever epic detection lands on without a second pass over
262
+ # the mapping. Non-story keys (epic-2, epic-2-retrospective, ...) never enter
263
+ # here, because STORY_RE does not match them.
264
+ story_keys = []
265
+ for key, value in dev.items():
266
+ m = STORY_RE.match(str(key))
267
+ if not m:
268
+ continue
269
+ epic_num = int(m.group(1))
270
+ story_keys.append((epic_num, key, value))
271
+ if value == "done":
272
+ done_stories.append(key)
273
+ if max_epic is None or epic_num > max_epic:
274
+ max_epic = epic_num
275
+
276
+ # Optional --epic aims the gate at a supplied number (the -H <epic> path)
277
+ # instead of auto-picking the highest epic with a done story. Without it,
278
+ # behavior is unchanged: detect, then scope pending_stories to that epic.
279
+ if args.epic is not None:
280
+ if args.epic < 1:
281
+ _emit_error(
282
+ f"invalid --epic {args.epic} (expected a positive integer)",
283
+ 1,
284
+ )
285
+ selected = args.epic
286
+ else:
287
+ selected = max_epic
288
+
289
+ if selected is None:
290
+ # Uniform shape: pending_stories is always present, even with no epic to
291
+ # scope it to, so a caller can read it without branching on epic first.
292
+ _emit(
293
+ {
294
+ "epic": None,
295
+ "story_count": 0,
296
+ "done_stories": done_stories,
297
+ "pending_stories": [],
298
+ "retro_key": None,
299
+ "retro_status": None,
300
+ }
301
+ )
302
+
303
+ # Scoped to the selected epic only -- deliberately unlike done_stories, which
304
+ # spans the whole file. A pending story in some *other* epic is not this
305
+ # retrospective's business.
306
+ selected_keys = [
307
+ (key, value) for epic_num, key, value in story_keys if epic_num == selected
308
+ ]
309
+ pending_stories = [key for key, value in selected_keys if value != "done"]
310
+
311
+ retro_key = f"epic-{selected}-retrospective"
312
+ retro_status = _retro_status(dev, retro_key)
313
+ _emit(
314
+ {
315
+ "epic": selected,
316
+ # An epic the file has never heard of returns the same empty
317
+ # pending_stories as a finished one; story_count is the key that
318
+ # separates "complete" from "nonexistent" (a typo'd --epic), so the
319
+ # unfinished-story gate can refuse to read silence as done.
320
+ "story_count": len(selected_keys),
321
+ "done_stories": done_stories,
322
+ "pending_stories": pending_stories,
323
+ "retro_key": retro_key,
324
+ "retro_status": retro_status,
325
+ }
326
+ )
327
+
328
+
329
+ def cmd_update(args):
330
+ # Every failure below happens before the write is attempted, so the file is
331
+ # untouched and "restored": true is the honest report.
332
+ untouched = True
333
+
334
+ # 0. Validate the inputs before anything is mutated or written.
335
+ if args.epic < 1:
336
+ _emit_error(
337
+ f"invalid --epic {args.epic} (expected a positive integer)", 1, untouched
338
+ )
339
+
340
+ if args.date is not None:
341
+ try:
342
+ parsed_date = datetime.strptime(args.date, DATE_FORMAT)
343
+ except (ValueError, TypeError):
344
+ _emit_error(
345
+ f'invalid --date {args.date!r} (expected "MM-DD-YYYY HH:MM")',
346
+ 1,
347
+ untouched,
348
+ )
349
+ # Normalize: strptime also accepts unpadded spellings like
350
+ # "1-2-2026 9:05", and writing those through would defeat the point of
351
+ # validating the format at all.
352
+ last_updated = parsed_date.strftime(DATE_FORMAT)
353
+ else:
354
+ last_updated = datetime.now().strftime(DATE_FORMAT)
355
+
356
+ if args.verdict is not None and args.verdict not in VERDICTS:
357
+ _emit_error(
358
+ f"invalid --verdict {args.verdict!r} (allowed: {', '.join(VERDICTS)})",
359
+ 1,
360
+ untouched,
361
+ )
362
+
363
+ actions = []
364
+ if args.add_action:
365
+ try:
366
+ actions = json.loads(args.add_action)
367
+ except json.JSONDecodeError as exc:
368
+ _emit_error(f"invalid --add-action JSON: {exc}", 1, untouched)
369
+ if not isinstance(actions, list):
370
+ _emit_error("--add-action must be a JSON array", 1, untouched)
371
+ for item in actions:
372
+ if not isinstance(item, dict):
373
+ _emit_error(
374
+ "each --add-action item must be an object", 1, untouched
375
+ )
376
+ action_value = item.get("action")
377
+ if not isinstance(action_value, str) or not action_value.strip():
378
+ # A JSON null/number/object would otherwise be str()'d into a
379
+ # literal "None"/"{...}" and written as a real action item.
380
+ _emit_error(
381
+ "each --add-action item must have a non-empty string action",
382
+ 1,
383
+ untouched,
384
+ )
385
+
386
+ status_updates = []
387
+ if args.set_action_status:
388
+ try:
389
+ status_updates = json.loads(args.set_action_status)
390
+ except json.JSONDecodeError as exc:
391
+ _emit_error(f"invalid --set-action-status JSON: {exc}", 1, untouched)
392
+ if not isinstance(status_updates, list):
393
+ _emit_error("--set-action-status must be a JSON array", 1, untouched)
394
+ for entry in status_updates:
395
+ if not isinstance(entry, dict):
396
+ _emit_error(
397
+ "each --set-action-status entry must be an object", 1, untouched
398
+ )
399
+ status_value = entry.get("status")
400
+ if not isinstance(status_value, str) or status_value not in ACTION_STATUSES:
401
+ _emit_error(
402
+ f"invalid --set-action-status status {status_value!r} "
403
+ f"(allowed: {', '.join(ACTION_STATUSES)})",
404
+ 1,
405
+ untouched,
406
+ )
407
+ item_id = entry.get("id")
408
+ if item_id is not None:
409
+ # Present but unusable is an input error, not a silent fallback to
410
+ # the epic/action form -- the caller meant to select by id.
411
+ if not isinstance(item_id, str) or not item_id.strip():
412
+ _emit_error(
413
+ "each --set-action-status id must be a non-empty string",
414
+ 1,
415
+ untouched,
416
+ )
417
+ continue
418
+ epic_value = entry.get("epic")
419
+ action_value = entry.get("action")
420
+ if isinstance(epic_value, bool) or not isinstance(epic_value, int):
421
+ _emit_error(
422
+ "each --set-action-status entry must have a non-empty string id, "
423
+ "or an integer epic and a non-empty string action",
424
+ 1,
425
+ untouched,
426
+ )
427
+ if not isinstance(action_value, str) or not action_value.strip():
428
+ _emit_error(
429
+ "each --set-action-status entry must have a non-empty string id, "
430
+ "or an integer epic and a non-empty string action",
431
+ 1,
432
+ untouched,
433
+ )
434
+
435
+ # 1. Keep original bytes for restore-on-failure, and the mode to write back
436
+ # with -- taken from the open handle so an unlink mid-run cannot leave the
437
+ # replacement silently narrowed to mkstemp's 0600.
438
+ try:
439
+ with open(args.file, "rb") as fh:
440
+ original_bytes = fh.read()
441
+ original_mode = stat.S_IMODE(os.fstat(fh.fileno()).st_mode)
442
+ except OSError as exc:
443
+ _emit_error(str(exc), 1, untouched)
444
+
445
+ try:
446
+ original_text = original_bytes.decode("utf-8")
447
+ except UnicodeDecodeError as exc:
448
+ _emit_error(f"{args.file} is not valid UTF-8: {exc}", 1, untouched)
449
+
450
+ # Every comment line in the file, not just the leading block: the template
451
+ # ships one above action_items, and losing it corrupts the document just the
452
+ # same as losing the header.
453
+ original_comments = _comment_counts(original_text)
454
+
455
+ yaml, data, dev = _load_document(args.file, restored=untouched)
456
+
457
+ if data is None:
458
+ _emit_error("empty or invalid YAML document", 1, untouched)
459
+
460
+ epic = args.epic
461
+ retro_key = f"epic-{epic}-retrospective"
462
+
463
+ # null distinguishes "the flag was not passed" from "the key was absent",
464
+ # which is the only case retro-document.md assigns "false" to.
465
+ retro_key_found = None
466
+ retro_status_before = None
467
+ retro_status_after = None
468
+
469
+ # 2. Optionally set the retrospective status to done (only if key exists).
470
+ if args.set_retro_done:
471
+ retro_key_found = retro_key in dev
472
+ if retro_key_found:
473
+ retro_status_before = _retro_status(dev, retro_key, restored=untouched)
474
+ dev[retro_key] = "done"
475
+ retro_status_after = "done"
476
+
477
+ # 3. Take the action_items sequence as loaded and shape-check it once; both
478
+ # of the steps below operate on this same list.
479
+ existing_actions = data.get("action_items")
480
+ if existing_actions is not None and not isinstance(existing_actions, list):
481
+ # A hand-corrupted file must still fail on the JSON contract, not crash.
482
+ _emit_error("action_items in file is not a list", 1, untouched)
483
+ items_added = 0
484
+ original_action_len = len(existing_actions) if existing_actions is not None else 0
485
+
486
+ # 4. Optionally transition the status of items already in the file. Selectors
487
+ # resolve against action_items *as loaded* and strictly before the
488
+ # --add-action append below, which is what makes an item appended in the
489
+ # same invocation unaddressable in that run. Every selector is resolved
490
+ # before any is applied, so a rejected batch never leaves a partial edit --
491
+ # and since nothing has been written yet, the file is still untouched.
492
+ status_targets = []
493
+ if status_updates:
494
+ pool = existing_actions if isinstance(existing_actions, list) else []
495
+ claimed = {}
496
+ for entry in status_updates:
497
+ label = _selector_label(entry)
498
+ matches = _match_action_items(entry, pool)
499
+ if not matches:
500
+ _emit_error(f"no action item matches {label}", 1, untouched)
501
+ if len(matches) > 1:
502
+ _emit_error(
503
+ f"ambiguous --set-action-status selector {label}: "
504
+ f"{len(matches)} matches",
505
+ 1,
506
+ untouched,
507
+ )
508
+ idx = matches[0]
509
+ if idx in claimed:
510
+ # Applying both would overcount action_items_updated, and a
511
+ # conflicting pair would surface as a confusing post-write
512
+ # validation failure instead of the input error it is.
513
+ _emit_error(
514
+ f"duplicate --set-action-status targets: {label} and "
515
+ f"{claimed[idx]} resolve to the same action item",
516
+ 1,
517
+ untouched,
518
+ )
519
+ claimed[idx] = label
520
+ status_targets.append((idx, entry["status"]))
521
+
522
+ for idx, new_status in status_targets:
523
+ # A plain assignment keeps the item's own scalar style: ruamel's
524
+ # CommentedMap re-applies the existing key's style on overwrite, for
525
+ # every ScalarString subclass. Pinned by the style tests.
526
+ pool[idx]["status"] = new_status
527
+
528
+ # 5. Optionally append action items.
529
+ if actions:
530
+ seq = data.get("action_items")
531
+ if seq is None:
532
+ seq = []
533
+ data["action_items"] = seq
534
+
535
+ for item in actions:
536
+ # Stable identity for orchestrator consumers: an id that lets a
537
+ # re-run dedupe against prior items, and a ref back to the sourced
538
+ # finding in the retro document. Both accept an explicit override.
539
+ seq_num = len(seq) + 1
540
+ action_text = str(item.get("action", ""))
541
+ item_id = item.get("id") or (
542
+ f"epic-{int(epic)}-retro-item-{seq_num}-{_slugify(action_text)}"
543
+ )
544
+ ref = item.get("ref") or (args.ref or "")
545
+ entry = {
546
+ "id": DoubleQuotedScalarString(str(item_id)),
547
+ "epic": int(epic),
548
+ "action": DoubleQuotedScalarString(action_text),
549
+ "owner": DoubleQuotedScalarString(str(item.get("owner", ""))),
550
+ "status": "open",
551
+ "ref": DoubleQuotedScalarString(str(ref)),
552
+ }
553
+ seq.append(entry)
554
+ items_added += 1
555
+
556
+ # 6. Update last_updated.
557
+ data["last_updated"] = last_updated
558
+
559
+ # 7. Serialize, then swap the file atomically.
560
+ try:
561
+ _atomic_write(args.file, _dump_bytes(yaml, data), original_mode)
562
+ except Exception as exc: # noqa: BLE001
563
+ # The target is only ever touched by the final rename, so if the write
564
+ # raised, the original is still on disk byte-for-byte. Calling _restore
565
+ # here would rewrite a file that was never modified -- the one write in
566
+ # the program with nothing to gain and a truncated file to lose.
567
+ _emit({"ok": False, "error": f"write failed: {exc}", "restored": True}, 1)
568
+
569
+ # 8. Validate the written file; restore on any failure.
570
+ def _fail(msg):
571
+ restored = _restore(args.file, original_bytes, original_mode)
572
+ _emit({"ok": False, "error": msg, "restored": restored}, 1)
573
+
574
+ try:
575
+ _, reloaded = _load_yaml(args.file)
576
+ except Exception as exc: # noqa: BLE001
577
+ _fail(f"re-parse failed after write: {exc}")
578
+
579
+ if reloaded is None:
580
+ _fail("re-parse produced empty document after write")
581
+
582
+ if not isinstance(reloaded, Mapping):
583
+ _fail("re-parse produced a non-mapping document after write")
584
+
585
+ rdev = reloaded.get("development_status") or {}
586
+ if args.set_retro_done and retro_key_found:
587
+ if not isinstance(rdev, Mapping) or rdev.get(retro_key) != "done":
588
+ _fail(f"validation: {retro_key} not set to done after write")
589
+
590
+ new_action_len = 0
591
+ if reloaded.get("action_items") is not None:
592
+ new_action_len = len(reloaded.get("action_items"))
593
+ if new_action_len != original_action_len + items_added:
594
+ _fail(
595
+ "validation: action_items length mismatch "
596
+ f"(expected {original_action_len + items_added}, got {new_action_len})"
597
+ )
598
+
599
+ if status_targets:
600
+ # The recorded indices are still valid: the only other mutation to the
601
+ # sequence is an append, and the length check above just confirmed it.
602
+ reloaded_actions = reloaded.get("action_items")
603
+ if not isinstance(reloaded_actions, list):
604
+ _fail("validation: action_items is not a list after write")
605
+ for idx, new_status in status_targets:
606
+ reloaded_item = reloaded_actions[idx]
607
+ if (
608
+ not isinstance(reloaded_item, Mapping)
609
+ or reloaded_item.get("status") != new_status
610
+ ):
611
+ _fail(
612
+ f"validation: action item at index {idx} is not "
613
+ f"{new_status!r} after write"
614
+ )
615
+
616
+ try:
617
+ with open(args.file, "r", encoding="utf-8") as fh:
618
+ new_text = fh.read()
619
+ except (OSError, UnicodeDecodeError) as exc:
620
+ _fail(f"re-read failed after write: {exc}")
621
+
622
+ # Loss-only: a comment may legitimately move or be added (a long quoted value
623
+ # can wrap onto a line that begins with '#'), but none may disappear.
624
+ lost = original_comments - _comment_counts(new_text)
625
+ if lost:
626
+ first = next(
627
+ (line for line in original_text.splitlines() if line in lost), None
628
+ )
629
+ _fail(f"validation: comment line lost after write: {first!r}")
630
+
631
+ _emit(
632
+ {
633
+ "ok": True,
634
+ "retro_key_found": retro_key_found,
635
+ "retro_status_before": retro_status_before,
636
+ "retro_status_after": retro_status_after,
637
+ "action_items_added": items_added,
638
+ "action_items_updated": len(status_targets),
639
+ "last_updated": last_updated,
640
+ "verdict": args.verdict,
641
+ }
642
+ )
643
+
644
+
645
+ def _restore(path, original_bytes, mode=None):
646
+ """Best-effort restore of the original bytes. Returns True on success so a
647
+ caller can surface a restore failure instead of hiding a half-written file.
648
+
649
+ Atomic for the same reason the primary write is: a truncating rewrite that
650
+ dies halfway destroys the very bytes it was trying to put back.
651
+ """
652
+ try:
653
+ _atomic_write(path, original_bytes, mode)
654
+ return True
655
+ except Exception as exc: # noqa: BLE001 - best-effort restore
656
+ sys.stderr.write(f"restore failed: {exc}\n")
657
+ return False
658
+
659
+
660
+ def build_parser():
661
+ parser = JsonArgumentParser(
662
+ description=(
663
+ "Detect the current retrospective epic and surgically update "
664
+ "sprint-status.yaml while preserving comments and formatting."
665
+ ),
666
+ add_help=False,
667
+ )
668
+ sub = parser.add_subparsers(dest="command", required=True)
669
+
670
+ p_detect = sub.add_parser(
671
+ "detect-epic",
672
+ help=(
673
+ "Find the highest epic with a done story and its retrospective "
674
+ "status, or aim the same pending_stories gate at --epic N."
675
+ ),
676
+ add_help=False,
677
+ )
678
+ p_detect.add_argument("--file", required=True, help="Path to sprint-status.yaml")
679
+ p_detect.add_argument(
680
+ "--epic",
681
+ type=int,
682
+ default=None,
683
+ help=(
684
+ "Optional. Scope the response to this epic number instead of "
685
+ "auto-detecting the highest epic with a done story. Orchestrators "
686
+ "passing -H <epic> should pass the same number here so pending_stories "
687
+ "covers the epic they are about to retro."
688
+ ),
689
+ )
690
+ p_detect.set_defaults(func=cmd_detect_epic)
691
+
692
+ p_update = sub.add_parser(
693
+ "update",
694
+ help="Surgically update retro status and/or action items.",
695
+ add_help=False,
696
+ )
697
+ p_update.add_argument("--file", required=True, help="Path to sprint-status.yaml")
698
+ p_update.add_argument("--epic", required=True, type=int, help="Epic number")
699
+ p_update.add_argument(
700
+ "--set-retro-done",
701
+ action="store_true",
702
+ help="Set epic-<N>-retrospective to done if the key exists.",
703
+ )
704
+ p_update.add_argument(
705
+ "--add-action",
706
+ help='JSON array of {"action":str,"owner":str,"id"?:str,"ref"?:str} to append.',
707
+ )
708
+ p_update.add_argument(
709
+ "--set-action-status",
710
+ help=(
711
+ "JSON array of status transitions for action items already in the file. "
712
+ 'Select each by id -- {"id":str,"status":"open|in-progress|done"} -- or, '
713
+ 'for legacy items with no id, by epic plus exact action text: '
714
+ '{"epic":int,"action":str,"status":...}. An entry carrying both uses the '
715
+ "id. Every selector must match exactly one item; any failure aborts the "
716
+ "whole invocation and leaves the file untouched."
717
+ ),
718
+ )
719
+ p_update.add_argument(
720
+ "--ref",
721
+ help="Reference (e.g. the retro document path) recorded on each appended action item.",
722
+ )
723
+ p_update.add_argument(
724
+ "--verdict",
725
+ help=(
726
+ "Acceptance verdict echoed back in the JSON result for orchestrator "
727
+ f"consumers. One of: {', '.join(VERDICTS)}."
728
+ ),
729
+ )
730
+ p_update.add_argument(
731
+ "--date",
732
+ help='Value for last_updated (default: now as "MM-DD-YYYY HH:MM").',
733
+ )
734
+ p_update.set_defaults(func=cmd_update)
735
+
736
+ return parser
737
+
738
+
739
+ def main(argv=None):
740
+ parser = build_parser()
741
+ args = parser.parse_args(argv)
742
+ args.func(args)
743
+
744
+
745
+ if __name__ == "__main__":
746
+ main()