Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
- run: python3 scripts/check-marker-block.py
- run: python3 scripts/check-marker-semantics.py
- run: python3 scripts/validate-examples.py
- run: python3 scripts/check-output-contract.py
- run: python3 scripts/validate-repo.py
- run: python3 scripts/check-savepoint-renderer.py
- run: python3 scripts/check-install-helper.py
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ python3 scripts/check-frontmatter.py
python3 scripts/check-marker-block.py
python3 scripts/check-marker-semantics.py
python3 scripts/validate-examples.py
python3 scripts/check-output-contract.py
python3 scripts/validate-repo.py
python3 scripts/check-savepoint-renderer.py
python3 scripts/check-install-helper.py
Expand Down
49 changes: 49 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,52 @@ portable skill entrypoint는 `skills/savepoint/scripts/savepoint.py`입니다. r

`inspect --json`은 파일과 marker가 valid이면 `0`, savepoint-like 파일을 읽었지만 invalid이면 `1`, 파일을 읽을 수 없거나 savepoint artifact가 아니면 `2`로 종료합니다.

최소 파일 흐름:

```bash
python3 scripts/savepoint.py init-input --output .savepoint/input.json
$EDITOR .savepoint/input.json
python3 scripts/savepoint.py save --input .savepoint/input.json --output .savepoint/SAVEPOINT.md --assert-no-active-commands --scan-redaction --validate
python3 scripts/savepoint.py inspect .savepoint/SAVEPOINT.md --json
```

짧은 savepoint를 JSON 편집 없이 만들 때:

```bash
python3 scripts/savepoint.py save \
--output .savepoint/SAVEPOINT.md \
--assert-no-active-commands --scan-redaction --validate \
--goal "focused fix 마무리" \
--current-state "구현은 끝난 상태" \
--next-action "최종 검증 suite 실행" \
--project-status passed \
--validation-command "python3 scripts/check-savepoint-renderer.py" \
--validation-result passed \
--validation-summary "focused renderer checks passed"
```

`--scan-redaction`을 쓰면 입력 JSON을 렌더 전에 먼저 스캔합니다. `.savepoint/input.json`에 raw secret을 넣지 마세요. `--delete-input-on-success`를 추가하면 resume-ready save가 성공했을 때만 `.savepoint/input.json`을 삭제합니다.

기존 자동화도 `scripts/savepoint.py`를 호출하게 바꿉니다.

| 이전 호출 | 현재 호출 |
|---|---|
| `scripts/render_savepoint.py --input ...` | `scripts/savepoint.py save --input ...` |
| `scripts/validate_savepoint.py ...` | `scripts/savepoint.py validate ...` |

프로젝트 검증 입력은 `validation.project`를 사용합니다.

| 이전 key | 현재 field |
|---|---|
| `project_validation` | `validation.project.commands`와 `validation.project.status` |
| `skipped_checks_next_validation` | `validation.project.next_validation` |
| `smallest_next_step` | `next_action` |
| `blockers` | `unresolved_blockers` |

`failed-expected` 또는 `not-run-justified`를 쓰면 사유와 다음 검증 명령을 함께 기록합니다.

`validation.project.status`는 문서에 나열된 영어 값을 사용합니다. 검증 명령 `result`는 `passed` 또는 `failed`처럼 canonical English 값을 쓰고, summary와 reason은 한국어도 괜찮습니다.

## 설치

추천 명령:
Expand All @@ -76,6 +122,8 @@ python3 scripts/install.py --target codex --scope repo --apply --add-gitignore

helper는 기본 dry-run입니다. 실제로 쓰려면 `--apply`가 필요합니다. repo-scope install에서 `--add-gitignore`를 주면 `.savepoint/`를 추가합니다.

Windows에서는 install helper나 일반 Git clone/worktree를 권장합니다. 일부 archive extraction 도구는 symlink를 제대로 처리하지 못할 수 있습니다.

## Runtime boundary

일반 create/load에서는 다음만 사용합니다.
Expand Down Expand Up @@ -107,6 +155,7 @@ python3 scripts/check-frontmatter.py
python3 scripts/check-marker-block.py
python3 scripts/check-marker-semantics.py
python3 scripts/validate-examples.py
python3 scripts/check-output-contract.py
python3 scripts/validate-repo.py
python3 scripts/check-savepoint-renderer.py
python3 scripts/check-install-helper.py
Expand Down
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,52 @@ The portable skill entrypoint is `skills/savepoint/scripts/savepoint.py`; reposi

`inspect --json` exits `0` when the file and marker are valid, `1` when a savepoint-like file is parsed but invalid, and `2` when the file cannot be read or is not a savepoint artifact.

Minimal file workflow:

```bash
python3 scripts/savepoint.py init-input --output .savepoint/input.json
$EDITOR .savepoint/input.json
python3 scripts/savepoint.py save --input .savepoint/input.json --output .savepoint/SAVEPOINT.md --assert-no-active-commands --scan-redaction --validate
python3 scripts/savepoint.py inspect .savepoint/SAVEPOINT.md --json
```

For a short savepoint without editing JSON:

```bash
python3 scripts/savepoint.py save \
--output .savepoint/SAVEPOINT.md \
--assert-no-active-commands --scan-redaction --validate \
--goal "finish the focused fix" \
--current-state "implementation is done" \
--next-action "run the final validation suite" \
--project-status passed \
--validation-command "python3 scripts/check-savepoint-renderer.py" \
--validation-result passed \
--validation-summary "focused renderer checks passed"
```

With `--scan-redaction`, the input JSON is scanned before rendering. Do not put raw secrets in `.savepoint/input.json`. Add `--delete-input-on-success` to remove `.savepoint/input.json` only after a resume-ready save succeeds.

