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,174 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run --script
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = []
|
|
5
|
+
# ///
|
|
6
|
+
"""UltraCode-Goal PreToolUse guard (Claude Code hook).
|
|
7
|
+
|
|
8
|
+
Enforces two invariants that must NOT live in memory (context, not enforcement):
|
|
9
|
+
1. No `git commit`/`git push` while on a protected branch.
|
|
10
|
+
2. No `git commit` until a "tests-ran" marker exists for the current story.
|
|
11
|
+
|
|
12
|
+
Hook contract (reads one JSON object on stdin):
|
|
13
|
+
in : {tool_name, tool_input:{command,...}, cwd, ...}
|
|
14
|
+
out: exit 0 + JSON {hookSpecificOutput:{hookEventName,permissionDecision,
|
|
15
|
+
permissionDecisionReason}} where permissionDecision is "deny" to block.
|
|
16
|
+
Defensive fallback: also exit 2 with the reason on stderr (older clients
|
|
17
|
+
honor exit-code-2-blocks even when they ignore the JSON).
|
|
18
|
+
|
|
19
|
+
Config resolution (all optional, env wins so the conductor can inject per run):
|
|
20
|
+
ULTRACODE_PROTECTED_BRANCHES comma-separated; default "main,master"
|
|
21
|
+
ULTRACODE_IMPL_ARTIFACTS dir holding run state (story id + markers)
|
|
22
|
+
ULTRACODE_STORY_ID current story id; else read from
|
|
23
|
+
<impl_artifacts>/.current-story
|
|
24
|
+
Marker file checked: <impl_artifacts>/.tests-ran-<story_id>
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
DEFAULT_PROTECTED = ["main", "master"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_event() -> dict:
|
|
38
|
+
try:
|
|
39
|
+
raw = sys.stdin.read()
|
|
40
|
+
return json.loads(raw) if raw.strip() else {}
|
|
41
|
+
except (json.JSONDecodeError, ValueError):
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _allow() -> None:
|
|
46
|
+
"""No decision needed: stay silent, let the normal permission flow run."""
|
|
47
|
+
sys.exit(0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _deny(reason: str) -> None:
|
|
51
|
+
print(
|
|
52
|
+
json.dumps(
|
|
53
|
+
{
|
|
54
|
+
"hookSpecificOutput": {
|
|
55
|
+
"hookEventName": "PreToolUse",
|
|
56
|
+
"permissionDecision": "deny",
|
|
57
|
+
"permissionDecisionReason": reason,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
# Belt-and-suspenders: clients that ignore JSON still block on exit 2.
|
|
63
|
+
print(reason, file=sys.stderr)
|
|
64
|
+
sys.exit(2)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _protected_branches() -> list[str]:
|
|
68
|
+
env = os.environ.get("ULTRACODE_PROTECTED_BRANCHES")
|
|
69
|
+
if env:
|
|
70
|
+
return [b.strip() for b in env.split(",") if b.strip()]
|
|
71
|
+
return DEFAULT_PROTECTED
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _current_branch(cwd: str | None) -> str | None:
|
|
75
|
+
try:
|
|
76
|
+
out = subprocess.run(
|
|
77
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
78
|
+
cwd=cwd or None,
|
|
79
|
+
capture_output=True,
|
|
80
|
+
text=True,
|
|
81
|
+
timeout=10,
|
|
82
|
+
)
|
|
83
|
+
except (OSError, subprocess.SubprocessError):
|
|
84
|
+
return None
|
|
85
|
+
branch = out.stdout.strip()
|
|
86
|
+
return branch or None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _impl_artifacts(cwd: str | None) -> Path | None:
|
|
90
|
+
env = os.environ.get("ULTRACODE_IMPL_ARTIFACTS")
|
|
91
|
+
if env:
|
|
92
|
+
return Path(env)
|
|
93
|
+
if cwd:
|
|
94
|
+
return Path(cwd) / "_bmad-output" / "implementation-artifacts"
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _current_story(impl: Path | None) -> str | None:
|
|
99
|
+
sid = os.environ.get("ULTRACODE_STORY_ID")
|
|
100
|
+
if sid:
|
|
101
|
+
return sid.strip()
|
|
102
|
+
if impl is not None:
|
|
103
|
+
marker = impl / ".current-story"
|
|
104
|
+
if marker.is_file():
|
|
105
|
+
try:
|
|
106
|
+
return marker.read_text(encoding="utf-8").strip() or None
|
|
107
|
+
except OSError:
|
|
108
|
+
return None
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# git verbs that write history. `git commit` and `git push` are the targets;
|
|
113
|
+
# a trailing word boundary keeps `git commit-tree`-style false positives out.
|
|
114
|
+
_GIT_WRITE = re.compile(
|
|
115
|
+
r"\bgit\b[^\n;&|]*?\b(?P<verb>commit|push)\b", re.IGNORECASE
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _git_writes(command: str) -> set[str]:
|
|
120
|
+
"""Return {'commit','push'} subset the command would perform.
|
|
121
|
+
|
|
122
|
+
Scans each shell-segment so a chained `git add && git commit` is caught.
|
|
123
|
+
"""
|
|
124
|
+
verbs: set[str] = set()
|
|
125
|
+
for segment in re.split(r"&&|\|\||;|\|", command):
|
|
126
|
+
m = _GIT_WRITE.search(segment)
|
|
127
|
+
if m and re.search(r"\bgit\b", segment):
|
|
128
|
+
verbs.add(m.group("verb").lower())
|
|
129
|
+
return verbs
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main() -> None:
|
|
133
|
+
event = _read_event()
|
|
134
|
+
if event.get("tool_name") != "Bash":
|
|
135
|
+
_allow()
|
|
136
|
+
|
|
137
|
+
command = (event.get("tool_input") or {}).get("command") or ""
|
|
138
|
+
if not isinstance(command, str):
|
|
139
|
+
_allow()
|
|
140
|
+
|
|
141
|
+
verbs = _git_writes(command)
|
|
142
|
+
if not verbs:
|
|
143
|
+
_allow()
|
|
144
|
+
|
|
145
|
+
cwd = event.get("cwd")
|
|
146
|
+
protected = _protected_branches()
|
|
147
|
+
branch = _current_branch(cwd)
|
|
148
|
+
|
|
149
|
+
if branch is not None and branch in protected:
|
|
150
|
+
_deny(
|
|
151
|
+
f"Protected-branch guard: refusing `git {'/'.join(sorted(verbs))}` "
|
|
152
|
+
f"on '{branch}'. UltraCode-Goal commits one green story per commit "
|
|
153
|
+
f"on an epic branch ('{os.environ.get('ULTRACODE_EPIC_BRANCH_PREFIX', 'ultracode/epic-')}<id>'), "
|
|
154
|
+
f"never on {protected}. Switch to the epic branch first."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if "commit" in verbs:
|
|
158
|
+
impl = _impl_artifacts(cwd)
|
|
159
|
+
story = _current_story(impl)
|
|
160
|
+
marker = (impl / f".tests-ran-{story}") if (impl and story) else None
|
|
161
|
+
if marker is None or not marker.is_file():
|
|
162
|
+
target = str(marker) if marker else "<impl-artifacts>/.tests-ran-<story>"
|
|
163
|
+
_deny(
|
|
164
|
+
"Tests-ran guard: refusing `git commit` — no tests-ran marker "
|
|
165
|
+
f"for the current story ({story or 'unknown'}). Run the story's "
|
|
166
|
+
f"test/lint/build to green and write {target} before committing. "
|
|
167
|
+
"Commit only at a verified-green story boundary."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
_allow()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
main()
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run --script
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = []
|
|
5
|
+
# ///
|
|
6
|
+
"""Mechanical preflight for an UltraCode-Goal epic run.
|
|
7
|
+
|
|
8
|
+
Plumbing ONLY. This script parses tool versions, inspects git state, checks
|
|
9
|
+
file/directory existence, and reads TEA config flags. It reports mechanical
|
|
10
|
+
facts plus a `budget` count of mechanical blockers. It does NOT decide whether
|
|
11
|
+
to intervene, remediate, or block on semantic grounds — that judgment lives in
|
|
12
|
+
references/preflight.md (the LLM). The "auto-remediation then hard-gate"
|
|
13
|
+
posture (decision D2') is the LLM's to run; this script only tells it what is
|
|
14
|
+
mechanically true right now.
|
|
15
|
+
|
|
16
|
+
What counts as a mechanical blocker (each adds 1 to `budget`):
|
|
17
|
+
- the Claude Code primitives needed for an unattended run are below the
|
|
18
|
+
minimum versions the run depends on (`/goal`, dynamic workflows, auto memory),
|
|
19
|
+
- the test framework is not scaffolded (no playwright/cypress/jest config),
|
|
20
|
+
- the working tree is dirty (a per-green-story commit needs a clean base),
|
|
21
|
+
- the current branch is a protected branch (the epic must run on its own branch).
|
|
22
|
+
|
|
23
|
+
`green` is true iff `budget == 0`. A green preflight means there is nothing
|
|
24
|
+
MECHANICAL left to clear; the LLM still owns the true-RED hard gate
|
|
25
|
+
(undecided architecture/product, unresolvable secrets), which this script
|
|
26
|
+
cannot and does not evaluate.
|
|
27
|
+
|
|
28
|
+
Severity is advisory metadata for the LLM, not a gate. `remediable` flags
|
|
29
|
+
blockers the remediation pass can plausibly auto-clear (e.g. scaffold the
|
|
30
|
+
framework, branch off, commit/stash) vs. ones that need a human (none here are
|
|
31
|
+
inherently un-remediable; true-RED items never reach this script).
|
|
32
|
+
|
|
33
|
+
Version gates (from the grounded constraints in .decision-log.md):
|
|
34
|
+
/goal >= 2.1.139, dynamic workflows >= 2.1.154, auto memory >= 2.1.59.
|
|
35
|
+
|
|
36
|
+
Output: JSON to stdout. Exit 0 whenever a payload is produced (a non-green
|
|
37
|
+
preflight is a valid result, not an error). Exit 2 is reserved for invocation
|
|
38
|
+
errors where no useful payload can be produced.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import argparse
|
|
44
|
+
import json
|
|
45
|
+
import re
|
|
46
|
+
import subprocess
|
|
47
|
+
import sys
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
# Minimum Claude Code versions the unattended run depends on.
|
|
51
|
+
MIN_GOAL = (2, 1, 139)
|
|
52
|
+
MIN_WORKFLOWS = (2, 1, 154)
|
|
53
|
+
MIN_AUTOMEMORY = (2, 1, 59)
|
|
54
|
+
|
|
55
|
+
# Filenames that signal a scaffolded test framework (playwright / cypress / jest).
|
|
56
|
+
FRAMEWORK_MARKERS = (
|
|
57
|
+
"playwright.config.ts",
|
|
58
|
+
"playwright.config.js",
|
|
59
|
+
"playwright.config.mjs",
|
|
60
|
+
"cypress.config.ts",
|
|
61
|
+
"cypress.config.js",
|
|
62
|
+
"cypress.config.mjs",
|
|
63
|
+
"cypress.json",
|
|
64
|
+
"jest.config.ts",
|
|
65
|
+
"jest.config.js",
|
|
66
|
+
"vitest.config.ts",
|
|
67
|
+
"vitest.config.js",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# TEA test-artifact subdirectories the gates write into (relative to test_artifacts root).
|
|
71
|
+
TEA_ARTIFACT_DIRS = ("test-design", "test-reviews", "traceability")
|
|
72
|
+
|
|
73
|
+
# TEA config flags worth surfacing to the LLM verbatim (mechanical read, no interpretation).
|
|
74
|
+
TEA_FLAG_KEYS = (
|
|
75
|
+
"test_execution_mode",
|
|
76
|
+
"tea_execution_mode",
|
|
77
|
+
"test_framework",
|
|
78
|
+
"test_stack_type",
|
|
79
|
+
"ci_platform",
|
|
80
|
+
"risk_threshold",
|
|
81
|
+
"tea_browser_automation",
|
|
82
|
+
"tea_capability_probe",
|
|
83
|
+
"test_artifacts",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Default protected branches when the caller doesn't override (mirrors customize.toml).
|
|
87
|
+
DEFAULT_PROTECTED = ("main", "master")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _run(cmd: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
|
|
91
|
+
"""Run a command, returning (returncode, stdout, stderr). 127 if not found."""
|
|
92
|
+
try:
|
|
93
|
+
proc = subprocess.run(
|
|
94
|
+
cmd,
|
|
95
|
+
cwd=str(cwd) if cwd else None,
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
timeout=30,
|
|
99
|
+
)
|
|
100
|
+
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
|
|
101
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
|
102
|
+
return 127, "", str(exc)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_semver(text: str) -> tuple[int, int, int] | None:
|
|
106
|
+
"""Extract the first dotted three-part version from text."""
|
|
107
|
+
match = re.search(r"(\d+)\.(\d+)\.(\d+)", text or "")
|
|
108
|
+
if not match:
|
|
109
|
+
return None
|
|
110
|
+
return tuple(int(g) for g in match.groups()) # type: ignore[return-value]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _cc_version() -> tuple[str | None, tuple[int, int, int] | None]:
|
|
114
|
+
"""Best-effort read of the installed Claude Code version."""
|
|
115
|
+
code, out, _ = _run(["claude", "--version"])
|
|
116
|
+
if code != 0:
|
|
117
|
+
return None, None
|
|
118
|
+
return out, _parse_semver(out)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _meets(version: tuple[int, int, int] | None, minimum: tuple[int, int, int]) -> bool:
|
|
122
|
+
return version is not None and version >= minimum
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _git(args: list[str], project_root: Path) -> tuple[int, str]:
|
|
126
|
+
code, out, _ = _run(["git", *args], cwd=project_root)
|
|
127
|
+
return code, out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _git_branch(project_root: Path) -> str | None:
|
|
131
|
+
code, out = _git(["rev-parse", "--abbrev-ref", "HEAD"], project_root)
|
|
132
|
+
if code != 0 or not out:
|
|
133
|
+
return None
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _git_clean(project_root: Path) -> bool | None:
|
|
138
|
+
code, out = _git(["status", "--porcelain"], project_root)
|
|
139
|
+
if code != 0:
|
|
140
|
+
return None
|
|
141
|
+
return out == ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _read_toml_or_yaml_flags(tea_config: Path) -> dict:
|
|
145
|
+
"""Read flat key: value scalars from the TEA config.
|
|
146
|
+
|
|
147
|
+
The TEA config is YAML in practice. To stay stdlib-only we parse the flat
|
|
148
|
+
`key: value` scalar lines we care about ourselves (no nesting is needed for
|
|
149
|
+
these flags). If `tomllib` ever applies (a .toml config) we try that first.
|
|
150
|
+
"""
|
|
151
|
+
if not tea_config.is_file():
|
|
152
|
+
return {}
|
|
153
|
+
text = tea_config.read_text(encoding="utf-8", errors="replace")
|
|
154
|
+
|
|
155
|
+
if tea_config.suffix.lower() == ".toml":
|
|
156
|
+
try:
|
|
157
|
+
import tomllib
|
|
158
|
+
|
|
159
|
+
data = tomllib.loads(text)
|
|
160
|
+
return {k: data[k] for k in TEA_FLAG_KEYS if k in data}
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
flags: dict = {}
|
|
165
|
+
for raw in text.splitlines():
|
|
166
|
+
line = raw.strip()
|
|
167
|
+
if not line or line.startswith("#") or ":" not in line:
|
|
168
|
+
continue
|
|
169
|
+
key, _, value = line.partition(":")
|
|
170
|
+
key = key.strip()
|
|
171
|
+
if key not in TEA_FLAG_KEYS:
|
|
172
|
+
continue
|
|
173
|
+
value = value.strip().strip('"').strip("'")
|
|
174
|
+
if value.lower() in ("true", "false"):
|
|
175
|
+
flags[key] = value.lower() == "true"
|
|
176
|
+
else:
|
|
177
|
+
flags[key] = value
|
|
178
|
+
return flags
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _tea_artifacts_root(project_root: Path, tea_flags: dict) -> Path:
|
|
182
|
+
"""Resolve the test_artifacts root, honoring a tea-config override if present."""
|
|
183
|
+
configured = tea_flags.get("test_artifacts")
|
|
184
|
+
if isinstance(configured, str) and configured:
|
|
185
|
+
resolved = configured.replace("{project-root}", str(project_root))
|
|
186
|
+
path = Path(resolved)
|
|
187
|
+
return path if path.is_absolute() else (project_root / path)
|
|
188
|
+
return project_root / "_bmad-output" / "test-artifacts"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _framework_present(project_root: Path) -> bool:
|
|
192
|
+
"""A framework config at the project root signals a scaffolded test framework."""
|
|
193
|
+
for marker in FRAMEWORK_MARKERS:
|
|
194
|
+
if (project_root / marker).is_file():
|
|
195
|
+
return True
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _test_artifacts_dirs(artifacts_root: Path) -> dict:
|
|
200
|
+
"""Map each TEA artifact subdir to whether it exists."""
|
|
201
|
+
return {name: (artifacts_root / name).is_dir() for name in TEA_ARTIFACT_DIRS}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _sprint_status_present(project_root: Path, impl_artifacts: Path) -> bool:
|
|
205
|
+
"""sprint-status.yaml controls test-design's System/Epic prompt (must exist to auto-run).
|
|
206
|
+
|
|
207
|
+
BMad writes it under the output tree; search the common locations rather
|
|
208
|
+
than hardcoding one path that may drift between installs.
|
|
209
|
+
"""
|
|
210
|
+
candidates = [
|
|
211
|
+
impl_artifacts / "sprint-status.yaml",
|
|
212
|
+
project_root / "_bmad-output" / "sprint-status.yaml",
|
|
213
|
+
]
|
|
214
|
+
for path in candidates:
|
|
215
|
+
if path.is_file():
|
|
216
|
+
return True
|
|
217
|
+
# Fall back to a bounded glob under the output tree.
|
|
218
|
+
output = project_root / "_bmad-output"
|
|
219
|
+
if output.is_dir():
|
|
220
|
+
for hit in output.rglob("sprint-status.yaml"):
|
|
221
|
+
if hit.is_file():
|
|
222
|
+
return True
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _project_context_count(project_root: Path) -> int:
|
|
227
|
+
"""Count project-context.md files (the persistent-facts source)."""
|
|
228
|
+
return sum(1 for _ in project_root.rglob("project-context.md"))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def build_report(
|
|
232
|
+
project_root: Path,
|
|
233
|
+
epic: str,
|
|
234
|
+
tea_config: Path,
|
|
235
|
+
impl_artifacts: Path,
|
|
236
|
+
protected_branches: tuple[str, ...],
|
|
237
|
+
) -> dict:
|
|
238
|
+
cc_raw, cc_ver = _cc_version()
|
|
239
|
+
goal_ok = _meets(cc_ver, MIN_GOAL)
|
|
240
|
+
workflows_ok = _meets(cc_ver, MIN_WORKFLOWS)
|
|
241
|
+
automemory_ok = _meets(cc_ver, MIN_AUTOMEMORY)
|
|
242
|
+
|
|
243
|
+
git_branch = _git_branch(project_root)
|
|
244
|
+
git_clean = _git_clean(project_root)
|
|
245
|
+
|
|
246
|
+
tea_flags = _read_toml_or_yaml_flags(tea_config)
|
|
247
|
+
artifacts_root = _tea_artifacts_root(project_root, tea_flags)
|
|
248
|
+
|
|
249
|
+
framework_present = _framework_present(project_root)
|
|
250
|
+
test_artifacts_dirs = _test_artifacts_dirs(artifacts_root)
|
|
251
|
+
sprint_status_present = _sprint_status_present(project_root, impl_artifacts)
|
|
252
|
+
project_context_count = _project_context_count(project_root)
|
|
253
|
+
|
|
254
|
+
checks = {
|
|
255
|
+
"cc_version": cc_raw,
|
|
256
|
+
"goal_ok": goal_ok,
|
|
257
|
+
"workflows_ok": workflows_ok,
|
|
258
|
+
"automemory_ok": automemory_ok,
|
|
259
|
+
"git_branch": git_branch,
|
|
260
|
+
"git_clean": git_clean,
|
|
261
|
+
"framework_present": framework_present,
|
|
262
|
+
"test_artifacts_dirs": test_artifacts_dirs,
|
|
263
|
+
"sprint_status_present": sprint_status_present,
|
|
264
|
+
"project_context_count": project_context_count,
|
|
265
|
+
"tea_flags": tea_flags,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
blockers: list[dict] = []
|
|
269
|
+
|
|
270
|
+
# --- Mechanical blockers (each counts toward the budget) ---
|
|
271
|
+
|
|
272
|
+
# Primitive versions: any one being below minimum blocks the unattended run.
|
|
273
|
+
if not (goal_ok and workflows_ok and automemory_ok):
|
|
274
|
+
below = []
|
|
275
|
+
if not goal_ok:
|
|
276
|
+
below.append("/goal>=%d.%d.%d" % MIN_GOAL)
|
|
277
|
+
if not workflows_ok:
|
|
278
|
+
below.append("workflows>=%d.%d.%d" % MIN_WORKFLOWS)
|
|
279
|
+
if not automemory_ok:
|
|
280
|
+
below.append("auto-memory>=%d.%d.%d" % MIN_AUTOMEMORY)
|
|
281
|
+
detail = (
|
|
282
|
+
"Claude Code %s is below the minimum for: %s"
|
|
283
|
+
% (cc_raw or "(version unreadable)", ", ".join(below))
|
|
284
|
+
)
|
|
285
|
+
blockers.append(
|
|
286
|
+
{
|
|
287
|
+
"id": "cc_version",
|
|
288
|
+
"kind": "version",
|
|
289
|
+
"severity": "high",
|
|
290
|
+
"detail": detail,
|
|
291
|
+
# The script can't upgrade the host; the LLM prompts the user.
|
|
292
|
+
"remediable": False,
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
if not framework_present:
|
|
297
|
+
blockers.append(
|
|
298
|
+
{
|
|
299
|
+
"id": "framework_present",
|
|
300
|
+
"kind": "framework",
|
|
301
|
+
"severity": "medium",
|
|
302
|
+
"detail": "No test framework config found at project root "
|
|
303
|
+
"(playwright/cypress/jest/vitest). ATDD halts without one.",
|
|
304
|
+
# Remediation pass scaffolds via bmad-testarch-framework.
|
|
305
|
+
"remediable": True,
|
|
306
|
+
}
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if git_clean is False:
|
|
310
|
+
blockers.append(
|
|
311
|
+
{
|
|
312
|
+
"id": "git_clean",
|
|
313
|
+
"kind": "git",
|
|
314
|
+
"severity": "medium",
|
|
315
|
+
"detail": "Working tree is dirty; a per-green-story commit needs a clean base.",
|
|
316
|
+
"remediable": True,
|
|
317
|
+
}
|
|
318
|
+
)
|
|
319
|
+
elif git_clean is None:
|
|
320
|
+
blockers.append(
|
|
321
|
+
{
|
|
322
|
+
"id": "git_repo",
|
|
323
|
+
"kind": "git",
|
|
324
|
+
"severity": "high",
|
|
325
|
+
"detail": "git status failed; project-root may not be a git repository.",
|
|
326
|
+
"remediable": False,
|
|
327
|
+
}
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
if git_branch is not None and git_branch in protected_branches:
|
|
331
|
+
blockers.append(
|
|
332
|
+
{
|
|
333
|
+
"id": "git_branch",
|
|
334
|
+
"kind": "git",
|
|
335
|
+
"severity": "medium",
|
|
336
|
+
"detail": "On protected branch '%s'; the epic must run on its own branch."
|
|
337
|
+
% git_branch,
|
|
338
|
+
"remediable": True,
|
|
339
|
+
}
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
budget = len(blockers)
|
|
343
|
+
return {
|
|
344
|
+
"green": budget == 0,
|
|
345
|
+
"budget": budget,
|
|
346
|
+
"blockers": blockers,
|
|
347
|
+
"checks": checks,
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _resolve(path_arg: str, project_root: Path) -> Path:
|
|
352
|
+
"""Expand a possible {project-root} token and resolve relative to project_root."""
|
|
353
|
+
resolved = path_arg.replace("{project-root}", str(project_root))
|
|
354
|
+
path = Path(resolved)
|
|
355
|
+
return path if path.is_absolute() else (project_root / path)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def main(argv: list[str] | None = None) -> int:
|
|
359
|
+
parser = argparse.ArgumentParser(
|
|
360
|
+
description="Mechanical preflight for an UltraCode-Goal epic run."
|
|
361
|
+
)
|
|
362
|
+
parser.add_argument("--project-root", required=True)
|
|
363
|
+
parser.add_argument("--epic", required=True)
|
|
364
|
+
parser.add_argument("--tea-config", required=True)
|
|
365
|
+
parser.add_argument("--impl-artifacts", required=True)
|
|
366
|
+
parser.add_argument(
|
|
367
|
+
"--protected-branch",
|
|
368
|
+
action="append",
|
|
369
|
+
default=None,
|
|
370
|
+
help="Protected branch name; repeatable. Defaults to main, master.",
|
|
371
|
+
)
|
|
372
|
+
args = parser.parse_args(argv)
|
|
373
|
+
|
|
374
|
+
project_root = Path(args.project_root).expanduser()
|
|
375
|
+
if not project_root.is_dir():
|
|
376
|
+
print(
|
|
377
|
+
json.dumps({"error": "project-root not found: %s" % project_root}),
|
|
378
|
+
file=sys.stderr,
|
|
379
|
+
)
|
|
380
|
+
return 2
|
|
381
|
+
|
|
382
|
+
project_root = project_root.resolve()
|
|
383
|
+
tea_config = _resolve(args.tea_config, project_root)
|
|
384
|
+
impl_artifacts = _resolve(args.impl_artifacts, project_root)
|
|
385
|
+
protected = tuple(args.protected_branch) if args.protected_branch else DEFAULT_PROTECTED
|
|
386
|
+
|
|
387
|
+
report = build_report(
|
|
388
|
+
project_root=project_root,
|
|
389
|
+
epic=args.epic,
|
|
390
|
+
tea_config=tea_config,
|
|
391
|
+
impl_artifacts=impl_artifacts,
|
|
392
|
+
protected_branches=protected,
|
|
393
|
+
)
|
|
394
|
+
print(json.dumps(report, indent=2))
|
|
395
|
+
return 0
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
if __name__ == "__main__":
|
|
399
|
+
sys.exit(main())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const { Installer } = require('../lib/installer');
|
|
3
|
+
const { UI } = require('../lib/ui');
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
command: 'install',
|
|
7
|
+
description: 'Install UltraCode Goal into your project',
|
|
8
|
+
options: [],
|
|
9
|
+
action: async () => {
|
|
10
|
+
try {
|
|
11
|
+
const ui = new UI();
|
|
12
|
+
const config = await ui.promptInstall();
|
|
13
|
+
|
|
14
|
+
if (config.cancelled) {
|
|
15
|
+
console.log(chalk.yellow('\nInstallation cancelled.'));
|
|
16
|
+
process.exit(0);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const installer = new Installer();
|
|
21
|
+
const result = await installer.install(config);
|
|
22
|
+
|
|
23
|
+
if (result && result.success) {
|
|
24
|
+
ui.displaySuccess(config.ucgFolder, config.ides, config._action);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
} else {
|
|
27
|
+
console.error(chalk.red('\nInstallation failed.'));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error(chalk.red('\nInstallation failed:'), error.message);
|
|
32
|
+
console.error(chalk.dim(error.stack));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
};
|