spec-agent-cli 0.1.0__tar.gz
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_cli-0.1.0/.agents/skills/spec-drift-sync/SKILL.md +60 -0
- spec_agent_cli-0.1.0/.agents/skills/spec-drift-sync/scripts/check.py +404 -0
- spec_agent_cli-0.1.0/.agents/skills/spec-evolution/SKILL.md +57 -0
- spec_agent_cli-0.1.0/.agents/skills/spec-evolution/scripts/record.py +126 -0
- spec_agent_cli-0.1.0/.agents/skills/spec-evolution/scripts/timeline.py +138 -0
- spec_agent_cli-0.1.0/.agents/skills/spec-request-flow/SKILL.md +78 -0
- spec_agent_cli-0.1.0/.agents/skills/spec-request-flow/assets/SPEC.template.md +41 -0
- spec_agent_cli-0.1.0/.agents/skills/spec-request-flow/assets/feature-acceptance.template.md +46 -0
- spec_agent_cli-0.1.0/.agents/skills/spec-request-flow/assets/feature-spec.template.md +71 -0
- spec_agent_cli-0.1.0/.agents/skills/spec-request-flow/references/spec-format.md +60 -0
- spec_agent_cli-0.1.0/.agents/skills/spec-request-flow/scripts/create_feature.py +63 -0
- spec_agent_cli-0.1.0/.gitignore +7 -0
- spec_agent_cli-0.1.0/AGENTS.md +18 -0
- spec_agent_cli-0.1.0/LICENSE +21 -0
- spec_agent_cli-0.1.0/PKG-INFO +86 -0
- spec_agent_cli-0.1.0/README.md +63 -0
- spec_agent_cli-0.1.0/SPEC.md +165 -0
- spec_agent_cli-0.1.0/pyproject.toml +52 -0
- spec_agent_cli-0.1.0/src/spec_agent/__init__.py +11 -0
- spec_agent_cli-0.1.0/src/spec_agent/__main__.py +9 -0
- spec_agent_cli-0.1.0/src/spec_agent/cli.py +70 -0
- spec_agent_cli-0.1.0/src/spec_agent/commands/__init__.py +1 -0
- spec_agent_cli-0.1.0/src/spec_agent/commands/init.py +29 -0
- spec_agent_cli-0.1.0/src/spec_agent/installer.py +201 -0
- spec_agent_cli-0.1.0/src/spec_agent/package_assets.py +56 -0
- spec_agent_cli-0.1.0/tests/test_agent_assets.py +148 -0
- spec_agent_cli-0.1.0/tests/test_cli.py +23 -0
- spec_agent_cli-0.1.0/tests/test_cli_installer.py +97 -0
- spec_agent_cli-0.1.0/tests/test_end_to_end.py +79 -0
- spec_agent_cli-0.1.0/tests/test_event_log.py +114 -0
- spec_agent_cli-0.1.0/tests/test_feature_scaffold.py +91 -0
- spec_agent_cli-0.1.0/tests/test_skill_workflows.py +171 -0
- spec_agent_cli-0.1.0/tests/test_spec_drift.py +283 -0
- spec_agent_cli-0.1.0/tests/test_timeline.py +74 -0
|
@@ -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.
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Dependency-free structural specification/code drift checker."""
|
|
3
|
+
|
|
4
|
+
# spec: SA-013, SA-014, SA-015
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
from dataclasses import asdict, dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Iterable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
UPPER_ID = re.compile(r"^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d{3}):\s*(.*)$")
|
|
18
|
+
SLUG_ID = re.compile(r"^((?:[CQ]:)?[a-z][a-z0-9]*(?:-[a-z0-9]+)+):\s*(.*)$")
|
|
19
|
+
CODE_REF = re.compile(r"^\s*~\s+(.+?)\s*$")
|
|
20
|
+
COMMENT = re.compile(r"(?:#|//|/\*|\*)\s*spec:\s*([^#/*\n]*)", re.I)
|
|
21
|
+
TOKEN = re.compile(
|
|
22
|
+
r"^(?:(?:[CQ]:)?[a-z][a-z0-9]*(?:-[a-z0-9]+)+|"
|
|
23
|
+
r"[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d{3})$"
|
|
24
|
+
)
|
|
25
|
+
SKIP_DIRS = {
|
|
26
|
+
".git",
|
|
27
|
+
".spec",
|
|
28
|
+
".venv",
|
|
29
|
+
"venv",
|
|
30
|
+
"node_modules",
|
|
31
|
+
"__pycache__",
|
|
32
|
+
"dist",
|
|
33
|
+
"build",
|
|
34
|
+
"evolution",
|
|
35
|
+
}
|
|
36
|
+
SKIP_FILES = {"AGENTS.md", "CLAUDE.md", "GEMINI.md"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class SpecEntry:
|
|
41
|
+
behavior_id: str
|
|
42
|
+
statement: str
|
|
43
|
+
spec_file: str
|
|
44
|
+
line: int
|
|
45
|
+
planned: bool
|
|
46
|
+
code_status: str | None
|
|
47
|
+
code_refs: tuple[str, ...]
|
|
48
|
+
verified_commit: str | None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class CodeBacklink:
|
|
53
|
+
behavior_id: str
|
|
54
|
+
code_file: str
|
|
55
|
+
line: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class DriftIssue:
|
|
60
|
+
kind: str
|
|
61
|
+
behavior_id: str
|
|
62
|
+
message: str
|
|
63
|
+
spec_file: str | None = None
|
|
64
|
+
code_file: str | None = None
|
|
65
|
+
line: int | None = None
|
|
66
|
+
ref: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class DriftReport:
|
|
71
|
+
repo_root: str
|
|
72
|
+
spec_root: str
|
|
73
|
+
entries: tuple[SpecEntry, ...]
|
|
74
|
+
backlinks: tuple[CodeBacklink, ...]
|
|
75
|
+
issues: tuple[DriftIssue, ...]
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def clean(self) -> bool:
|
|
79
|
+
return not self.issues
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def analyze_repo(
|
|
83
|
+
repo_root: Path | str, spec_root: Path | str | None = None
|
|
84
|
+
) -> DriftReport:
|
|
85
|
+
repo = Path(repo_root).resolve()
|
|
86
|
+
specs = _resolve_spec_sources(repo, spec_root)
|
|
87
|
+
entries = tuple(
|
|
88
|
+
entry for source in specs for entry in parse_spec_entries(source, repo)
|
|
89
|
+
)
|
|
90
|
+
backlinks = tuple(scan_code_backlinks(repo))
|
|
91
|
+
known = {entry.behavior_id: entry for entry in entries}
|
|
92
|
+
by_id: dict[str, list[CodeBacklink]] = {}
|
|
93
|
+
for backlink in backlinks:
|
|
94
|
+
by_id.setdefault(backlink.behavior_id, []).append(backlink)
|
|
95
|
+
|
|
96
|
+
issues: list[DriftIssue] = []
|
|
97
|
+
for backlink in backlinks:
|
|
98
|
+
if not _entry_exists(backlink.behavior_id, known):
|
|
99
|
+
issues.append(
|
|
100
|
+
DriftIssue(
|
|
101
|
+
"phantom",
|
|
102
|
+
backlink.behavior_id,
|
|
103
|
+
"code backlink has no spec entry",
|
|
104
|
+
code_file=backlink.code_file,
|
|
105
|
+
line=backlink.line,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
for entry in entries:
|
|
109
|
+
for ref in entry.code_refs:
|
|
110
|
+
path = ref.split(":", 1)[0]
|
|
111
|
+
if not (repo / path).exists():
|
|
112
|
+
issues.append(
|
|
113
|
+
DriftIssue(
|
|
114
|
+
"dead-ref",
|
|
115
|
+
entry.behavior_id,
|
|
116
|
+
"spec code reference does not exist",
|
|
117
|
+
spec_file=entry.spec_file,
|
|
118
|
+
line=entry.line,
|
|
119
|
+
ref=ref,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
if entry.planned and entry.behavior_id in by_id:
|
|
123
|
+
link = by_id[entry.behavior_id][0]
|
|
124
|
+
issues.append(
|
|
125
|
+
DriftIssue(
|
|
126
|
+
"silent-implementation",
|
|
127
|
+
entry.behavior_id,
|
|
128
|
+
"planned behavior already has a code backlink",
|
|
129
|
+
spec_file=entry.spec_file,
|
|
130
|
+
code_file=link.code_file,
|
|
131
|
+
line=link.line,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
if (
|
|
135
|
+
not entry.planned
|
|
136
|
+
and entry.code_status != "not_applicable"
|
|
137
|
+
and not entry.code_refs
|
|
138
|
+
and entry.behavior_id not in by_id
|
|
139
|
+
):
|
|
140
|
+
issues.append(
|
|
141
|
+
DriftIssue(
|
|
142
|
+
"unlinked",
|
|
143
|
+
entry.behavior_id,
|
|
144
|
+
"accepted behavior has no trace link",
|
|
145
|
+
spec_file=entry.spec_file,
|
|
146
|
+
line=entry.line,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
if entry.verified_commit and _is_git_repo(repo):
|
|
150
|
+
for ref in entry.code_refs:
|
|
151
|
+
path = ref.split(":", 1)[0]
|
|
152
|
+
if _git(
|
|
153
|
+
repo,
|
|
154
|
+
["log", "--format=%H", f"{entry.verified_commit}..HEAD", "--", path],
|
|
155
|
+
):
|
|
156
|
+
issues.append(
|
|
157
|
+
DriftIssue(
|
|
158
|
+
"stale-verification",
|
|
159
|
+
entry.behavior_id,
|
|
160
|
+
"linked code changed after verification",
|
|
161
|
+
spec_file=entry.spec_file,
|
|
162
|
+
line=entry.line,
|
|
163
|
+
ref=ref,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return DriftReport(
|
|
168
|
+
str(repo),
|
|
169
|
+
" | ".join(str(source) for source in specs),
|
|
170
|
+
entries,
|
|
171
|
+
backlinks,
|
|
172
|
+
tuple(issues),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def parse_spec_entries(spec_root: Path, repo_root: Path) -> Iterable[SpecEntry]:
|
|
177
|
+
paths = [spec_root] if spec_root.is_file() else sorted(spec_root.rglob("*.md"))
|
|
178
|
+
for path in paths:
|
|
179
|
+
if "evolution" in path.parts:
|
|
180
|
+
continue
|
|
181
|
+
text = _read(path)
|
|
182
|
+
verified = _frontmatter(text, "verified_commit")
|
|
183
|
+
code_status = _frontmatter(text, "code_status")
|
|
184
|
+
lines = text.splitlines()
|
|
185
|
+
index = 0
|
|
186
|
+
while index < len(lines):
|
|
187
|
+
match = UPPER_ID.match(lines[index]) or SLUG_ID.match(lines[index])
|
|
188
|
+
if not match:
|
|
189
|
+
index += 1
|
|
190
|
+
continue
|
|
191
|
+
refs: list[str] = []
|
|
192
|
+
cursor = index + 1
|
|
193
|
+
while cursor < len(lines) and not (
|
|
194
|
+
UPPER_ID.match(lines[cursor]) or SLUG_ID.match(lines[cursor])
|
|
195
|
+
):
|
|
196
|
+
ref = CODE_REF.match(lines[cursor])
|
|
197
|
+
if ref:
|
|
198
|
+
refs.append(ref.group(1).strip())
|
|
199
|
+
cursor += 1
|
|
200
|
+
statement = match.group(2).strip()
|
|
201
|
+
yield SpecEntry(
|
|
202
|
+
match.group(1),
|
|
203
|
+
statement,
|
|
204
|
+
_relative(path, repo_root),
|
|
205
|
+
index + 1,
|
|
206
|
+
"[planned]" in statement.lower(),
|
|
207
|
+
code_status,
|
|
208
|
+
tuple(refs),
|
|
209
|
+
verified,
|
|
210
|
+
)
|
|
211
|
+
index = cursor
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def scan_code_backlinks(repo_root: Path) -> Iterable[CodeBacklink]:
|
|
215
|
+
for path in sorted(repo_root.rglob("*")):
|
|
216
|
+
if _skip(path):
|
|
217
|
+
continue
|
|
218
|
+
for number, line in enumerate(_read(path).splitlines(), 1):
|
|
219
|
+
visible = _mask_strings(line)
|
|
220
|
+
for match in COMMENT.finditer(visible):
|
|
221
|
+
for raw in match.group(1).replace(",", " ").split():
|
|
222
|
+
token = raw.strip().strip("`'\"").rstrip(".,;")
|
|
223
|
+
if TOKEN.match(token):
|
|
224
|
+
yield CodeBacklink(token, _relative(path, repo_root), number)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def write_traceability(report: DriftReport, output: Path | str) -> dict[str, object]:
|
|
228
|
+
"""Write a deterministic, non-normative index derived from code backlinks."""
|
|
229
|
+
target = Path(output)
|
|
230
|
+
behaviors: dict[str, list[dict[str, object]]] = {}
|
|
231
|
+
for backlink in sorted(
|
|
232
|
+
report.backlinks,
|
|
233
|
+
key=lambda item: (item.behavior_id, item.code_file, item.line),
|
|
234
|
+
):
|
|
235
|
+
behaviors.setdefault(backlink.behavior_id, []).append(
|
|
236
|
+
{"path": backlink.code_file, "line": backlink.line}
|
|
237
|
+
)
|
|
238
|
+
document: dict[str, object] = {
|
|
239
|
+
"schema_version": 1,
|
|
240
|
+
"source": "derived-from-code-backlinks",
|
|
241
|
+
"behaviors": behaviors,
|
|
242
|
+
}
|
|
243
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
target.write_text(
|
|
245
|
+
json.dumps(document, indent=2, sort_keys=True) + "\n",
|
|
246
|
+
encoding="utf-8",
|
|
247
|
+
)
|
|
248
|
+
return document
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def render_text(report: DriftReport) -> str:
|
|
252
|
+
if report.clean:
|
|
253
|
+
return "=== SPEC-AGENT DRIFT CHECK ===\n\nCLEAN\n - no structural drift detected"
|
|
254
|
+
lines = [
|
|
255
|
+
"=== SPEC-AGENT DRIFT CHECK ===",
|
|
256
|
+
"",
|
|
257
|
+
f"DRIFT SUMMARY: {len(report.issues)} item(s)",
|
|
258
|
+
]
|
|
259
|
+
for issue in report.issues:
|
|
260
|
+
location = issue.code_file or issue.spec_file or ""
|
|
261
|
+
lines.append(
|
|
262
|
+
f" - {issue.kind}: {issue.behavior_id}: {issue.message} {location}".rstrip()
|
|
263
|
+
)
|
|
264
|
+
return "\n".join(lines)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def main(argv: list[str] | None = None) -> int:
|
|
268
|
+
parser = argparse.ArgumentParser(description="Check structural spec/code drift.")
|
|
269
|
+
sub = parser.add_subparsers(dest="command")
|
|
270
|
+
check = sub.add_parser("check")
|
|
271
|
+
check.add_argument("--repo", default=".")
|
|
272
|
+
check.add_argument("--spec-root")
|
|
273
|
+
check.add_argument("--json", action="store_true")
|
|
274
|
+
sync = sub.add_parser("sync")
|
|
275
|
+
sync.add_argument("--repo", default=".")
|
|
276
|
+
sync.add_argument("--spec-root")
|
|
277
|
+
sync.add_argument("--output", default="spec/traceability.json")
|
|
278
|
+
args = parser.parse_args(argv)
|
|
279
|
+
if args.command not in {"check", "sync"}:
|
|
280
|
+
parser.print_help()
|
|
281
|
+
return 2
|
|
282
|
+
try:
|
|
283
|
+
report = analyze_repo(args.repo, args.spec_root)
|
|
284
|
+
except (FileNotFoundError, NotADirectoryError) as error:
|
|
285
|
+
print(f"error: {error}")
|
|
286
|
+
return 2
|
|
287
|
+
if args.command == "sync":
|
|
288
|
+
output = Path(args.output)
|
|
289
|
+
if not output.is_absolute():
|
|
290
|
+
output = Path(args.repo).resolve() / output
|
|
291
|
+
try:
|
|
292
|
+
write_traceability(report, output)
|
|
293
|
+
except OSError as error:
|
|
294
|
+
print(f"error: {error}")
|
|
295
|
+
return 2
|
|
296
|
+
print(output)
|
|
297
|
+
return 0
|
|
298
|
+
print(json.dumps(asdict(report), indent=2) if args.json else render_text(report))
|
|
299
|
+
return 0 if report.clean else 1
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _resolve_spec_sources(
|
|
303
|
+
repo: Path, spec_root: Path | str | None
|
|
304
|
+
) -> tuple[Path, ...]:
|
|
305
|
+
if spec_root is not None:
|
|
306
|
+
candidate = Path(spec_root)
|
|
307
|
+
candidate = candidate if candidate.is_absolute() else repo / candidate
|
|
308
|
+
if not candidate.exists() or not (candidate.is_file() or candidate.is_dir()):
|
|
309
|
+
raise FileNotFoundError(f"spec source does not exist: {candidate}")
|
|
310
|
+
return (candidate.resolve(),)
|
|
311
|
+
root_spec = repo / "SPEC.md"
|
|
312
|
+
if root_spec.is_file():
|
|
313
|
+
sources = [root_spec.resolve()]
|
|
314
|
+
spec_dir = repo / "spec"
|
|
315
|
+
if spec_dir.is_dir():
|
|
316
|
+
sources.append(spec_dir.resolve())
|
|
317
|
+
return tuple(sources)
|
|
318
|
+
for name in ("spec", "specs"):
|
|
319
|
+
candidate = repo / name
|
|
320
|
+
if candidate.is_dir():
|
|
321
|
+
return (candidate.resolve(),)
|
|
322
|
+
raise FileNotFoundError("No spec source found. Expected SPEC.md, spec/, or specs/.")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _frontmatter(text: str, key: str) -> str | None:
|
|
326
|
+
if not text.startswith("---\n"):
|
|
327
|
+
return None
|
|
328
|
+
parts = text.split("---", 2)
|
|
329
|
+
if len(parts) < 3:
|
|
330
|
+
return None
|
|
331
|
+
match = re.search(rf"^\s*{re.escape(key)}:\s*([^\s#]+)\s*$", parts[1], re.M)
|
|
332
|
+
if not match:
|
|
333
|
+
return None
|
|
334
|
+
value = match.group(1).strip("'\"")
|
|
335
|
+
return None if value in {"", "null", "None"} else value
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _mask_strings(line: str) -> str:
|
|
339
|
+
output: list[str] = []
|
|
340
|
+
quote: str | None = None
|
|
341
|
+
escaped = False
|
|
342
|
+
for char in line:
|
|
343
|
+
if quote:
|
|
344
|
+
output.append(" ")
|
|
345
|
+
if escaped:
|
|
346
|
+
escaped = False
|
|
347
|
+
elif char == "\\":
|
|
348
|
+
escaped = True
|
|
349
|
+
elif char == quote:
|
|
350
|
+
quote = None
|
|
351
|
+
elif char in {"'", '"'}:
|
|
352
|
+
quote = char
|
|
353
|
+
output.append(" ")
|
|
354
|
+
else:
|
|
355
|
+
output.append(char)
|
|
356
|
+
return "".join(output)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _skip(path: Path) -> bool:
|
|
360
|
+
if any(part in SKIP_DIRS for part in path.parts) or path.name in SKIP_FILES:
|
|
361
|
+
return True
|
|
362
|
+
try:
|
|
363
|
+
return not path.is_file() or path.suffix.lower() == ".md"
|
|
364
|
+
except OSError:
|
|
365
|
+
return True
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _entry_exists(behavior_id: str, entries: dict[str, SpecEntry]) -> bool:
|
|
369
|
+
return (
|
|
370
|
+
behavior_id in entries
|
|
371
|
+
or f"C:{behavior_id}" in entries
|
|
372
|
+
or f"Q:{behavior_id}" in entries
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _is_git_repo(repo: Path) -> bool:
|
|
377
|
+
return _git(repo, ["rev-parse", "--is-inside-work-tree"]) == "true"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _git(repo: Path, args: list[str]) -> str:
|
|
381
|
+
try:
|
|
382
|
+
return subprocess.check_output(
|
|
383
|
+
["git", *args], cwd=repo, text=True, stderr=subprocess.DEVNULL
|
|
384
|
+
).strip()
|
|
385
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
386
|
+
return ""
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _read(path: Path) -> str:
|
|
390
|
+
try:
|
|
391
|
+
return path.read_text(encoding="utf-8", errors="ignore")
|
|
392
|
+
except OSError:
|
|
393
|
+
return ""
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _relative(path: Path, root: Path) -> str:
|
|
397
|
+
try:
|
|
398
|
+
return str(path.relative_to(root)).replace("\\", "/")
|
|
399
|
+
except ValueError:
|
|
400
|
+
return str(path).replace("\\", "/")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
if __name__ == "__main__":
|
|
404
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: spec-evolution
|
|
3
|
+
description: Use when an approved specification change, product authority decision, correction, terminal spec outcome, timeline, or UI history must be preserved as durable product evolution.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Spec Evolution
|
|
7
|
+
|
|
8
|
+
Preserve meaningful product decisions and contract transitions in one append-only, UI-ready log.
|
|
9
|
+
Current specifications own present truth; evolution owns why that truth changed.
|
|
10
|
+
|
|
11
|
+
## Event boundaries
|
|
12
|
+
|
|
13
|
+
Append one event only at these boundaries:
|
|
14
|
+
|
|
15
|
+
| Boundary | Record when |
|
|
16
|
+
|---|---|
|
|
17
|
+
| approved specification change | Product authority explicitly approves new or revised `spec.md` and `acceptance.md`. |
|
|
18
|
+
| product authority decision | A drift conflict or open product question receives an authoritative decision. |
|
|
19
|
+
| correction | A historical product record is wrong and a replacement names it in `supersedes`. |
|
|
20
|
+
| terminal spec outcome | Specification work completes or is explicitly stopped, blocked, or abandoned with a meaningful product state. |
|
|
21
|
+
|
|
22
|
+
Routine reads, tool calls, unanswered clarification, unchanged drift observations, and
|
|
23
|
+
engineering execution do not create product evolution events.
|
|
24
|
+
|
|
25
|
+
## Record the event
|
|
26
|
+
|
|
27
|
+
1. Run `scripts/record.py --help`, then append to `spec/evolution/events.jsonl` with a
|
|
28
|
+
unique ID and ISO-8601 timestamp.
|
|
29
|
+
2. Include summarized user intent, task type, behavior IDs, both spec files, assumptions,
|
|
30
|
+
approved product decision, rationale, normative spec delta, follow-ups, and
|
|
31
|
+
`supersedes` when correcting history.
|
|
32
|
+
3. Exclude implementation plans, code locations or deltas, engineering evidence, test
|
|
33
|
+
outcomes, verification status, commands, full prompts, secrets, credentials, copied
|
|
34
|
+
file contents, and unnecessary private data.
|
|
35
|
+
4. Never rewrite, reorder, or delete completed lines. Correct by appending a replacement.
|
|
36
|
+
|
|
37
|
+
## Regenerate and verify
|
|
38
|
+
|
|
39
|
+
1. Run `scripts/timeline.py --log spec/evolution/events.jsonl --output spec/evolution/timeline.md`.
|
|
40
|
+
2. Verify the event ID appears in both the JSONL source and generated timeline.
|
|
41
|
+
3. Report exact validation or line errors; never reconstruct missing product evidence.
|
|
42
|
+
|
|
43
|
+
UI consumers read `events.jsonl` for structured history. `timeline.md` is generated
|
|
44
|
+
Markdown/Mermaid presentation and is never normative.
|
|
45
|
+
|
|
46
|
+
## Ownership boundaries
|
|
47
|
+
|
|
48
|
+
- Root and feature product specs own current approved behavior.
|
|
49
|
+
- `events.jsonl` owns product intent, decisions, rationale, deltas, and supersession.
|
|
50
|
+
- Git and external engineering systems own code diffs, plans, tests, and verification.
|
|
51
|
+
- `timeline.md` owns no source data.
|
|
52
|
+
|
|
53
|
+
## Gotchas
|
|
54
|
+
|
|
55
|
+
- Do not append one event per prompt; record defined product boundaries only.
|
|
56
|
+
- Later prompts reuse the same feature and stable behavior IDs.
|
|
57
|
+
- Missing information remains missing rather than being inferred.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Append one validated, product-only specification evolution event."""
|
|
3
|
+
|
|
4
|
+
# spec: ELOG-001, ELOG-002, ELOG-003, ELOG-004
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
LIST_FIELDS = ("behavior_ids", "spec_files")
|
|
16
|
+
TEXT_DEFAULTS = {
|
|
17
|
+
"actor": "agent",
|
|
18
|
+
"status": "completed",
|
|
19
|
+
"assumptions": "",
|
|
20
|
+
"decision": "",
|
|
21
|
+
"rationale": "",
|
|
22
|
+
"spec_delta": "",
|
|
23
|
+
"follow_ups": "",
|
|
24
|
+
"supersedes": "",
|
|
25
|
+
}
|
|
26
|
+
REQUIRED_TEXT = ("id", "timestamp", "title", "task_type", "user_intent")
|
|
27
|
+
ENGINEERING_FIELDS = {
|
|
28
|
+
"code_files",
|
|
29
|
+
"evidence_refs",
|
|
30
|
+
"implementation_delta",
|
|
31
|
+
"drift_status",
|
|
32
|
+
"drift_summary",
|
|
33
|
+
"verification_result",
|
|
34
|
+
"verification_summary",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def append_event(log: Path, event: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
normalized = normalize_event(event)
|
|
40
|
+
existing_ids: set[str] = set()
|
|
41
|
+
if log.exists():
|
|
42
|
+
for line_number, line in enumerate(log.read_text(encoding="utf-8").splitlines(), 1):
|
|
43
|
+
if not line.strip():
|
|
44
|
+
continue
|
|
45
|
+
try:
|
|
46
|
+
value = json.loads(line)
|
|
47
|
+
except json.JSONDecodeError as error:
|
|
48
|
+
raise ValueError(f"existing log has invalid JSON at line {line_number}") from error
|
|
49
|
+
if not isinstance(value, dict):
|
|
50
|
+
raise ValueError(f"existing log line {line_number} is not an object")
|
|
51
|
+
event_id = value.get("id")
|
|
52
|
+
if isinstance(event_id, str):
|
|
53
|
+
existing_ids.add(event_id)
|
|
54
|
+
if normalized["id"] in existing_ids:
|
|
55
|
+
raise ValueError(f"duplicate event id: {normalized['id']}")
|
|
56
|
+
|
|
57
|
+
log.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
with log.open("a", encoding="utf-8") as handle:
|
|
59
|
+
handle.write(json.dumps(normalized, ensure_ascii=False, sort_keys=True))
|
|
60
|
+
handle.write("\n")
|
|
61
|
+
return normalized
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def normalize_event(event: dict[str, Any]) -> dict[str, Any]:
|
|
65
|
+
forbidden = sorted(ENGINEERING_FIELDS.intersection(event))
|
|
66
|
+
if forbidden:
|
|
67
|
+
raise ValueError(f"engineering field is not allowed: {', '.join(forbidden)}")
|
|
68
|
+
|
|
69
|
+
normalized: dict[str, Any] = {"schema_version": 2}
|
|
70
|
+
for field in REQUIRED_TEXT:
|
|
71
|
+
value = event.get(field)
|
|
72
|
+
if not isinstance(value, str) or not value.strip():
|
|
73
|
+
raise ValueError(f"missing required field: {field}")
|
|
74
|
+
normalized[field] = value.strip()
|
|
75
|
+
try:
|
|
76
|
+
datetime.fromisoformat(normalized["timestamp"].replace("Z", "+00:00"))
|
|
77
|
+
except ValueError as error:
|
|
78
|
+
raise ValueError("timestamp must be ISO-8601") from error
|
|
79
|
+
for field, default in TEXT_DEFAULTS.items():
|
|
80
|
+
value = event.get(field, default)
|
|
81
|
+
if not isinstance(value, str):
|
|
82
|
+
raise ValueError(f"field must be text: {field}")
|
|
83
|
+
normalized[field] = value.strip()
|
|
84
|
+
for field in LIST_FIELDS:
|
|
85
|
+
value = event.get(field, [])
|
|
86
|
+
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
|
87
|
+
raise ValueError(f"field must be a string list: {field}")
|
|
88
|
+
normalized[field] = [item.strip() for item in value if item.strip()]
|
|
89
|
+
return normalized
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main(argv: list[str] | None = None) -> int:
|
|
93
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
94
|
+
parser.add_argument("--log", type=Path, default=Path("spec/evolution/events.jsonl"))
|
|
95
|
+
parser.add_argument("--id", required=True)
|
|
96
|
+
parser.add_argument("--timestamp", required=True)
|
|
97
|
+
parser.add_argument("--title", required=True)
|
|
98
|
+
parser.add_argument("--task-type", required=True)
|
|
99
|
+
parser.add_argument("--user-intent", required=True)
|
|
100
|
+
parser.add_argument("--actor", default="agent")
|
|
101
|
+
parser.add_argument("--status", default="completed")
|
|
102
|
+
parser.add_argument("--behavior-id", action="append", default=[])
|
|
103
|
+
parser.add_argument("--spec-file", action="append", default=[])
|
|
104
|
+
parser.add_argument("--assumptions", default="")
|
|
105
|
+
parser.add_argument("--decision", default="")
|
|
106
|
+
parser.add_argument("--rationale", default="")
|
|
107
|
+
parser.add_argument("--spec-delta", default="")
|
|
108
|
+
parser.add_argument("--follow-ups", default="")
|
|
109
|
+
parser.add_argument("--supersedes", default="")
|
|
110
|
+
args = parser.parse_args(argv)
|
|
111
|
+
event = vars(args)
|
|
112
|
+
log = event.pop("log")
|
|
113
|
+
event["task_type"] = event.pop("task_type")
|
|
114
|
+
event["user_intent"] = event.pop("user_intent")
|
|
115
|
+
event["behavior_ids"] = event.pop("behavior_id")
|
|
116
|
+
event["spec_files"] = event.pop("spec_file")
|
|
117
|
+
try:
|
|
118
|
+
append_event(log, event)
|
|
119
|
+
except (OSError, ValueError) as error:
|
|
120
|
+
parser.error(str(error))
|
|
121
|
+
print(log)
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
raise SystemExit(main())
|