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.
Files changed (34) hide show
  1. spec_agent_cli-0.1.0/.agents/skills/spec-drift-sync/SKILL.md +60 -0
  2. spec_agent_cli-0.1.0/.agents/skills/spec-drift-sync/scripts/check.py +404 -0
  3. spec_agent_cli-0.1.0/.agents/skills/spec-evolution/SKILL.md +57 -0
  4. spec_agent_cli-0.1.0/.agents/skills/spec-evolution/scripts/record.py +126 -0
  5. spec_agent_cli-0.1.0/.agents/skills/spec-evolution/scripts/timeline.py +138 -0
  6. spec_agent_cli-0.1.0/.agents/skills/spec-request-flow/SKILL.md +78 -0
  7. spec_agent_cli-0.1.0/.agents/skills/spec-request-flow/assets/SPEC.template.md +41 -0
  8. spec_agent_cli-0.1.0/.agents/skills/spec-request-flow/assets/feature-acceptance.template.md +46 -0
  9. spec_agent_cli-0.1.0/.agents/skills/spec-request-flow/assets/feature-spec.template.md +71 -0
  10. spec_agent_cli-0.1.0/.agents/skills/spec-request-flow/references/spec-format.md +60 -0
  11. spec_agent_cli-0.1.0/.agents/skills/spec-request-flow/scripts/create_feature.py +63 -0
  12. spec_agent_cli-0.1.0/.gitignore +7 -0
  13. spec_agent_cli-0.1.0/AGENTS.md +18 -0
  14. spec_agent_cli-0.1.0/LICENSE +21 -0
  15. spec_agent_cli-0.1.0/PKG-INFO +86 -0
  16. spec_agent_cli-0.1.0/README.md +63 -0
  17. spec_agent_cli-0.1.0/SPEC.md +165 -0
  18. spec_agent_cli-0.1.0/pyproject.toml +52 -0
  19. spec_agent_cli-0.1.0/src/spec_agent/__init__.py +11 -0
  20. spec_agent_cli-0.1.0/src/spec_agent/__main__.py +9 -0
  21. spec_agent_cli-0.1.0/src/spec_agent/cli.py +70 -0
  22. spec_agent_cli-0.1.0/src/spec_agent/commands/__init__.py +1 -0
  23. spec_agent_cli-0.1.0/src/spec_agent/commands/init.py +29 -0
  24. spec_agent_cli-0.1.0/src/spec_agent/installer.py +201 -0
  25. spec_agent_cli-0.1.0/src/spec_agent/package_assets.py +56 -0
  26. spec_agent_cli-0.1.0/tests/test_agent_assets.py +148 -0
  27. spec_agent_cli-0.1.0/tests/test_cli.py +23 -0
  28. spec_agent_cli-0.1.0/tests/test_cli_installer.py +97 -0
  29. spec_agent_cli-0.1.0/tests/test_end_to_end.py +79 -0
  30. spec_agent_cli-0.1.0/tests/test_event_log.py +114 -0
  31. spec_agent_cli-0.1.0/tests/test_feature_scaffold.py +91 -0
  32. spec_agent_cli-0.1.0/tests/test_skill_workflows.py +171 -0
  33. spec_agent_cli-0.1.0/tests/test_spec_drift.py +283 -0
  34. 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())