Skip to content

Commit efe29d0

Browse files
feat(spec): steering scan/scaffold API and tasks CLI
Expose scan_steering_files and scaffold_steering_files for .cecli/STEERING.md and .cecli/steering/*.md so BrightVision and bright-vision-tasks can list and create project steering without duplicating logic in the HTTP layer. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 64bfdaa commit efe29d0

3 files changed

Lines changed: 164 additions & 1 deletion

File tree

cecli/spec/steering.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,30 @@
33

44
from __future__ import annotations
55

6+
from dataclasses import dataclass
67
from pathlib import Path
78

9+
STEERING_MAIN_RELPATH = ".cecli/STEERING.md"
10+
STEERING_FRAGMENTS_DIR_RELPATH = ".cecli/steering"
11+
12+
DEFAULT_STEERING_TEMPLATE = """\
13+
# Project steering
14+
15+
Rules the spec agent and implementation turns should follow across **all** tasks in this repo.
16+
17+
## Stack & conventions
18+
19+
- Language / framework:
20+
- Test command:
21+
- Avoid:
22+
23+
## Spec discipline
24+
25+
- EARS: ### REQ-NNN with **WHEN** … **THE** system **SHALL** …
26+
- Keep design and tasks_md aligned with every REQ id.
27+
- Do not mark implementation done until requirements pass EARS lint.
28+
"""
29+
830
SPEC_FOCUS_INSTRUCTIONS = """\
931
## Spec-focus mode (BrightVision)
1032
@@ -42,6 +64,76 @@ def workspace_lib_missing(workspace: str | Path) -> bool:
4264
"""
4365

4466

67+
@dataclass(frozen=True)
68+
class SteeringFileRecord:
69+
relpath: str
70+
size_bytes: int
71+
nonempty: bool
72+
73+
74+
@dataclass(frozen=True)
75+
class SteeringFilesSnapshot:
76+
main: SteeringFileRecord | None
77+
fragments: tuple[SteeringFileRecord, ...]
78+
79+
@property
80+
def has_content(self) -> bool:
81+
if self.main and self.main.nonempty:
82+
return True
83+
return any(fragment.nonempty for fragment in self.fragments)
84+
85+
@property
86+
def file_count(self) -> int:
87+
count = 0
88+
if self.main and self.main.nonempty:
89+
count += 1
90+
count += sum(1 for fragment in self.fragments if fragment.nonempty)
91+
return count
92+
93+
94+
def _steering_file_record(root: Path, relpath: str) -> SteeringFileRecord | None:
95+
path = root / relpath
96+
if not path.is_file():
97+
return None
98+
try:
99+
size_bytes = path.stat().st_size
100+
text = path.read_text(encoding="utf-8").strip()
101+
except OSError:
102+
return None
103+
return SteeringFileRecord(
104+
relpath=relpath.replace("\\", "/"),
105+
size_bytes=size_bytes,
106+
nonempty=bool(text),
107+
)
108+
109+
110+
def scan_steering_files(workspace: str | Path) -> SteeringFilesSnapshot:
111+
"""List ``.cecli/STEERING.md`` and ``.cecli/steering/*.md`` with sizes."""
112+
root = Path(workspace).resolve()
113+
main = _steering_file_record(root, STEERING_MAIN_RELPATH)
114+
fragments: list[SteeringFileRecord] = []
115+
frag_dir = root / ".cecli" / "steering"
116+
if frag_dir.is_dir():
117+
for path in sorted(frag_dir.glob("*.md")):
118+
rel = str(path.relative_to(root)).replace("\\", "/")
119+
record = _steering_file_record(root, rel)
120+
if record is not None:
121+
fragments.append(record)
122+
return SteeringFilesSnapshot(main=main, fragments=tuple(fragments))
123+
124+
125+
def scaffold_steering_files(workspace: str | Path) -> list[str]:
126+
"""Create ``.cecli/STEERING.md`` from template when missing. Returns new relpaths."""
127+
root = Path(workspace).resolve()
128+
created: list[str] = []
129+
main_path = root / ".cecli" / "STEERING.md"
130+
if not main_path.is_file():
131+
main_path.parent.mkdir(parents=True, exist_ok=True)
132+
main_path.write_text(DEFAULT_STEERING_TEMPLATE, encoding="utf-8")
133+
created.append(STEERING_MAIN_RELPATH)
134+
return created
135+
136+
45137
def load_steering_markdown(workspace: str | Path) -> str:
46138
"""Load ``.cecli/STEERING.md`` and ``.cecli/steering/*.md`` if present."""
47139
root = Path(workspace).resolve()

cecli/spec/tasks_cli.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
next_open_implementation_step,
1616
)
1717
from cecli.spec.pubspec_repair import repair_pubspec_dependencies
18+
from cecli.spec.steering import scaffold_steering_files, scan_steering_files
1819
from cecli.spec.todos import WorkspaceTodos
1920

2021

@@ -87,6 +88,33 @@ def cmd_sync_agent(workspace: Path) -> int:
8788
return 0
8889

8990

91+
def _steering_payload(snapshot) -> dict:
92+
return {
93+
"has_content": snapshot.has_content,
94+
"file_count": snapshot.file_count,
95+
"main": asdict(snapshot.main) if snapshot.main else None,
96+
"fragments": [asdict(fragment) for fragment in snapshot.fragments],
97+
}
98+
99+
100+
def cmd_steering_scan(workspace: Path) -> int:
101+
snapshot = scan_steering_files(workspace)
102+
print(json.dumps(_steering_payload(snapshot), indent=2))
103+
return 0
104+
105+
106+
def cmd_steering_scaffold(workspace: Path) -> int:
107+
created = scaffold_steering_files(workspace)
108+
snapshot = scan_steering_files(workspace)
109+
print(
110+
json.dumps(
111+
{"created": created, **_steering_payload(snapshot)},
112+
indent=2,
113+
)
114+
)
115+
return 0
116+
117+
90118
def cmd_repair_pubspec(workspace: Path, *, apply: bool) -> int:
91119
result = repair_pubspec_dependencies(workspace, apply=apply)
92120
print(
@@ -129,6 +157,11 @@ def build_parser() -> argparse.ArgumentParser:
129157
"--apply", action="store_true", help="Run flutter pub add or edit pubspec.yaml"
130158
)
131159

160+
p_steer = sub.add_parser("steering", help="Project steering files (.cecli/STEERING.md)")
161+
steer_sub = p_steer.add_subparsers(dest="steering_cmd", required=True)
162+
steer_sub.add_parser("scan", help="List steering markdown files as JSON")
163+
steer_sub.add_parser("scaffold", help="Create STEERING.md template when missing")
164+
132165
return parser
133166

134167

@@ -148,6 +181,12 @@ def main(argv: list[str] | None = None) -> int:
148181
return cmd_sync_agent(workspace)
149182
if args.command == "repair-pubspec":
150183
return cmd_repair_pubspec(workspace, apply=args.apply)
184+
if args.command == "steering":
185+
if args.steering_cmd == "scan":
186+
return cmd_steering_scan(workspace)
187+
if args.steering_cmd == "scaffold":
188+
return cmd_steering_scaffold(workspace)
189+
parser.error(f"unknown steering command: {args.steering_cmd}")
151190
parser.error(f"unknown command: {args.command}")
152191
return 2
153192

tests/spec/test_spec_steering.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
import unittest
77
from pathlib import Path
88

9-
from cecli.spec.steering import build_spec_focus_preamble, load_steering_markdown
9+
from cecli.spec.steering import (
10+
STEERING_MAIN_RELPATH,
11+
build_spec_focus_preamble,
12+
load_steering_markdown,
13+
scaffold_steering_files,
14+
scan_steering_files,
15+
)
1016

1117

1218
class TestSpecSteering(unittest.TestCase):
@@ -29,6 +35,32 @@ def test_preamble_includes_spec_focus(self):
2935
pre = build_spec_focus_preamble(tmp)
3036
self.assertIn("Spec-focus mode", pre)
3137

38+
def test_scan_steering_files(self):
39+
with tempfile.TemporaryDirectory() as tmp:
40+
root = Path(tmp)
41+
(root / ".cecli").mkdir()
42+
(root / ".cecli" / "STEERING.md").write_text("Rules here.", encoding="utf-8")
43+
steering = root / ".cecli" / "steering"
44+
steering.mkdir()
45+
(steering / "security.md").write_text("", encoding="utf-8")
46+
(steering / "style.md").write_text("Tabs not spaces.", encoding="utf-8")
47+
snapshot = scan_steering_files(root)
48+
self.assertTrue(snapshot.has_content)
49+
self.assertEqual(snapshot.file_count, 2)
50+
self.assertIsNotNone(snapshot.main)
51+
self.assertTrue(snapshot.main.nonempty)
52+
self.assertEqual(len(snapshot.fragments), 2)
53+
self.assertFalse(snapshot.fragments[0].nonempty)
54+
55+
def test_scaffold_steering_creates_main_once(self):
56+
with tempfile.TemporaryDirectory() as tmp:
57+
root = Path(tmp)
58+
created = scaffold_steering_files(root)
59+
self.assertEqual(created, [STEERING_MAIN_RELPATH])
60+
self.assertTrue((root / ".cecli" / "STEERING.md").is_file())
61+
again = scaffold_steering_files(root)
62+
self.assertEqual(again, [])
63+
3264

3365
if __name__ == "__main__":
3466
unittest.main()

0 commit comments

Comments
 (0)