CtrlK
BlogDocsLog inGet started
Tessl Logo

spec-driven-development/spec-as-source

Spec-driven development on OpenSpec, with mechanical spec-as-source enforcement: a custom 'spec-as-source' OpenSpec schema adds file-ownership (targets) and test-verification ([@test]) metadata to every capability spec, three scripts (link check, ownership check, manifest build) keep code and specs from drifting apart, plus requirement-gathering, spec-writer, work-review, and a session-handoff skill with a proactive context-warning hook and a packaged handoff memory: the skill ships the exporter, importer, graph model, facts pipeline, Neo4j Compose runtime and operating guide to load handoffs into a local, authenticated Neo4j graph and query them.

68

Quality

85%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

handoff_graph_facts.pyskills/handoff/scripts/

#!/usr/bin/env python3
# GENERATED FROM SPEC — DO NOT EDIT DIRECTLY
# Source: openspec/specs/handoff-graph-model/spec.md
"""Deterministic handoff -> facts pipeline driven by the declarative graph model.

Subcommands:
  validate-model          check the model offline
  collect ROOT...         list HANDOFF-*.md under each root with SHA-256
  extract ROOT...         write HANDOFF-NNN.facts.json beside each handoff
  ai-requests ROOT...     write pending AI requests (JSON lines) for an external executor
  ai-apply ROOT...        validate the executor's responses and cache them

A root is a project directory containing ``.handoff/`` or a ``.handoff/``
directory itself. Extraction reads Markdown, ``.meta.yaml`` and the project
identity file; it writes only ``*.facts.json``, and only after every handoff of
every root was extracted and validated. It never creates ``graph-project-id``
unless ``--create-identity`` is given. No subcommand calls a model: AI rules
work through request and response files and ``.handoff/graph-ai-cache.json``
(see references/graph-ai-executor.md of the handoff skill). Import the result
with ``import_handoff_graph.py import --facts <dir>``, the importer beside this
script.
"""

from __future__ import annotations

import argparse
import hashlib
import importlib.util
import json
import sys
from pathlib import Path
from typing import Any

MODEL_ENGINE = Path(__file__).resolve().parent / "handoff_graph_model.py"


def _load_engine() -> Any:
    name = "handoff_graph_model"
    if name not in sys.modules:
        spec = importlib.util.spec_from_file_location(name, MODEL_ENGINE)
        module = importlib.util.module_from_spec(spec)
        sys.modules[name] = module
        spec.loader.exec_module(module)
    return sys.modules[name]


engine = _load_engine()


class PipelineError(Exception):
    pass


def handoff_dir(root: Path) -> Path:
    root = root.expanduser()
    if (root / ".handoff").is_dir():
        return (root / ".handoff").resolve()
    if root.is_dir() and (root.name == ".handoff" or any(root.glob("HANDOFF-*.md"))):
        return root.resolve()
    raise PipelineError(f"{root}: no .handoff directory")


