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
19 changes: 13 additions & 6 deletions autoform_cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -621,8 +621,8 @@ worktree or a submodule. Blockers are unmet dependency IDs or one of
`roadmap:not-a-formalizable-leaf`, `roadmap:missing-article-id`,
`roadmap:missing-article-revision`, and `roadmap:not-ready`. The claim target
prefers durable `article_id` metadata. `work list` fails explicitly if an
unfinished formalizable leaf lacks one; plan the missing IDs with `autoform
migrate article-ids` and add them to the frontmatter. `work context` may still
unfinished formalizable leaf lacks one; add the missing IDs with `autoform
migrate article-ids --write`. `work context` may still
select that article by its path ID to
report the migration blocker. An item whose article records `statement:
retracted` is a revision: it carries `revision` true in JSON, and the text of
Expand Down Expand Up @@ -717,18 +717,25 @@ autoform search blueprint "interlacing" --state proved --state fully_proved
are required. A query that starts with `-` goes last, after `--`. It exits 0
with or without hits. See the [search contract](#search-contract).

Plan durable article identity metadata without changing the blueprint:
Plan durable article identity metadata, and add it:

```bash
autoform migrate article-ids blueprint --json
autoform migrate article-ids blueprint --check
autoform migrate article-ids blueprint --write
```

`article_id` accepts opaque values in the form `af_` plus 24 lowercase hex
digits. The planner validates uniqueness, proposes deterministic IDs for
missing articles, includes exact source hashes, and is strictly read-only.
Runtime v3 and `autoform work` expose assigned IDs immediately; applying plans
and preserving publication routes across path moves remain follow-up changes.
missing articles, and includes exact source hashes; without `--write` it is
strictly read-only. `--write` adds each planned ID as the first frontmatter
line of the article that lacks one, or as a frontmatter block of its own, and
changes nothing else. It writes an article only while its bytes still have
the planned hash, checks every article before writing the first, and refuses
an article that changed or became a symlink after planning; after such a
refusal, running it again completes the plan. Runtime v3 and
`autoform work` expose assigned IDs immediately; preserving publication routes
across path moves remains a follow-up change.

Coordinate temporary cross-machine ownership without modifying the book:

Expand Down
27 changes: 23 additions & 4 deletions autoform_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from pathlib import Path

from . import status
from .article_identity import plan_article_ids
from .article_identity import ArticleIdWriteError, plan_article_ids, write_article_ids
from .audit import audit_blueprint
from .claims import CLAIM_TTL_S, ClaimBoard, ClaimTransportError, author_claim_key
from .doctor import diagnose_project
Expand Down Expand Up @@ -253,18 +253,24 @@ def main(argv: Sequence[str] | None = None) -> int:
claim_cleanup = claim_subparsers.add_parser("cleanup")
_add_claim_board_arguments(claim_cleanup)

migrate = subparsers.add_parser("migrate", help="inspect authored migration contracts")
migrate = subparsers.add_parser("migrate", help="plan or apply authored migrations")
migrate_subparsers = migrate.add_subparsers(dest="migrate_command", required=True)
article_ids = migrate_subparsers.add_parser(
"article-ids",
help="plan durable roadmap article identifiers without writing files",
help="plan durable roadmap article identifiers, or add them with --write",
)
article_ids.add_argument("blueprint_dir")
article_ids.add_argument(
article_ids_mode = article_ids.add_mutually_exclusive_group()
article_ids_mode.add_argument(
"--check",
action="store_true",
help="fail when an article is missing article_id frontmatter",
)
article_ids_mode.add_argument(
"--write",
action="store_true",
help="add each planned article_id to the frontmatter of the article missing one",
)
article_ids.add_argument("--json", action="store_true", help="write stable machine-readable output")

skeleton = subparsers.add_parser(
Expand Down Expand Up @@ -941,7 +947,20 @@ def _migrate(args: argparse.Namespace) -> int:
if args.migrate_command != "article-ids":
return 2
try:
if args.write:
# Beside --json, stdout carries only the plan, so the writes go to stderr.
stream = sys.stderr if args.json else sys.stdout
for entry in write_article_ids(args.blueprint_dir):
print(f"{entry.article_path}: added article_id {entry.article_id}", file=stream)
plan = plan_article_ids(args.blueprint_dir)
except ArticleIdWriteError as error:
for entry in error.written:
print(f"{entry.article_path}: added article_id {entry.article_id}", file=sys.stderr)
for issue in error.issues:
print(f"error: {issue}", file=sys.stderr)
if error.written:
print(f"error: added {len(error.written)} article_id(s) before stopping", file=sys.stderr)
return 2
except GraphValidationError as error:
for issue in error.issues:
print(f"error: {issue}", file=sys.stderr)
Expand Down
109 changes: 108 additions & 1 deletion autoform_cli/article_identity.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Read-only planning for durable roadmap article identifiers."""
"""Planning, and applying, durable roadmap article identifiers."""

from __future__ import annotations

import hashlib
import json
import os
import stat
import tempfile
from dataclasses import dataclass
from pathlib import Path

Expand Down Expand Up @@ -93,6 +96,108 @@ def plan_article_ids(blueprint_dir: str | Path) -> ArticleIdentityPlan:
)


class ArticleIdWriteError(GraphValidationError):
"""Applying a plan stopped; ``written`` names the articles it had already changed."""

def __init__(self, issues: list[str] | tuple[str, ...], written: tuple[ArticleIdentityEntry, ...] = ()) -> None:
super().__init__(issues)
self.written = written


def write_article_ids(blueprint_dir: str | Path) -> tuple[ArticleIdentityEntry, ...]:
"""Add each planned ID to the frontmatter of the article that lacks one.

The ID goes in as the first frontmatter line; an article without
frontmatter gets a block holding only the ID. Nothing else in the file
changes. An article is written only while its bytes still have the hash
the plan was made from, so an article edited meanwhile is refused: every
article is checked before the first is written, and again just before its
own replacement. The plan is deterministic, so after such a refusal,
running the command again completes it.
"""

plan = plan_article_ids(blueprint_dir)
blueprint = Path(blueprint_dir).expanduser().resolve()
pending = [entry for entry in plan.entries if not entry.assigned]
prepared: list[tuple[ArticleIdentityEntry, Path, bytes, int]] = []
issues: list[str] = []
for entry in pending:
path = blueprint / entry.article_path
try:
content, mode = _read_regular(path)
except OSError as error:
issues.append(f"{entry.article_path}: cannot read article: {error}")
continue
if hashlib.sha256(content).hexdigest() != entry.source_sha256:
issues.append(f"{entry.article_path}: changed while the plan was made; run the command again")
continue
if content.startswith(b"\xef\xbb\xbf"):
issues.append(f"{entry.article_path}: starts with a byte-order mark; remove it and run again")
continue
prepared.append((entry, path, _with_article_id(content, entry.article_id), mode))
if issues:
raise ArticleIdWriteError(issues)

written: list[ArticleIdentityEntry] = []
for entry, path, content, mode in prepared:
try:
_replace_if_unchanged(path, entry.source_sha256, content, mode)
except OSError as error:
raise ArticleIdWriteError(
[f"{entry.article_path}: not written: {error}"], tuple(written)
) from None
written.append(entry)
return tuple(written)


def _read_regular(path: Path) -> tuple[bytes, int]:
"""The bytes and permission bits of a regular file, refusing anything else."""

descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode):
raise OSError(f"not a regular file: {path}")
with os.fdopen(descriptor, "rb", closefd=False) as source:
return source.read(), stat.S_IMODE(metadata.st_mode)
finally:
os.close(descriptor)


def _with_article_id(content: bytes, article_id: str) -> bytes:
"""Insert ``article_id`` as the first frontmatter key, keeping the file's line endings."""

text = content.decode("utf-8")
# Split as graph._parse_node does, so both agree on where the frontmatter is.
first = text.splitlines(keepends=True)[0] if text else ""
opener = first.splitlines()[0] if first else ""
ending = first[len(opener) :]
newline = ending if ending in ("\r\n", "\r") else "\n"
line = f"article_id: {article_id}{newline}"
if opener.strip() == "---" and ending:
return (first + line + text[len(first) :]).encode("utf-8")
return (f"---{newline}{line}---{newline}{newline}" + text).encode("utf-8")


def _replace_if_unchanged(path: Path, expected_sha256: str, content: bytes, mode: int) -> None:
"""Replace *path* from a same-directory temporary file, unless it changed since it was planned."""

descriptor, temporary_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "wb") as output:
output.write(content)
output.flush()
os.fsync(output.fileno())
temporary.chmod(mode)
current, _ = _read_regular(path)
if hashlib.sha256(current).hexdigest() != expected_sha256:
raise OSError("the article changed while the plan was applied; run the command again")
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)


def _proposed_id(path_id: str, source_sha256: str) -> str:
digest = hashlib.sha256(b"autoform-article-id/v1\0")
encoded_path = path_id.encode("utf-8")
Expand All @@ -105,8 +210,10 @@ def _proposed_id(path_id: str, source_sha256: str) -> str:

__all__ = [
"IDENTITY_PLAN_SCHEMA",
"ArticleIdWriteError",
"ArticleIdentityEntry",
"ArticleIdentityPlan",
"GraphValidationError",
"plan_article_ids",
"write_article_ids",
]
3 changes: 1 addition & 2 deletions autoform_cli/work.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,7 @@ def list_ready_work(
raise WorkError(
"formalizable leaves need durable article_id metadata: "
+ ", ".join(missing)
+ " (plan IDs with `autoform migrate article-ids <blueprint> --json`, then"
" add each article_id to its article's frontmatter)"
+ " (add them with `autoform migrate article-ids <blueprint> --write`)"
)
unversioned = tuple(node.id for node in unfinished if node.source_sha256 is None)
if unversioned:
Expand Down
6 changes: 3 additions & 3 deletions skills/formalize/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ subagent, that is its own worktree. Start by running `autoform work list
`autoform work context <NODE> <PROJECT> --lean-root <PROJECT> --json`. The
returned phase is derived from typed dependencies and verified assertions; never
author ready, running, retrying, failed, or blocked scheduler states. If `work
list` refuses because unfinished leaves lack `article_id`, add the IDs planned
by `autoform migrate article-ids <PROJECT>/blueprint --json` to those articles'
frontmatter, validate, and commit that change before claiming anything.
list` refuses because unfinished leaves lack `article_id`, run `autoform
migrate article-ids <PROJECT>/blueprint --write`, validate, and commit that
change before claiming anything.

For a direct formalization request, use a compatible native persistent Goal
when available and complete one useful frontier pass. Native subagents may work
Expand Down
6 changes: 3 additions & 3 deletions skills/roadmap/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,9 @@ Write milestone pages under `blueprint/roadmap/` by mathematical significance,
then one fine article per coherent unit with one unique main result. Ground each
statement and proof sketch in the source. Put genuine statement prerequisites
under `## Depends on` and proof-only prerequisites under `## Proof depends on`.
Assign durable `article_id` metadata to new articles; use
`autoform migrate article-ids blueprint --json` to obtain deterministic IDs
after creating the pages.
Assign durable `article_id` metadata to new articles; run
`autoform migrate article-ids blueprint --write` after creating the pages to
add deterministic IDs.
Assert formalization or `mathlib: true` only after exact verification. Before
revising a formalizable leaf, acquire the `claim_target` that `autoform work
context` reports for it, passing your own `--worker-id`; renew it while editing
Expand Down
Loading
Loading