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,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()
|