def collect(roots: list[str]) -> list[dict[str, str]]:
    found: dict[str, dict[str, str]] = {}
    for raw in roots:
        directory = handoff_dir(Path(raw))
        for path in sorted(directory.glob("HANDOFF-*.md")):
            if not path.is_file() or not path.name[len("HANDOFF-"):-len(".md")].isdigit():
                continue
            found[str(path)] = {"file": str(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
    return [found[key] for key in sorted(found)]


def _build(path: Path, create_identity: bool) -> tuple[dict[str, Any], dict[str, Any], str]:
    exporter = engine._load_exporter()
    spans: dict[str, dict[str, Any]] = {}
    try:
        document, _ = exporter.build_document(path, create_identity=create_identity, spans=spans)
        exporter.validate_document(document)
    except exporter.ExportError as exc:
        raise PipelineError(f"{path}: {exc}") from exc
    return document, spans, hashlib.sha256(path.read_bytes()).hexdigest()


def load_items(roots: list[str], model: Any, create_identity: bool = False) -> tuple[list[Any], list[dict[str, Any]]]:
    """Deterministic facts of every handoff under the roots; raises after collecting every error."""
    items, meta, errors = [], [], []
    for entry in collect(roots):
        path = Path(entry["file"])
        try:
            document, spans, digest = _build(path, create_identity)
            facts = engine.extract(document, model, spans)
        except PipelineError as exc:
            errors.append(str(exc))
            continue
        except engine.ExtractError as exc:
            errors.append(f"{path}: {exc}")
            continue
        items.append(engine.AIItem(document, facts, str(path.parent)))
        meta.append({"path": path, "sha256": digest})
    if errors:
        raise PipelineError("; ".join(errors))
    return items, meta


def _caches(items: list[Any]) -> dict[str, dict[str, Any]]:
    try:
        return {directory: engine.load_cache(directory) for directory in sorted({item.directory for item in items})}
    except engine.ExtractError as exc:
        raise PipelineError(str(exc)) from exc


def extract(roots: list[str], model: Any, create_identity: bool = False) -> dict[str, list[str]]:
    """Extract every handoff first, add cached AI edges, publish only when all succeeded."""
    items, meta = load_items(roots, model, create_identity)
    derived = engine.derived_edges(engine.build_requests(items, model), _caches(items))
    exporter = engine._load_exporter()
    outputs: list[tuple[Path, bytes]] = []
    for item, info in zip(items, meta):
        path = info["path"]
        try:
            facts = engine.add_derived(item.facts, derived)
            # Same repository-relative rule as the sidecar exporter: never an absolute path.
            data = engine.facts_document(model, item.document, facts, {
                "kind": "markdown", "file": path.name,
                "directory": exporter.relative_source_directory(path.parent), "sha256": info["sha256"],
            })
            engine.validate_facts(data, model)
        except engine.ExtractError as exc:
            raise PipelineError(f"{path}: {exc}") from exc
        outputs.append((path.with_name(f"{path.stem}.facts.json"), engine.canonical(data)))
    result: dict[str, list[str]] = {"created": [], "replaced": [], "unchanged": []}
    for destination, payload in outputs:
        result[exporter.publish_bytes(payload, destination)].append(str(destination))
    return result


def ai_requests(roots: list[str], model: Any) -> list[dict[str, Any]]:
    """Requests of every AI rule that have no cached answer yet."""
    items, _ = load_items(roots, model)
    caches = _caches(items)
    return [
        item.request for item in engine.build_requests(items, model)
        if item.request["request_id"] not in caches.get(item.directory, {})
    ]


def ai_apply(roots: list[str], model: Any, responses: Path) -> dict[str, int]:
    """Validate every response, then update the caches atomically; nothing is written on any error."""
    items, _ = load_items(roots, model)
    try:
        lines = responses.read_text(encoding="utf-8").splitlines()
        answers = engine.validate_responses(lines, engine.build_requests(items, model))
    except (OSError, UnicodeError) as exc:
        raise PipelineError(f"{responses}: could not be read") from exc
    except engine.ExtractError as exc:
        raise PipelineError(str(exc)) from exc
    caches = _caches(items)
    exporter = engine._load_exporter()
    written = 0
    for directory in sorted(answers):
        merged = {**caches.get(directory, {}), **answers[directory]}
        exporter.publish_bytes(engine.cache_bytes(merged), Path(directory) / engine.AI_CACHE_NAME)
        written += len(answers[directory])
    return {"answers": written, "caches": len(answers)}


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Handoff -> facts pipeline driven by the graph model")
    parser.add_argument("--model", default=None, help="graph model file (default: references/graph-model.yaml of the handoff skill)")
    sub = parser.add_subparsers(dest="command", required=True)
    sub.add_parser("validate-model", help="validate the graph model offline")
    for name, help_text in (
        ("collect", "list handoffs under the roots"),
        ("extract", "write facts beside each handoff"),
        ("ai-requests", "write pending AI requests as JSON lines"),
        ("ai-apply", "validate executor responses and store them in the AI caches"),
    ):
        cmd = sub.add_parser(name, help=help_text)
        cmd.add_argument("roots", nargs="+", help="project directories with .handoff/ or .handoff/ directories")
        if name == "extract":
            cmd.add_argument("--create-identity", action="store_true", help="create graph-project-id when a root has none")
        if name == "ai-requests":
            cmd.add_argument("--out", default=None, help="file to write (default: standard output)")
        if name == "ai-apply":
            cmd.add_argument("--responses", required=True, help="JSON lines produced by the executor")
    return parser


def main(argv: list[str] | None = None) -> int:
    args = _parser().parse_args(argv)
    try:
        model = engine.load_model(args.model)
    except engine.ModelError as exc:
        print(f"invalid model: {exc}", file=sys.stderr)
        return 1
    try:
        if args.command == "validate-model":
            print(
                f"valid: model version {model.version}, {len(model.nodes)} node type(s), "
                f"{len(model.relationships)} relationship type(s), {len(model.rules)} rule(s), {model.digest}"
            )
        elif args.command == "collect":
            print(json.dumps(collect(args.roots), indent=2))
        elif args.command == "ai-requests":
            pending = ai_requests(args.roots, model)
            text = "".join(json.dumps(item, ensure_ascii=False, sort_keys=True) + "\n" for item in pending)
            if args.out:
                Path(args.out).write_text(text, encoding="utf-8")
                print(f"ai-requests: {len(pending)} pending request(s) written to {args.out}")
            else:
                sys.stdout.write(text)
        elif args.command == "ai-apply":
            result = ai_apply(args.roots, model, Path(args.responses))
            print(f"ai-apply: {result['answers']} answer(s) stored in {result['caches']} cache file(s)")
        else:
            result = extract(args.roots, model, args.create_identity)
            print(
                f"extract: {sum(len(v) for v in result.values())} facts file(s) "
                f"({len(result['created'])} created, {len(result['replaced'])} replaced, "
                f"{len(result['unchanged'])} unchanged), model version {model.version}"
            )
    except PipelineError as exc:
        print(f"{args.command} failed: {exc}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

README.md

tile.json