Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Framework-owned templates for status, state, agent instructions, ADRs, manual-engine specs, and cron prompt skeletons.
- `scripts/validate-templates.py` and template tests for the M1 template set.
- `references/state-schema.json` and `scripts/validate-state.py` for validating machine-readable pipeline state.
- `pyproject.toml` with development dependencies and Ruff configuration.
- Initial repository scaffolding: PLAN.md (v2.0 roadmap), README, AGENTS.md (dogfooded), CONTRIBUTING, SECURITY, LICENSE (MIT).
Expand Down
13 changes: 13 additions & 0 deletions references/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ Long-form documentation. Each file is referenced from a short overview elsewhere
- `integration-openspec.md` — OpenSpec as default SPEC/APPLY/ARCHIVE engine; phase-to-command map; `manual` fallback contract
- `handoff-backends.md` — pluggable handoff/discussion backends (`local` default + `github` opt-in); tradeoff and mention discipline
- `state-schema.json` — JSON Schema for `STATE.json`

## Template inventory

The reusable templates live under [`../templates/`](../templates/):

- `STATUS.md` — human-readable project status.
- `STATE.json` — machine-readable phase state validated by `state-schema.json`.
- `AGENTS.md` — portable agent instructions for a project root.
- `ADR.md` — Architecture Decision Record.
- `spec-delta.md` and `project.md` — manual engine fallback only; OpenSpec owns native spec artifacts when `spec_engine = openspec`.
- `cron-pipeline-manager.txt` and `cron-single-project.txt` — skeletal cron prompt templates completed in M5.
- `handoff-schema.yaml` is deferred to M12.
- `templates/gates/*.yaml` are deferred to M6.
- `borrowed/` — engineering-technique docs forked from `agent-skills` (MIT), with attribution; see [`borrowed/README.md`](borrowed/README.md) for the policy