Existing automation should call `scripts/savepoint.py`. Update old root-wrapper calls as:

| Old call | Current call |
|---|---|
| `scripts/render_savepoint.py --input ...` | `scripts/savepoint.py save --input ...` |
| `scripts/validate_savepoint.py ...` | `scripts/savepoint.py validate ...` |

Use `validation.project` for project validation input. Replace old top-level input keys as:

| Old key | Current field |
|---|---|
| `project_validation` | `validation.project.commands` plus `validation.project.status` |
| `skipped_checks_next_validation` | `validation.project.next_validation` |
| `smallest_next_step` | `next_action` |
| `blockers` | `unresolved_blockers` |

For `failed-expected` or `not-run-justified`, include the reason and next validation command.

Use the listed English `validation.project.status` values. Validation command `result` should use canonical English values such as `passed` or `failed`; summaries and reasons may be any language.

## Install

Recommended commands:
Expand All @@ -76,6 +122,8 @@ python3 scripts/install.py --target codex --scope repo --apply --add-gitignore

The helper defaults to dry-run. It writes files only with `--apply`. With repo-scope install, `--add-gitignore` appends `.savepoint/`.

On Windows, prefer the install helper or a normal Git clone/worktree. Archive extraction tools can mishandle symlinks.

Typical skill locations:

- Codex user skill: `$HOME/.agents/skills/savepoint/`
Expand Down Expand Up @@ -114,6 +162,7 @@ python3 scripts/check-frontmatter.py
python3 scripts/check-marker-block.py
python3 scripts/check-marker-semantics.py
python3 scripts/validate-examples.py
python3 scripts/check-output-contract.py
python3 scripts/validate-repo.py
python3 scripts/check-savepoint-renderer.py
python3 scripts/check-install-helper.py
Expand Down
105 changes: 105 additions & 0 deletions scripts/check-output-contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Validate evals/output-contract.json."""

from __future__ import annotations

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


ROOT = Path(__file__).resolve().parents[1]
DEFAULT_PATH = ROOT / "evals" / "output-contract.json"
REQUIRED_CATEGORIES = {
"artifact-contract",
"security-redaction",
"resume-ready-semantics",
"token-budget",
"no-unwanted-files",
"least-permission",
}
REQUIRED_CASE_IDS = {
"artifact-file-mode-01",
"text-mode-no-recovery-01",
"redaction-secret-01",
"resume-ready-not-run-justified-01",
"resume-ready-failed-expected-01",
"resume-ready-failed-blocking-01",
"least-permission-01",
}


def validate_contract(path: Path) -> list[str]:
errors: list[str] = []
try:
data = json.loads(path.read_text(encoding="utf-8-sig"))
except OSError as exc:
return [f"{path}: failed to read file: {exc}"]
except json.JSONDecodeError as exc:
return [f"{path}: invalid JSON: {exc}"]
Comment on lines +34 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In validate_contract, only FileNotFoundError and json.JSONDecodeError are caught when reading the contract file. If any other OSError occurs (such as PermissionError or IsADirectoryError), the script will crash with an unhandled exception instead of returning a clean list of errors.\n\nCatching OSError instead of FileNotFoundError makes the validation script much more robust.

def validate_contract(path: Path) -> list[str]:\n    errors: list[str] = []\n    try:\n        data = json.loads(path.read_text(encoding="utf-8-sig"))\n    except OSError as exc:\n        return [f"{path}: failed to read file: {exc}"]\n    except json.JSONDecodeError as exc:\n        return [f"{path}: invalid JSON: {exc}"]


if data.get("skill_name") != "savepoint":
errors.append(f"{path}: skill_name must be savepoint")
if data.get("version") != 1:
errors.append(f"{path}: version must be 1")

cases = data.get("cases")
if not isinstance(cases, list) or not cases:
errors.append(f"{path}: cases must be a non-empty list")
return errors

seen_ids: set[str] = set()
categories: set[str] = set()
for index, case in enumerate(cases):
if not isinstance(case, dict):
errors.append(f"{path}: case #{index} must be an object")
continue
errors.extend(validate_case(path, index, case))
case_id = case.get("id")
category = case.get("category")
if isinstance(case_id, str):
if case_id in seen_ids:
errors.append(f"{path}: duplicate case id: {case_id}")
seen_ids.add(case_id)
if isinstance(category, str):
categories.add(category)

for category in sorted(REQUIRED_CATEGORIES - categories):
errors.append(f"{path}: missing category: {category}")
for case_id in sorted(REQUIRED_CASE_IDS - seen_ids):
errors.append(f"{path}: missing case id: {case_id}")
return errors


def validate_case(path: Path, index: int, case: dict[str, Any]) -> list[str]:
errors: list[str] = []
for field in ["id", "category", "scenario"]:
value = case.get(field)
if not isinstance(value, str) or not value.strip():
errors.append(f"{path}: case #{index} has invalid {field}")
must = case.get("must")
if not isinstance(must, list) or not must:
errors.append(f"{path}: case #{index} must be a non-empty list")
elif any(not isinstance(item, str) or not item.strip() for item in must):
errors.append(f"{path}: case #{index} must entries must be non-empty strings")
Comment on lines +85 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a minor typo/grammatical issue in the error message on line 86: "must entries must be". It should be clarified to refer to the 'must' field/entries specifically.

    elif any(not isinstance(item, str) or not item.strip() for item in must):\n        errors.append(f"{path}: case #{index} 'must' entries must be non-empty strings")

return errors


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--path", type=Path, default=DEFAULT_PATH)
args = parser.parse_args(argv)

errors = validate_contract(args.path)
if errors:
for error in errors:
print(f"error: {error}", file=sys.stderr)
return 1
print("ok: output contract")
return 0


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