spec-agent-cli 0.1.0__py3-none-any.whl

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.
spec_agent/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Spec Agent CLI package."""
2
+
3
+ __all__ = ["main"]
4
+
5
+
6
+ def __getattr__(name: str):
7
+ if name == "main":
8
+ from spec_agent.cli import main
9
+
10
+ return main
11
+ raise AttributeError(name)
spec_agent/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from spec_agent.cli import main
6
+
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
spec_agent/cli.py ADDED
@@ -0,0 +1,70 @@
1
+ """Command-line interface for installing Spec Agent into repositories."""
2
+
3
+ # spec: CLI-001, CLI-007, CLI-008
4
+
5
+ from __future__ import annotations
6
+
7
+ import sys
8
+ from importlib.metadata import PackageNotFoundError, version
9
+ from pathlib import Path
10
+
11
+ import typer
12
+
13
+ from spec_agent.commands.init import cmd_init
14
+
15
+
16
+ app = typer.Typer(
17
+ no_args_is_help=True,
18
+ add_completion=True,
19
+ help="Install the strict Spec Agent workflow into a repository.",
20
+ )
21
+
22
+
23
+ def _version_callback(value: bool) -> None:
24
+ if not value:
25
+ return
26
+ try:
27
+ current = version("spec-agent-cli")
28
+ except PackageNotFoundError:
29
+ current = "dev"
30
+ print(f"spec-agent {current}")
31
+ raise typer.Exit()
32
+
33
+
34
+ @app.callback()
35
+ def root(
36
+ show_version: bool = typer.Option(
37
+ False,
38
+ "--version",
39
+ help="Show the installed version and exit.",
40
+ callback=_version_callback,
41
+ is_eager=True,
42
+ ),
43
+ ) -> None:
44
+ """Install and maintain Spec Agent project assets."""
45
+
46
+
47
+ @app.command("init")
48
+ def init_command(
49
+ repo: Path = typer.Option(Path("."), "--repo", help="Repository to initialize."),
50
+ check: bool = typer.Option(False, "--check", help="Report status; write nothing."),
51
+ force: bool = typer.Option(
52
+ False,
53
+ "--force",
54
+ help="Update locally changed managed skill files.",
55
+ ),
56
+ ) -> None:
57
+ """Install .agents/skills and scaffold missing specification files."""
58
+ raise typer.Exit(cmd_init(repo, check=check, force=force))
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ try:
63
+ app(args=argv, prog_name="spec-agent")
64
+ except SystemExit as error:
65
+ return error.code if isinstance(error.code, int) else 1
66
+ return 0
67
+
68
+
69
+ if __name__ == "__main__":
70
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Spec Agent command implementations."""
@@ -0,0 +1,29 @@
1
+ """Implementation of ``spec-agent init``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from spec_agent import installer
8
+
9
+
10
+ def _print_rows(rows: list[tuple[str, str]]) -> None:
11
+ width = max((len(path) for path, _ in rows), default=0)
12
+ for path, status in rows:
13
+ print(f" {path.ljust(width)} {status}")
14
+
15
+
16
+ def cmd_init(repo_root: Path, *, check: bool = False, force: bool = False) -> int:
17
+ root = repo_root.resolve()
18
+ if check:
19
+ rows = installer.check_status(root)
20
+ _print_rows(rows)
21
+ valid = {"up to date", "present"}
22
+ return 0 if all(status in valid for _, status in rows) else 1
23
+
24
+ rows = installer.install(root, force=force)
25
+ print(f"Spec Agent initialized in {root}")
26
+ _print_rows(rows)
27
+ print("")
28
+ print("Next: ask your agent to use spec-request-flow for a feature request.")
29
+ return 0 if all(status != installer.DAMAGED for _, status in rows) else 1
@@ -0,0 +1,201 @@
1
+ """Safely install packaged Spec Agent assets into a repository."""
2
+
3
+ # spec: CLI-002, CLI-003, CLI-004, CLI-005, CLI-006, CLI-007, CLI-008, CLI-009, CLI-010, CLI-011, SA-017, SA-018, SA-019
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from spec_agent import package_assets
11
+
12
+
13
+ MARKER_START = "<!-- spec-agent:rules:start -->"
14
+ MARKER_END = "<!-- spec-agent:rules:end -->"
15
+ DAMAGED = "damaged markers — fix or remove the spec-agent:rules markers, then re-run"
16
+
17
+ EMPTY_TIMELINE = """<!-- Generated from spec/evolution/events.jsonl by .agents/skills/spec-evolution/scripts/timeline.py; do not edit. -->
18
+ # Specification Evolution Timeline
19
+
20
+ ## Mermaid
21
+
22
+ ```mermaid
23
+ timeline
24
+ title Specification Evolution
25
+ ```
26
+ """
27
+
28
+ EMPTY_TRACEABILITY = json.dumps(
29
+ {
30
+ "behaviors": {},
31
+ "schema_version": 1,
32
+ "source": "derived-from-code-backlinks",
33
+ },
34
+ indent=2,
35
+ sort_keys=True,
36
+ ) + "\n"
37
+
38
+
39
+ def load_skill_files() -> dict[str, bytes]:
40
+ """Return every file from the three canonical skill directories."""
41
+ return package_assets.skill_files()
42
+
43
+
44
+ def render_rules_block(body: str) -> str:
45
+ return f"{MARKER_START}\n{body.strip()}\n{MARKER_END}"
46
+
47
+
48
+ def _find_block(text: str) -> tuple[int, int] | None:
49
+ starts = text.count(MARKER_START)
50
+ ends = text.count(MARKER_END)
51
+ if starts == 0 and ends == 0:
52
+ return None
53
+ if starts != 1 or ends != 1:
54
+ raise ValueError("unbalanced spec-agent rules markers")
55
+ start = text.find(MARKER_START)
56
+ end = text.find(MARKER_END)
57
+ if end < start:
58
+ raise ValueError("spec-agent rules end marker precedes start marker")
59
+ return start, end + len(MARKER_END)
60
+
61
+
62
+ def _managed_file_status(path: Path, expected: bytes) -> str:
63
+ if not path.is_file():
64
+ return "missing"
65
+ return "up to date" if path.read_bytes() == expected else "outdated"
66
+
67
+
68
+ def _rules_status(path: Path, block: str) -> str:
69
+ if not path.is_file():
70
+ return "missing"
71
+ text = path.read_text(encoding="utf-8")
72
+ try:
73
+ span = _find_block(text)
74
+ except ValueError:
75
+ return DAMAGED
76
+ if span is None:
77
+ return "missing"
78
+ return "up to date" if text[span[0] : span[1]] == block else "outdated"
79
+
80
+
81
+ def _apply_managed_file(path: Path, expected: bytes, force: bool) -> str:
82
+ if not path.is_file():
83
+ path.parent.mkdir(parents=True, exist_ok=True)
84
+ path.write_bytes(expected)
85
+ return "installed"
86
+ if path.read_bytes() == expected:
87
+ return "up to date"
88
+ if force:
89
+ path.write_bytes(expected)
90
+ return "updated"
91
+ return "outdated (--force to update)"
92
+
93
+
94
+ def _apply_rules(path: Path, block: str) -> str:
95
+ if not path.is_file():
96
+ path.write_text(block + "\n", encoding="utf-8")
97
+ return "created"
98
+ text = path.read_text(encoding="utf-8")
99
+ try:
100
+ span = _find_block(text)
101
+ except ValueError:
102
+ return DAMAGED
103
+ if span is None:
104
+ updated = text.rstrip("\n") + ("\n\n" if text.strip() else "") + block + "\n"
105
+ else:
106
+ updated = text[: span[0]] + block + text[span[1] :]
107
+ if updated == text:
108
+ return "up to date"
109
+ path.write_text(updated, encoding="utf-8")
110
+ return "rules block added" if span is None else "updated"
111
+
112
+
113
+ def _create_file(path: Path, content: str) -> str:
114
+ if path.exists():
115
+ return "present"
116
+ path.parent.mkdir(parents=True, exist_ok=True)
117
+ path.write_text(content, encoding="utf-8")
118
+ return "created"
119
+
120
+
121
+ def _create_directory(path: Path) -> str:
122
+ if path.is_dir():
123
+ return "present"
124
+ path.mkdir(parents=True, exist_ok=True)
125
+ return "created"
126
+
127
+
128
+ def _scaffold_items(repo_root: Path) -> list[tuple[str, str]]:
129
+ return [
130
+ ("SPEC.md", "present" if (repo_root / "SPEC.md").is_file() else "missing"),
131
+ (
132
+ "spec/features/",
133
+ "present" if (repo_root / "spec/features").is_dir() else "missing",
134
+ ),
135
+ (
136
+ "spec/evolution/events.jsonl",
137
+ "present"
138
+ if (repo_root / "spec/evolution/events.jsonl").is_file()
139
+ else "missing",
140
+ ),
141
+ (
142
+ "spec/evolution/timeline.md",
143
+ "present"
144
+ if (repo_root / "spec/evolution/timeline.md").is_file()
145
+ else "missing",
146
+ ),
147
+ (
148
+ "spec/traceability.json",
149
+ "present" if (repo_root / "spec/traceability.json").is_file() else "missing",
150
+ ),
151
+ ]
152
+
153
+
154
+ def check_status(repo_root: Path) -> list[tuple[str, str]]:
155
+ """Report installation state without writing anything."""
156
+ root = repo_root.resolve()
157
+ rows = [
158
+ (
159
+ f".agents/skills/{relative}",
160
+ _managed_file_status(root / ".agents/skills" / relative, content),
161
+ )
162
+ for relative, content in sorted(load_skill_files().items())
163
+ ]
164
+ block = render_rules_block(package_assets.rules_text())
165
+ rows.append(("AGENTS.md", _rules_status(root / "AGENTS.md", block)))
166
+ rows.extend(_scaffold_items(root))
167
+ return rows
168
+
169
+
170
+ def install(repo_root: Path, *, force: bool = False) -> list[tuple[str, str]]:
171
+ """Install agent assets and scaffold missing project-owned spec files."""
172
+ root = repo_root.resolve()
173
+ root.mkdir(parents=True, exist_ok=True)
174
+ rows = [
175
+ (
176
+ f".agents/skills/{relative}",
177
+ _apply_managed_file(root / ".agents/skills" / relative, content, force),
178
+ )
179
+ for relative, content in sorted(load_skill_files().items())
180
+ ]
181
+ block = render_rules_block(package_assets.rules_text())
182
+ rows.append(("AGENTS.md", _apply_rules(root / "AGENTS.md", block)))
183
+ rows.extend(
184
+ [
185
+ ("SPEC.md", _create_file(root / "SPEC.md", package_assets.project_spec_template())),
186
+ ("spec/features/", _create_directory(root / "spec/features")),
187
+ (
188
+ "spec/evolution/events.jsonl",
189
+ _create_file(root / "spec/evolution/events.jsonl", ""),
190
+ ),
191
+ (
192
+ "spec/evolution/timeline.md",
193
+ _create_file(root / "spec/evolution/timeline.md", EMPTY_TIMELINE),
194
+ ),
195
+ (
196
+ "spec/traceability.json",
197
+ _create_file(root / "spec/traceability.json", EMPTY_TRACEABILITY),
198
+ ),
199
+ ]
200
+ )
201
+ return rows
@@ -0,0 +1,56 @@
1
+ """Read canonical Spec Agent assets in source checkouts and built wheels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.resources import files
6
+ from pathlib import Path, PurePosixPath
7
+ from typing import Any
8
+
9
+
10
+ def _development_root() -> Path:
11
+ return Path(__file__).resolve().parents[2]
12
+
13
+
14
+ def _embedded_root() -> Any:
15
+ return files("spec_agent").joinpath("resources")
16
+
17
+
18
+ def skill_root() -> Any:
19
+ embedded = _embedded_root().joinpath("skills")
20
+ if embedded.is_dir():
21
+ return embedded
22
+ return _development_root() / ".agents/skills"
23
+
24
+
25
+ def rules_text() -> str:
26
+ embedded = _embedded_root().joinpath("project-rules.md")
27
+ if embedded.is_file():
28
+ return embedded.read_text(encoding="utf-8")
29
+ return (_development_root() / "AGENTS.md").read_text(encoding="utf-8")
30
+
31
+
32
+ def project_spec_template() -> str:
33
+ return (
34
+ skill_root()
35
+ .joinpath("spec-request-flow")
36
+ .joinpath("assets")
37
+ .joinpath("SPEC.template.md")
38
+ .read_text(encoding="utf-8")
39
+ )
40
+
41
+
42
+ def skill_files() -> dict[str, bytes]:
43
+ result: dict[str, bytes] = {}
44
+
45
+ def visit(directory: Any, prefix: PurePosixPath) -> None:
46
+ for entry in sorted(directory.iterdir(), key=lambda item: item.name):
47
+ if entry.name == "__pycache__" or entry.name.endswith((".pyc", ".pyo")):
48
+ continue
49
+ relative = prefix / entry.name
50
+ if entry.is_dir():
51
+ visit(entry, relative)
52
+ elif entry.is_file():
53
+ result[str(relative)] = entry.read_bytes()
54
+
55
+ visit(skill_root(), PurePosixPath())
56
+ return result
@@ -0,0 +1,18 @@
1
+ # Strict Spec Agent
2
+
3
+ Read root `SPEC.md`, then load one discoverable skill from `.agents/skills/`.
4
+ Feature packets contain product-only `spec.md` and `acceptance.md` files under
5
+ `spec/features/<feature-slug>/`.
6
+
7
+ | Situation | Load skill |
8
+ |---|---|
9
+ | Feature, fix, refactor, or behavior request | `spec-request-flow` |
10
+ | Possible spec/code/backlink disagreement | `spec-drift-sync` |
11
+ | Approved product change, rationale, or timeline | `spec-evolution` |
12
+
13
+ `spec-request-flow` ends after explicit approval and the Code Agent handoff. It
14
+ never writes implementation plans, code, tests, or engineering commands.
15
+
16
+ Code backlinks use `spec: BEHAVIOR-ID`. `spec-drift-sync` observes them and may
17
+ regenerate only non-normative `spec/traceability.json`; it never repairs code or
18
+ specifications. Product decision history lives in `spec/evolution/events.jsonl`.
@@ -0,0 +1,60 @@
1
+ ---
2
+ name: spec-drift-sync
3
+ description: Use when accepted specifications may disagree with implementations after refactors, merges, hotfixes, or code-only changes, or when backlinks, traceability, tests, or verification evidence may be missing or stale.
4
+ ---
5
+
6
+ # Spec Drift Sync
7
+
8
+ Act as a read-only observer of specification/code disagreement. Product specs
9
+ remain normative; code, tests, Git history, and passing verification are evidence.
10
+
11
+ <HARD-GATE>
12
+ Do not edit specifications, code, tests, code backlinks, or verification state. Do not
13
+ run an implementation or repair workflow. The only permitted write is regenerating the
14
+ non-normative derived traceability index from backlinks that already exist in code.
15
+ </HARD-GATE>
16
+
17
+ ## Detection
18
+
19
+ 1. Read root `SPEC.md`, both files in the affected feature packet, current code and
20
+ public behavior, existing `spec: BEHAVIOR-ID` backlinks, relevant Git changes, and
21
+ related product-decision history.
22
+ 2. Run `scripts/check.py check --repo REPOSITORY`. Use `--spec-root SPEC_SOURCE` only
23
+ for an intentionally scoped observation.
24
+ 3. Inspect meaning manually. A clean structural report proves identity consistency,
25
+ not semantic compliance.
26
+
27
+ ## Traceability refresh
28
+
29
+ Run `scripts/check.py sync --repo REPOSITORY --output spec/traceability.json` to replace
30
+ only the derived traceability index. It maps behavior IDs to existing code backlink
31
+ locations. It must not be copied into `spec.md` or `acceptance.md`, treated as product
32
+ authority, or edited to conceal missing links.
33
+
34
+ ## Evidence report
35
+
36
+ For every finding report:
37
+
38
+ - behavior ID and drift category;
39
+ - normative product evidence with specification path and rule;
40
+ - implementation evidence with code, interface, test, or Git location;
41
+ - structural identity problem or semantic conflict;
42
+ - observable impact and the authority decision required.
43
+
44
+ Structural findings include `phantom`, `dead-ref`, `silent-implementation`, `unlinked`,
45
+ and `stale-verification`. Semantic conflicts include code contradicting accepted
46
+ behavior, obsolete or ambiguous product rules, and behavior without product authority.
47
+
48
+ ## Authority handoff
49
+
50
+ Ask one product decision at a time and present viable outcomes with impact. Never
51
+ silently promote observed code to intended behavior.
52
+
53
+ - If intended product behavior changes, hand the decision to `spec-request-flow` for
54
+ clarification, both spec files, and approval.
55
+ - If the approved product behavior remains correct, hand the evidence to a separate
56
+ Code Agent to plan engineering changes.
57
+ - Use `spec-evolution` only to preserve an approved product authority decision.
58
+
59
+ Stop after the evidence report and handoff. Do not apply the selected repair, add
60
+ backlinks, execute tests, weaken the checker, or mark verification current.