See [PLAN.md §3.1](../PLAN.md#31-repository-layout) for the canonical inventory.
248 changes: 248 additions & 0 deletions scripts/validate-templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""Validate framework-owned templates."""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any

EXIT_SUCCESS = 0
EXIT_USER_ERROR = 1
EXIT_RUNTIME_ERROR = 2

EXPECTED_TEMPLATES = [
"STATUS.md",
"STATE.json",
"AGENTS.md",
"ADR.md",
"spec-delta.md",
"project.md",
"cron-pipeline-manager.txt",
"cron-single-project.txt",
]

COMMENT_PREFIXES = {
".md": "<!--",
".txt": "#",
}

MANUAL_FALLBACK_TEMPLATES = ["spec-delta.md", "project.md"]


class TemplateValidationError(Exception):
"""User-correctable template validation error."""

def __init__(self, errors: list[dict[str, Any]]) -> None:
self.errors = errors
super().__init__(errors[0]["message"] if errors else "validation failed")


class RuntimeDependencyError(Exception):
"""Required validation dependency is missing or failed."""


def parse_args(argv: list[str]) -> argparse.Namespace:
repo_root = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description="Validate SDD Framework templates.")
parser.add_argument(
"--templates-dir",
type=Path,
default=repo_root / "templates",
help="Path to the templates directory",
)
parser.add_argument(
"--schema",
type=Path,
default=repo_root / "references" / "state-schema.json",
help="Path to the STATE.json schema",
)
parser.add_argument(
"--format",
choices=("tty", "json"),
default="tty",
help="Output format",
)
return parser.parse_args(argv)


def load_json(path: Path, kind: str) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise TemplateValidationError(
[{"type": "missing", "path": str(path), "message": f"{kind} not found"}]
) from exc
except json.JSONDecodeError as exc:
raise TemplateValidationError(
[
{
"type": "json",
"path": str(path),
"message": f"invalid JSON: {exc.msg}",
"line": exc.lineno,
"column": exc.colno,
}
]
) from exc
except OSError as exc:
raise RuntimeDependencyError(f"could not read {kind}: {exc}") from exc


def json_path(error_path: Any) -> str:
parts = [str(part) for part in error_path]
return ".".join(parts) if parts else "$"


def validate_state_template(
state_path: Path, schema_path: Path
) -> list[dict[str, Any]]:
try:
from jsonschema import Draft202012Validator, FormatChecker
except ModuleNotFoundError as exc:
raise RuntimeDependencyError(
"missing dependency 'jsonschema'; install with `pip install -e .[dev]`"
) from exc

schema = load_json(schema_path, "schema")
state = load_json(state_path, "STATE.json template")
validator = Draft202012Validator(schema, format_checker=FormatChecker())
errors = []
for error in sorted(validator.iter_errors(state), key=lambda item: list(item.path)):
errors.append(
{
"type": "schema",
"path": str(state_path),
"field": json_path(error.path),
"message": error.message,
}
)
return errors


def validate_text_template(path: Path) -> list[dict[str, Any]]:
errors = []
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError as exc:
raise RuntimeDependencyError(f"could not read template: {exc}") from exc

if not lines:
return [{"type": "empty", "path": str(path), "message": "template is empty"}]

expected_prefix = COMMENT_PREFIXES.get(path.suffix)
if expected_prefix is None:
errors.append(
{
"type": "unsupported",
"path": str(path),
"message": f"unsupported template suffix: {path.suffix}",
}
)
return errors

first_line = lines[0]
if not first_line.startswith(expected_prefix) or "Placeholders:" not in first_line:
errors.append(
{
"type": "header",
"path": str(path),
"message": "missing first-line placeholder header",
}
)

return errors


def validate_templates(
templates_dir: Path, schema_path: Path
) -> tuple[list[str], list[dict[str, Any]]]:
checked = []
errors = []

for relative_path in EXPECTED_TEMPLATES:
path = templates_dir / relative_path
if not path.is_file():
errors.append(
{"type": "missing", "path": str(path), "message": "template not found"}
)
continue

checked.append(relative_path)
if path.suffix == ".json":
errors.extend(validate_state_template(path, schema_path))
else:
errors.extend(validate_text_template(path))

for relative_path in MANUAL_FALLBACK_TEMPLATES:
path = templates_dir / relative_path
if not path.is_file():
continue
content = path.read_text(encoding="utf-8").lower()
if "manual engine fallback only" not in content or "openspec" not in content:
errors.append(
{
"type": "manual-fallback-label",
"path": str(path),
"message": (
"manual fallback template must mention OpenSpec "
"and manual engine fallback only"
),
}
)

return checked, errors


def emit_json(ok: bool, checked: list[str], errors: list[dict[str, Any]]) -> None:
print(json.dumps({"ok": ok, "checked": checked, "errors": errors}, indent=2))


def emit_tty(ok: bool, checked: list[str], errors: list[dict[str, Any]]) -> None:
if ok:
print(f"valid templates: {len(checked)} checked")
return

print("invalid templates")
for error in errors:
print(f"- {error['path']}: {error['message']}")


def main(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:])

try:
checked, errors = validate_templates(args.templates_dir, args.schema)
except TemplateValidationError as exc:
checked = []
errors = exc.errors
except RuntimeDependencyError as exc:
message = str(exc)
if args.format == "json":
emit_json(
False,
[],
[{"type": "runtime", "path": "$", "message": message}],
)
else:
print(f"error: {message}", file=sys.stderr)
return EXIT_RUNTIME_ERROR

if errors:
if args.format == "json":
emit_json(False, checked, errors)
else:
emit_tty(False, checked, errors)
return EXIT_USER_ERROR

if args.format == "json":
emit_json(True, checked, [])
else:
emit_tty(True, checked, [])
return EXIT_SUCCESS


if __name__ == "__main__":
raise SystemExit(main())
22 changes: 22 additions & 0 deletions templates/ADR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!-- Placeholders: {{ADR_NUMBER}}, {{ADR_TITLE}}, {{DATE}}, {{STATUS}}, {{CONTEXT}}, {{DECISION}}, {{CONSEQUENCES}} -->
# ADR {{ADR_NUMBER}}: {{ADR_TITLE}}

- Date: {{DATE}}
- Status: {{STATUS}}

## Context

{{CONTEXT}}

## Decision

{{DECISION}}

## Consequences

{{CONSEQUENCES}}

## Links

- Related spec: `<path>`
- Related plan: `<path>`
32 changes: 32 additions & 0 deletions templates/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!-- Placeholders: {{PROJECT_NAME}}, {{PROJECT_DESCRIPTION}}, {{PRIMARY_LANGUAGE}}, {{TEST_COMMAND}}, {{LINT_COMMAND}}, {{SPEC_ENGINE}}, {{HANDOFF_BACKEND}} -->
# {{PROJECT_NAME}}

## Description

{{PROJECT_DESCRIPTION}}

## Stack

- Primary language: {{PRIMARY_LANGUAGE}}
- Spec engine: {{SPEC_ENGINE}}
- Handoff backend: {{HANDOFF_BACKEND}}

## Commands

- Test: `{{TEST_COMMAND}}`
- Lint: `{{LINT_COMMAND}}`

## SDD Framework files

- `STATUS.md` is the human-readable project status.
- `STATE.json` is the machine-readable pipeline state.
- `.sdd/plans/` stores implementation plans.
- OpenSpec owns `openspec/` when `spec_engine = openspec`.

## Agent rules

- Read `STATE.json` and `STATUS.md` before changing phase state.
- Keep changes within the current phase unless the gate has passed.
- Update `STATUS.md` when the phase changes or a blocker appears.
- Do not rely on chat history as the source of truth.
- Preserve portability: anything outside agent-specific folders must be usable without a specific AI tool.
16 changes: 8 additions & 8 deletions templates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@ Copy-paste artifacts users drop into their projects. Each template:
2. Documents its placeholders in a header comment.
3. Has a corresponding usage section in the relevant `references/phase-*.md`.

## Planned files (v2.0)
## Template files (v2.0)

- `STATUS.md` — narrative, human-first.
- `STATE.json` — machine-readable state.
- `STATE.json` — machine-readable state; validates against `references/state-schema.json`.
- `AGENTS.md` — project root template.
- `ADR.md` — Architecture Decision Record.
- `spec-delta.md` — per-change spec.
- `project.md` — source-of-truth spec.
- `cron-pipeline-manager.txt` — multi-project cron prompt.
- `cron-single-project.txt` — single-project cron prompt.
- `handoff-schema.yaml` — cross-backend schema for handoff/discussion messages (consumed by `scripts/handoff.py`).
- `gates/` — one YAML per phase, machine-verifiable.
- `spec-delta.md` — per-change spec, manual engine fallback only.
- `project.md` — source-of-truth spec, manual engine fallback only.
- `cron-pipeline-manager.txt` — multi-project cron prompt skeleton; completed in M5.
- `cron-single-project.txt` — single-project cron prompt skeleton; completed in M5.
- `handoff-schema.yaml` — deferred to M12, where `scripts/handoff.py` validates it.
- `gates/` — one YAML per phase, deferred to M6 for the machine-verifiable gate schema.

See [PLAN.md §3.1](../PLAN.md#31-repository-layout) for the canonical inventory.
21 changes: 21 additions & 0 deletions templates/STATE.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"phase": "RESEARCH",
"next_step": "Write the initial problem statement and compare candidate approaches.",
"spec": "openspec/project.md",
"plan": ".sdd/plans/README.md",
"branch": "main",
"worktree": ".",
"last_commit": "0000000",
"last_agent": "human",
"last_update": "1970-01-01T00:00:00Z",
"lock": {
"agent": "none",
"started_at": "1970-01-01T00:00:00Z",
"ttl_minutes": 1
},
"retry_count": 0,
"last_error": null,
"flavor": "software",
"spec_engine": "openspec",
"handoff_backend": "local"
}
Loading