bmad-module-ultracode-goal 0.1.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/.claude-plugin/marketplace.json +20 -0
- package/.gitattributes +16 -0
- package/.nvmrc +1 -0
- package/LICENSE +27 -0
- package/README.md +200 -0
- package/docs/_internal/RELEASING.md +101 -0
- package/docs/_internal/STABILITY.md +62 -0
- package/docs/architecture.md +77 -0
- package/docs/assets/ucg-logo.svg +73 -0
- package/docs/gate-model.md +109 -0
- package/docs/getting-started.md +51 -0
- package/docs/health-check.md +61 -0
- package/docs/how-it-works.md +73 -0
- package/docs/index.md +25 -0
- package/docs/parallel-mode.md +33 -0
- package/docs/troubleshooting.md +56 -0
- package/docs/why-ultracode-goal.md +34 -0
- package/package.json +100 -0
- package/skills/.gitkeep +0 -0
- package/skills/module.yaml +12 -0
- package/skills/ultracode-goal/SKILL.md +75 -0
- package/skills/ultracode-goal/assets/execute-epic.workflow.js +208 -0
- package/skills/ultracode-goal/customize.toml +47 -0
- package/skills/ultracode-goal/references/define-done.md +41 -0
- package/skills/ultracode-goal/references/execute.md +90 -0
- package/skills/ultracode-goal/references/finalize.md +64 -0
- package/skills/ultracode-goal/references/gate.md +70 -0
- package/skills/ultracode-goal/references/health-check.md +332 -0
- package/skills/ultracode-goal/references/ingest-and-scope.md +46 -0
- package/skills/ultracode-goal/references/preflight.md +100 -0
- package/skills/ultracode-goal/scripts/gate_eval.py +280 -0
- package/skills/ultracode-goal/scripts/health_check_fp.py +196 -0
- package/skills/ultracode-goal/scripts/hooks/budget_stop.py +162 -0
- package/skills/ultracode-goal/scripts/hooks/guard_pretooluse.py +174 -0
- package/skills/ultracode-goal/scripts/preflight_check.py +399 -0
- package/tools/cli/commands/install.js +36 -0
- package/tools/cli/commands/status.js +161 -0
- package/tools/cli/commands/uninstall.js +175 -0
- package/tools/cli/commands/update.js +65 -0
- package/tools/cli/lib/ide-skills.js +218 -0
- package/tools/cli/lib/installer.js +226 -0
- package/tools/cli/lib/manifest.js +100 -0
- package/tools/cli/lib/platform-codes.yaml +223 -0
- package/tools/cli/lib/ui.js +353 -0
- package/tools/cli/lib/version-check.js +86 -0
- package/tools/cli/ucg-cli.js +45 -0
- package/tools/ucg-npx-wrapper.js +36 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run --script
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = []
|
|
5
|
+
# ///
|
|
6
|
+
"""Decide an Epic/story verdict from TEA's deterministic quality gate.
|
|
7
|
+
|
|
8
|
+
Completion truth for ultracode-goal: this script reads TEA's gate artifact and
|
|
9
|
+
maps the gate_status to a verdict. It NEVER re-derives the TEA thresholds
|
|
10
|
+
(P0=100%, P1>=90%, overall>=80%) — those are decided upstream by the trace
|
|
11
|
+
workflow and written into the artifact; here we read gate_status as given.
|
|
12
|
+
|
|
13
|
+
Verdict mapping (gate_status -> verdict):
|
|
14
|
+
PASS | WAIVED -> advance
|
|
15
|
+
CONCERNS -> defer
|
|
16
|
+
FAIL -> reloop
|
|
17
|
+
NOT_EVALUATED -> escalate
|
|
18
|
+
|
|
19
|
+
Profile:
|
|
20
|
+
light -> the trace gate is the whole decision.
|
|
21
|
+
production -> additionally AND two signals; any failure downgrades an
|
|
22
|
+
otherwise-advance verdict to reloop (never below — a CONCERNS
|
|
23
|
+
stays defer, a FAIL stays reloop):
|
|
24
|
+
- nfr-assessment.md : Overall Status != FAIL
|
|
25
|
+
- test-review.md : Quality Score >= 80 AND
|
|
26
|
+
Recommendation != Block
|
|
27
|
+
|
|
28
|
+
Artifact resolution:
|
|
29
|
+
Read gate-decision.json (the slim file). Its name is resolved from the trace
|
|
30
|
+
report markdown frontmatter when it records one, else defaults to
|
|
31
|
+
<trace-output>/gate-decision.json. The slim file is only written by TEA when
|
|
32
|
+
the run is gate-eligible AND the decision is PASS/CONCERNS/FAIL/WAIVED, so
|
|
33
|
+
its ABSENCE is normal, not an error: fall back to the always-written
|
|
34
|
+
e2e-trace-summary.json and read its gate fields. When even the summary
|
|
35
|
+
carries no gate fields (not gate-eligible), gate_status is NOT_EVALUATED.
|
|
36
|
+
|
|
37
|
+
python3 gate_eval.py --trace-output DIR --profile production \
|
|
38
|
+
--nfr DIR/nfr-assessment.md --test-review DIR/test-review.md
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import argparse
|
|
44
|
+
import json
|
|
45
|
+
import re
|
|
46
|
+
import sys
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
|
|
49
|
+
GATE_VERDICT = {
|
|
50
|
+
"PASS": "advance",
|
|
51
|
+
"WAIVED": "advance",
|
|
52
|
+
"CONCERNS": "defer",
|
|
53
|
+
"FAIL": "reloop",
|
|
54
|
+
"NOT_EVALUATED": "escalate",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Frontmatter keys a trace report may use to point at its slim gate file.
|
|
58
|
+
_FRONTMATTER_GATE_KEYS = ("gateDecisionFile", "gateDecisionPath", "gate_decision_path")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_json(path: Path) -> dict:
|
|
62
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _frontmatter(text: str) -> dict[str, str]:
|
|
66
|
+
"""Parse the leading ``---`` YAML frontmatter as flat key: value scalars.
|
|
67
|
+
|
|
68
|
+
Stdlib-only: we only need top-level string scalars (the gate-file hint), so
|
|
69
|
+
a line scan is sufficient and avoids a yaml dependency.
|
|
70
|
+
"""
|
|
71
|
+
match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL)
|
|
72
|
+
if not match:
|
|
73
|
+
return {}
|
|
74
|
+
out: dict[str, str] = {}
|
|
75
|
+
for line in match.group(1).splitlines():
|
|
76
|
+
kv = re.match(r"^([A-Za-z_][\w]*):\s*(.*)$", line)
|
|
77
|
+
if not kv:
|
|
78
|
+
continue
|
|
79
|
+
value = kv.group(2).strip().strip("'\"")
|
|
80
|
+
out[kv.group(1)] = value
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _resolve_gate_file(trace_output: Path) -> Path:
|
|
85
|
+
"""Locate the slim gate-decision file, honoring a trace-report hint."""
|
|
86
|
+
for report in sorted(trace_output.glob("*.md")):
|
|
87
|
+
try:
|
|
88
|
+
fm = _frontmatter(report.read_text(encoding="utf-8"))
|
|
89
|
+
except OSError:
|
|
90
|
+
continue
|
|
91
|
+
if fm.get("workflowType") not in ("testarch-trace", "trace"):
|
|
92
|
+
continue
|
|
93
|
+
for key in _FRONTMATTER_GATE_KEYS:
|
|
94
|
+
hint = fm.get(key)
|
|
95
|
+
if hint:
|
|
96
|
+
hinted = Path(hint)
|
|
97
|
+
return hinted if hinted.is_absolute() else trace_output / hinted
|
|
98
|
+
return trace_output / "gate-decision.json"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _gate_fields_from_summary(summary: dict) -> dict:
|
|
102
|
+
"""Lift gate fields from e2e-trace-summary.json.
|
|
103
|
+
|
|
104
|
+
The summary only carries gate_status / gate_criteria when the run was
|
|
105
|
+
gate-eligible; otherwise those keys are absent and the gate is NOT_EVALUATED.
|
|
106
|
+
"""
|
|
107
|
+
criteria = summary.get("gate_criteria") or {}
|
|
108
|
+
return {
|
|
109
|
+
"gate_status": summary.get("gate_status", "NOT_EVALUATED"),
|
|
110
|
+
"p0_status": criteria.get("p0_status"),
|
|
111
|
+
"p1_status": criteria.get("p1_status"),
|
|
112
|
+
"overall_status": criteria.get("overall_status"),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load_gate(trace_output: Path, reasons: list[str]) -> dict:
|
|
117
|
+
"""Return normalized gate fields, preferring the slim file, else the summary."""
|
|
118
|
+
gate_file = _resolve_gate_file(trace_output)
|
|
119
|
+
if gate_file.is_file():
|
|
120
|
+
slim = _read_json(gate_file)
|
|
121
|
+
reasons.append(f"gate read from {gate_file.name}")
|
|
122
|
+
return {
|
|
123
|
+
"gate_status": slim.get("gate_status", "NOT_EVALUATED"),
|
|
124
|
+
"p0_status": slim.get("p0_status"),
|
|
125
|
+
"p1_status": slim.get("p1_status"),
|
|
126
|
+
"overall_status": slim.get("overall_status"),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
summary_file = trace_output / "e2e-trace-summary.json"
|
|
130
|
+
if summary_file.is_file():
|
|
131
|
+
reasons.append(
|
|
132
|
+
f"{gate_file.name} absent; gate read from {summary_file.name} (not a failure)"
|
|
133
|
+
)
|
|
134
|
+
return _gate_fields_from_summary(_read_json(summary_file))
|
|
135
|
+
|
|
136
|
+
reasons.append(
|
|
137
|
+
f"neither {gate_file.name} nor e2e-trace-summary.json present in {trace_output}"
|
|
138
|
+
)
|
|
139
|
+
return {
|
|
140
|
+
"gate_status": "NOT_EVALUATED",
|
|
141
|
+
"p0_status": None,
|
|
142
|
+
"p1_status": None,
|
|
143
|
+
"overall_status": None,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _scan_nfr_overall_status(text: str) -> str | None:
|
|
148
|
+
"""Read the NFR audit's Overall Status (PASS | CONCERNS | FAIL)."""
|
|
149
|
+
match = re.search(
|
|
150
|
+
r"(?:Overall\s+Status|overallStatus)[*:_\s]*[`*]*\s*(PASS|CONCERNS|FAIL|NOT_ASSESSED)",
|
|
151
|
+
text,
|
|
152
|
+
re.IGNORECASE,
|
|
153
|
+
)
|
|
154
|
+
return match.group(1).upper() if match else None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _scan_review_score(text: str) -> int | None:
|
|
158
|
+
"""Read the test-review Quality Score (``{score}/100``)."""
|
|
159
|
+
match = re.search(r"(?:Quality\s+Score|score)[*:_\s]*[`*]*\s*(\d{1,3})\s*/\s*100", text, re.IGNORECASE)
|
|
160
|
+
return int(match.group(1)) if match else None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _scan_review_recommendation(text: str) -> str | None:
|
|
164
|
+
"""Read the test-review Recommendation (Approve / ... / Block)."""
|
|
165
|
+
match = re.search(
|
|
166
|
+
r"Recommendation[*:_\s]*[`*]*\s*"
|
|
167
|
+
r"(Approve with Comments|Approve|Request Changes|Block)",
|
|
168
|
+
text,
|
|
169
|
+
re.IGNORECASE,
|
|
170
|
+
)
|
|
171
|
+
return match.group(1) if match else None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def apply_production_and(
|
|
175
|
+
verdict: str,
|
|
176
|
+
nfr_path: Path | None,
|
|
177
|
+
review_path: Path | None,
|
|
178
|
+
reasons: list[str],
|
|
179
|
+
) -> tuple[str, str | None, int | None]:
|
|
180
|
+
"""AND the production signals; downgrade an advance to reloop on any failure.
|
|
181
|
+
|
|
182
|
+
Returns (verdict, nfr_status, review_score). The downgrade floor is reloop:
|
|
183
|
+
only an otherwise-advance verdict moves; defer/reloop/escalate are unchanged.
|
|
184
|
+
|
|
185
|
+
FAIL-CLOSED CONTRACT (deliberate — do not "relax"): a missing file or a
|
|
186
|
+
field the scanners below cannot parse is treated as a *failing* signal, not
|
|
187
|
+
a neutral/absent one (see the ``nfr_status is None`` / ``review_score is None``
|
|
188
|
+
/ file-not-found branches). TEA prose-format drift therefore degrades to a
|
|
189
|
+
conservative reloop rather than a silent false-advance. The conservative
|
|
190
|
+
direction is intentional: we would rather re-loop a green story than advance
|
|
191
|
+
a story whose evidence we could not actually read.
|
|
192
|
+
"""
|
|
193
|
+
nfr_status: str | None = None
|
|
194
|
+
review_score: int | None = None
|
|
195
|
+
failed = False
|
|
196
|
+
|
|
197
|
+
if nfr_path is not None and nfr_path.is_file():
|
|
198
|
+
nfr_status = _scan_nfr_overall_status(nfr_path.read_text(encoding="utf-8"))
|
|
199
|
+
if nfr_status == "FAIL":
|
|
200
|
+
reasons.append("nfr overallStatus is FAIL")
|
|
201
|
+
failed = True
|
|
202
|
+
elif nfr_status is None:
|
|
203
|
+
reasons.append(f"nfr Overall Status not found in {nfr_path.name}; treated as failing")
|
|
204
|
+
failed = True
|
|
205
|
+
elif nfr_path is not None:
|
|
206
|
+
reasons.append(f"nfr file {nfr_path} not found; treated as failing")
|
|
207
|
+
failed = True
|
|
208
|
+
|
|
209
|
+
if review_path is not None and review_path.is_file():
|
|
210
|
+
review_text = review_path.read_text(encoding="utf-8")
|
|
211
|
+
review_score = _scan_review_score(review_text)
|
|
212
|
+
recommendation = _scan_review_recommendation(review_text)
|
|
213
|
+
if review_score is None:
|
|
214
|
+
reasons.append(f"test-review score not found in {review_path.name}; treated as failing")
|
|
215
|
+
failed = True
|
|
216
|
+
elif review_score < 80:
|
|
217
|
+
reasons.append(f"test-review score {review_score} < 80")
|
|
218
|
+
failed = True
|
|
219
|
+
if recommendation is not None and recommendation.lower() == "block":
|
|
220
|
+
reasons.append("test-review recommendation is Block")
|
|
221
|
+
failed = True
|
|
222
|
+
elif review_path is not None:
|
|
223
|
+
reasons.append(f"test-review file {review_path} not found; treated as failing")
|
|
224
|
+
failed = True
|
|
225
|
+
|
|
226
|
+
if failed and verdict == "advance":
|
|
227
|
+
reasons.append("production signal failed; advance downgraded to reloop")
|
|
228
|
+
verdict = "reloop"
|
|
229
|
+
|
|
230
|
+
return verdict, nfr_status, review_score
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def evaluate(args: argparse.Namespace) -> dict:
|
|
234
|
+
reasons: list[str] = []
|
|
235
|
+
trace_output = Path(args.trace_output)
|
|
236
|
+
|
|
237
|
+
gate = load_gate(trace_output, reasons)
|
|
238
|
+
gate_status = (gate["gate_status"] or "NOT_EVALUATED").upper()
|
|
239
|
+
verdict = GATE_VERDICT.get(gate_status, "escalate")
|
|
240
|
+
if gate_status not in GATE_VERDICT:
|
|
241
|
+
reasons.append(f"unrecognized gate_status {gate_status!r}; escalating")
|
|
242
|
+
else:
|
|
243
|
+
reasons.append(f"gate_status {gate_status} -> {verdict}")
|
|
244
|
+
|
|
245
|
+
nfr_status: str | None = None
|
|
246
|
+
review_score: int | None = None
|
|
247
|
+
if args.profile == "production":
|
|
248
|
+
nfr_path = Path(args.nfr) if args.nfr else None
|
|
249
|
+
review_path = Path(args.test_review) if args.test_review else None
|
|
250
|
+
verdict, nfr_status, review_score = apply_production_and(
|
|
251
|
+
verdict, nfr_path, review_path, reasons
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
"verdict": verdict,
|
|
256
|
+
"gate_status": gate_status,
|
|
257
|
+
"p0_status": gate["p0_status"],
|
|
258
|
+
"p1_status": gate["p1_status"],
|
|
259
|
+
"overall_status": gate["overall_status"],
|
|
260
|
+
"nfr_status": nfr_status,
|
|
261
|
+
"review_score": review_score,
|
|
262
|
+
"reasons": reasons,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def main(argv: list[str] | None = None) -> int:
|
|
267
|
+
parser = argparse.ArgumentParser(description="Evaluate the TEA quality gate into a verdict.")
|
|
268
|
+
parser.add_argument("--trace-output", required=True, help="Directory holding the trace gate artifacts.")
|
|
269
|
+
parser.add_argument("--profile", required=True, choices=["light", "production"])
|
|
270
|
+
parser.add_argument("--nfr", help="Path to nfr-assessment.md (production only).")
|
|
271
|
+
parser.add_argument("--test-review", help="Path to test-review.md (production only).")
|
|
272
|
+
args = parser.parse_args(argv)
|
|
273
|
+
|
|
274
|
+
result = evaluate(args)
|
|
275
|
+
print(json.dumps(result, indent=2))
|
|
276
|
+
return 0
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
if __name__ == "__main__":
|
|
280
|
+
sys.exit(main())
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run --script
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = []
|
|
5
|
+
# ///
|
|
6
|
+
"""Deterministic fingerprint + seen-cache plumbing for the ultracode-goal health check.
|
|
7
|
+
|
|
8
|
+
Plumbing ONLY. This script computes the dedup fingerprint for a health-check
|
|
9
|
+
finding and manages the machine-local seen-cache. It does NO network, NO `gh`,
|
|
10
|
+
and makes NO submit/queue/route decisions — that judgment lives in
|
|
11
|
+
references/health-check.md (the LLM). Here we only guarantee a byte-stable
|
|
12
|
+
fingerprint and a crash-proof cache.
|
|
13
|
+
|
|
14
|
+
The fingerprint is install-mode-invariant: the `step_file` component is ALWAYS
|
|
15
|
+
the source-repo form `skills/ultracode-goal/references/{stage}.md` regardless of
|
|
16
|
+
where the skill is installed, so the same defect dedups to the same key across a
|
|
17
|
+
dev checkout and an installed `_bmad/` tree.
|
|
18
|
+
|
|
19
|
+
fp = "fp-" + sha1("{severity}|ultracode-goal/{stage}|"
|
|
20
|
+
"skills/ultracode-goal/references/{stage}.md|{section-slug}")[:7]
|
|
21
|
+
|
|
22
|
+
Subcommands:
|
|
23
|
+
fingerprint --severity S --stage T --section-slug SLUG
|
|
24
|
+
Validate S against the 3 severities and T against the 6 stages; validate
|
|
25
|
+
SLUG against ^[a-z0-9]+(-[a-z0-9]+)*$. Emit {"fp": ..., "tuple": ...}.
|
|
26
|
+
seen --fp FP --cache PATH
|
|
27
|
+
Validate FP. Missing/empty/corrupt cache -> {"seen": false, "record": null}
|
|
28
|
+
(never crash). Found -> {"seen": true, "record": {...}}.
|
|
29
|
+
record --fp FP --cache PATH --issue-url URL --action A --date YYYY-MM-DD
|
|
30
|
+
Validate FP + action. Create parent dirs. Merge-write (preserve other fps),
|
|
31
|
+
atomic via temp file + os.replace. Emit {"written": true, "fp": FP}.
|
|
32
|
+
|
|
33
|
+
Output: JSON to stdout. Errors are JSON to stdout with exit code 1. A successful
|
|
34
|
+
payload exits 0.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import argparse
|
|
40
|
+
import hashlib
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import re
|
|
44
|
+
import sys
|
|
45
|
+
import tempfile
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
SEVERITIES = ("bug", "friction", "gap")
|
|
49
|
+
STAGES = (
|
|
50
|
+
"ingest-and-scope",
|
|
51
|
+
"preflight",
|
|
52
|
+
"define-done",
|
|
53
|
+
"execute",
|
|
54
|
+
"gate",
|
|
55
|
+
"finalize",
|
|
56
|
+
)
|
|
57
|
+
ACTIONS = ("created", "reacted", "commented", "queued")
|
|
58
|
+
|
|
59
|
+
SLUG_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
60
|
+
FP_RE = re.compile(r"^fp-[0-9a-f]{7}$")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fail(message: str) -> int:
|
|
64
|
+
print(json.dumps({"error": message}))
|
|
65
|
+
return 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _compute_fp(severity: str, stage: str, slug: str) -> tuple[str, str]:
|
|
69
|
+
"""Return (fp, tuple_string). The tuple string is the exact sha1 input."""
|
|
70
|
+
step_file = f"skills/ultracode-goal/references/{stage}.md"
|
|
71
|
+
tuple_str = f"{severity}|ultracode-goal/{stage}|{step_file}|{slug}"
|
|
72
|
+
digest = hashlib.sha1(tuple_str.encode("utf-8")).hexdigest()[:7]
|
|
73
|
+
return f"fp-{digest}", tuple_str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_fingerprint(args: argparse.Namespace) -> int:
|
|
77
|
+
if args.severity not in SEVERITIES:
|
|
78
|
+
return _fail(
|
|
79
|
+
"invalid severity %r; expected one of %s"
|
|
80
|
+
% (args.severity, ", ".join(SEVERITIES))
|
|
81
|
+
)
|
|
82
|
+
if args.stage not in STAGES:
|
|
83
|
+
return _fail(
|
|
84
|
+
"invalid stage %r; expected one of %s" % (args.stage, ", ".join(STAGES))
|
|
85
|
+
)
|
|
86
|
+
if not SLUG_RE.match(args.section_slug):
|
|
87
|
+
return _fail(
|
|
88
|
+
"invalid section-slug %r; expected kebab-case ^[a-z0-9]+(-[a-z0-9]+)*$"
|
|
89
|
+
% args.section_slug
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
fp, tuple_str = _compute_fp(args.severity, args.stage, args.section_slug)
|
|
93
|
+
print(json.dumps({"fp": fp, "tuple": tuple_str}))
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _load_cache(cache: Path) -> dict:
|
|
98
|
+
"""Read the seen-cache as a dict. Missing/empty/corrupt -> {} (never raises)."""
|
|
99
|
+
try:
|
|
100
|
+
# errors="replace" so raw non-UTF-8 garbage decodes to junk text rather
|
|
101
|
+
# than raising at the read stage; the json.loads below then rejects it.
|
|
102
|
+
text = cache.read_text(encoding="utf-8", errors="replace")
|
|
103
|
+
except (FileNotFoundError, OSError):
|
|
104
|
+
return {}
|
|
105
|
+
if not text.strip():
|
|
106
|
+
return {}
|
|
107
|
+
try:
|
|
108
|
+
data = json.loads(text)
|
|
109
|
+
except ValueError:
|
|
110
|
+
return {}
|
|
111
|
+
return data if isinstance(data, dict) else {}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_seen(args: argparse.Namespace) -> int:
|
|
115
|
+
if not FP_RE.match(args.fp):
|
|
116
|
+
return _fail("invalid fp %r; expected ^fp-[0-9a-f]{7}$" % args.fp)
|
|
117
|
+
|
|
118
|
+
cache = Path(args.cache).expanduser()
|
|
119
|
+
data = _load_cache(cache)
|
|
120
|
+
record = data.get(args.fp)
|
|
121
|
+
if isinstance(record, dict):
|
|
122
|
+
print(json.dumps({"seen": True, "record": record}))
|
|
123
|
+
else:
|
|
124
|
+
print(json.dumps({"seen": False, "record": None}))
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cmd_record(args: argparse.Namespace) -> int:
|
|
129
|
+
if not FP_RE.match(args.fp):
|
|
130
|
+
return _fail("invalid fp %r; expected ^fp-[0-9a-f]{7}$" % args.fp)
|
|
131
|
+
if args.action not in ACTIONS:
|
|
132
|
+
return _fail(
|
|
133
|
+
"invalid action %r; expected one of %s"
|
|
134
|
+
% (args.action, ", ".join(ACTIONS))
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
cache = Path(args.cache).expanduser()
|
|
138
|
+
cache.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
|
|
140
|
+
# Merge: preserve every other fp; a corrupt/empty cache is treated as empty.
|
|
141
|
+
data = _load_cache(cache)
|
|
142
|
+
data[args.fp] = {
|
|
143
|
+
"issue_url": args.issue_url,
|
|
144
|
+
"action": args.action,
|
|
145
|
+
"date": args.date,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# Atomic write: temp file in the same dir, then os.replace.
|
|
149
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(cache.parent), prefix=".hc-seen-", suffix=".tmp")
|
|
150
|
+
try:
|
|
151
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
152
|
+
json.dump(data, handle, indent=2, sort_keys=True)
|
|
153
|
+
handle.write("\n")
|
|
154
|
+
os.replace(tmp_name, str(cache))
|
|
155
|
+
except OSError as exc:
|
|
156
|
+
try:
|
|
157
|
+
os.unlink(tmp_name)
|
|
158
|
+
except OSError:
|
|
159
|
+
pass
|
|
160
|
+
return _fail("could not write cache %s: %s" % (cache, exc))
|
|
161
|
+
|
|
162
|
+
print(json.dumps({"written": True, "fp": args.fp}))
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main(argv: list[str] | None = None) -> int:
|
|
167
|
+
parser = argparse.ArgumentParser(
|
|
168
|
+
description="Fingerprint + seen-cache plumbing for the ultracode-goal health check."
|
|
169
|
+
)
|
|
170
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
171
|
+
|
|
172
|
+
fp_parser = sub.add_parser("fingerprint", help="Compute the dedup fingerprint.")
|
|
173
|
+
fp_parser.add_argument("--severity", required=True)
|
|
174
|
+
fp_parser.add_argument("--stage", required=True)
|
|
175
|
+
fp_parser.add_argument("--section-slug", required=True, dest="section_slug")
|
|
176
|
+
fp_parser.set_defaults(func=cmd_fingerprint)
|
|
177
|
+
|
|
178
|
+
seen_parser = sub.add_parser("seen", help="Check the seen-cache for a fingerprint.")
|
|
179
|
+
seen_parser.add_argument("--fp", required=True)
|
|
180
|
+
seen_parser.add_argument("--cache", required=True)
|
|
181
|
+
seen_parser.set_defaults(func=cmd_seen)
|
|
182
|
+
|
|
183
|
+
rec_parser = sub.add_parser("record", help="Record a fingerprint outcome to the cache.")
|
|
184
|
+
rec_parser.add_argument("--fp", required=True)
|
|
185
|
+
rec_parser.add_argument("--cache", required=True)
|
|
186
|
+
rec_parser.add_argument("--issue-url", required=True, dest="issue_url")
|
|
187
|
+
rec_parser.add_argument("--action", required=True)
|
|
188
|
+
rec_parser.add_argument("--date", required=True)
|
|
189
|
+
rec_parser.set_defaults(func=cmd_record)
|
|
190
|
+
|
|
191
|
+
args = parser.parse_args(argv)
|
|
192
|
+
return args.func(args)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run --script
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = []
|
|
5
|
+
# ///
|
|
6
|
+
"""UltraCode-Goal budget Stop hook (Claude Code hook).
|
|
7
|
+
|
|
8
|
+
Belt-and-suspenders to the in-/goal-condition "stop after N turns" clause:
|
|
9
|
+
counts Stop events (turns) and accumulated tokens for the current story against
|
|
10
|
+
max_turns_per_story / story_token_budget. On overrun it writes an escalation
|
|
11
|
+
marker and surfaces a clear message, then LETS THE STOP PROCEED.
|
|
12
|
+
|
|
13
|
+
KNOWN LIMITATION: a Stop hook fires only when Claude is *already* trying to stop.
|
|
14
|
+
It cannot interrupt a /goal condition mid-turn — it can record the overrun and
|
|
15
|
+
warn, but the budget ceiling is ultimately advisory at this layer. The hard
|
|
16
|
+
runaway guard is the gate_eval re-loop budget plus the /goal stop-after-N clause;
|
|
17
|
+
this hook is the third, defensive layer.
|
|
18
|
+
|
|
19
|
+
Hook contract (reads one JSON object on stdin):
|
|
20
|
+
in : {session_id, cwd, hook_event_name:"Stop", ...}
|
|
21
|
+
out: exit 0. We never set decision:"block" (we WANT the stop to proceed);
|
|
22
|
+
on overrun we emit {systemMessage, suppressOutput:false} so the user
|
|
23
|
+
sees the escalation. JSON is best-effort; absence is also valid.
|
|
24
|
+
|
|
25
|
+
State + config (env wins so the conductor injects per-story values):
|
|
26
|
+
ULTRACODE_IMPL_ARTIFACTS state dir; default <cwd>/_bmad-output/implementation-artifacts
|
|
27
|
+
ULTRACODE_STORY_ID current story id; else <impl>/.current-story
|
|
28
|
+
ULTRACODE_MAX_TURNS int; default 25
|
|
29
|
+
ULTRACODE_TOKEN_BUDGET int; default 1500000
|
|
30
|
+
ULTRACODE_TURN_TOKENS int tokens to add this turn (optional; 0 if unknown)
|
|
31
|
+
|
|
32
|
+
State file: <impl>/.budget-<story>.json {turns:int, tokens:int}
|
|
33
|
+
Escalation marker: <impl>/.escalation-<story>.md
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import sys
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
DEFAULT_MAX_TURNS = 25
|
|
42
|
+
DEFAULT_TOKEN_BUDGET = 1_500_000
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read_event() -> dict:
|
|
46
|
+
try:
|
|
47
|
+
raw = sys.stdin.read()
|
|
48
|
+
return json.loads(raw) if raw.strip() else {}
|
|
49
|
+
except (json.JSONDecodeError, ValueError):
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _emit(message: str | None) -> None:
|
|
54
|
+
"""Exit 0 and let the stop proceed; surface a message only on overrun."""
|
|
55
|
+
if message:
|
|
56
|
+
print(json.dumps({"systemMessage": message, "suppressOutput": False}))
|
|
57
|
+
sys.exit(0)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _int_env(name: str, default: int) -> int:
|
|
61
|
+
try:
|
|
62
|
+
return int(os.environ.get(name, "") or default)
|
|
63
|
+
except ValueError:
|
|
64
|
+
return default
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _impl_artifacts(cwd: str | None) -> Path | None:
|
|
68
|
+
env = os.environ.get("ULTRACODE_IMPL_ARTIFACTS")
|
|
69
|
+
if env:
|
|
70
|
+
return Path(env)
|
|
71
|
+
if cwd:
|
|
72
|
+
return Path(cwd) / "_bmad-output" / "implementation-artifacts"
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _current_story(impl: Path | None) -> str:
|
|
77
|
+
sid = os.environ.get("ULTRACODE_STORY_ID")
|
|
78
|
+
if sid:
|
|
79
|
+
return sid.strip()
|
|
80
|
+
if impl is not None:
|
|
81
|
+
marker = impl / ".current-story"
|
|
82
|
+
if marker.is_file():
|
|
83
|
+
try:
|
|
84
|
+
return marker.read_text(encoding="utf-8").strip() or "unknown"
|
|
85
|
+
except OSError:
|
|
86
|
+
return "unknown"
|
|
87
|
+
return "unknown"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _load_state(path: Path) -> dict:
|
|
91
|
+
try:
|
|
92
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
93
|
+
return {
|
|
94
|
+
"turns": int(data.get("turns", 0)),
|
|
95
|
+
"tokens": int(data.get("tokens", 0)),
|
|
96
|
+
}
|
|
97
|
+
except (OSError, json.JSONDecodeError, ValueError, TypeError):
|
|
98
|
+
return {"turns": 0, "tokens": 0}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _save_state(path: Path, state: dict) -> None:
|
|
102
|
+
try:
|
|
103
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
path.write_text(json.dumps(state), encoding="utf-8")
|
|
105
|
+
except OSError:
|
|
106
|
+
pass # state best-effort; never crash a Stop hook
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main() -> None:
|
|
110
|
+
event = _read_event()
|
|
111
|
+
cwd = event.get("cwd")
|
|
112
|
+
impl = _impl_artifacts(cwd)
|
|
113
|
+
|
|
114
|
+
if impl is None:
|
|
115
|
+
_emit(None) # nowhere to keep state; proceed silently
|
|
116
|
+
|
|
117
|
+
story = _current_story(impl)
|
|
118
|
+
max_turns = _int_env("ULTRACODE_MAX_TURNS", DEFAULT_MAX_TURNS)
|
|
119
|
+
token_budget = _int_env("ULTRACODE_TOKEN_BUDGET", DEFAULT_TOKEN_BUDGET)
|
|
120
|
+
turn_tokens = _int_env("ULTRACODE_TURN_TOKENS", 0)
|
|
121
|
+
|
|
122
|
+
state_path = impl / f".budget-{story}.json"
|
|
123
|
+
state = _load_state(state_path)
|
|
124
|
+
state["turns"] += 1
|
|
125
|
+
state["tokens"] += max(turn_tokens, 0)
|
|
126
|
+
_save_state(state_path, state)
|
|
127
|
+
|
|
128
|
+
over_turns = state["turns"] >= max_turns
|
|
129
|
+
over_tokens = token_budget > 0 and state["tokens"] >= token_budget
|
|
130
|
+
if not (over_turns or over_tokens):
|
|
131
|
+
_emit(None)
|
|
132
|
+
|
|
133
|
+
breached = []
|
|
134
|
+
if over_turns:
|
|
135
|
+
breached.append(f"turns {state['turns']}/{max_turns}")
|
|
136
|
+
if over_tokens:
|
|
137
|
+
breached.append(f"tokens {state['tokens']}/{token_budget}")
|
|
138
|
+
detail = "; ".join(breached)
|
|
139
|
+
|
|
140
|
+
marker = impl / f".escalation-{story}.md"
|
|
141
|
+
try:
|
|
142
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
marker.write_text(
|
|
144
|
+
f"# Budget escalation — story {story}\n\n"
|
|
145
|
+
f"Story budget exceeded ({detail}). UltraCode-Goal stops this story "
|
|
146
|
+
f"and escalates: re-scope, split, or hand off. A Stop hook cannot "
|
|
147
|
+
f"interrupt a /goal condition mid-turn, so treat this as advisory — "
|
|
148
|
+
f"the deterministic guard is the gate_eval re-loop budget.\n",
|
|
149
|
+
encoding="utf-8",
|
|
150
|
+
)
|
|
151
|
+
except OSError:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
_emit(
|
|
155
|
+
f"UltraCode-Goal budget exceeded for story {story} ({detail}). "
|
|
156
|
+
f"Escalation marker written to {marker}. Stop and escalate this story; "
|
|
157
|
+
f"do not keep re-looping."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
main()
|