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.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.gitattributes +16 -0
  3. package/.nvmrc +1 -0
  4. package/LICENSE +27 -0
  5. package/README.md +200 -0
  6. package/docs/_internal/RELEASING.md +101 -0
  7. package/docs/_internal/STABILITY.md +62 -0
  8. package/docs/architecture.md +77 -0
  9. package/docs/assets/ucg-logo.svg +73 -0
  10. package/docs/gate-model.md +109 -0
  11. package/docs/getting-started.md +51 -0
  12. package/docs/health-check.md +61 -0
  13. package/docs/how-it-works.md +73 -0
  14. package/docs/index.md +25 -0
  15. package/docs/parallel-mode.md +33 -0
  16. package/docs/troubleshooting.md +56 -0
  17. package/docs/why-ultracode-goal.md +34 -0
  18. package/package.json +100 -0
  19. package/skills/.gitkeep +0 -0
  20. package/skills/module.yaml +12 -0
  21. package/skills/ultracode-goal/SKILL.md +75 -0
  22. package/skills/ultracode-goal/assets/execute-epic.workflow.js +208 -0
  23. package/skills/ultracode-goal/customize.toml +47 -0
  24. package/skills/ultracode-goal/references/define-done.md +41 -0
  25. package/skills/ultracode-goal/references/execute.md +90 -0
  26. package/skills/ultracode-goal/references/finalize.md +64 -0
  27. package/skills/ultracode-goal/references/gate.md +70 -0
  28. package/skills/ultracode-goal/references/health-check.md +332 -0
  29. package/skills/ultracode-goal/references/ingest-and-scope.md +46 -0
  30. package/skills/ultracode-goal/references/preflight.md +100 -0
  31. package/skills/ultracode-goal/scripts/gate_eval.py +280 -0
  32. package/skills/ultracode-goal/scripts/health_check_fp.py +196 -0
  33. package/skills/ultracode-goal/scripts/hooks/budget_stop.py +162 -0
  34. package/skills/ultracode-goal/scripts/hooks/guard_pretooluse.py +174 -0
  35. package/skills/ultracode-goal/scripts/preflight_check.py +399 -0
  36. package/tools/cli/commands/install.js +36 -0
  37. package/tools/cli/commands/status.js +161 -0
  38. package/tools/cli/commands/uninstall.js +175 -0
  39. package/tools/cli/commands/update.js +65 -0
  40. package/tools/cli/lib/ide-skills.js +218 -0
  41. package/tools/cli/lib/installer.js +226 -0
  42. package/tools/cli/lib/manifest.js +100 -0
  43. package/tools/cli/lib/platform-codes.yaml +223 -0
  44. package/tools/cli/lib/ui.js +353 -0
  45. package/tools/cli/lib/version-check.js +86 -0
  46. package/tools/cli/ucg-cli.js +45 -0
  47. 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()