diff --git a/scripts/build_dist.py b/scripts/build_dist.py index 95d654999..b33f694a7 100755 --- a/scripts/build_dist.py +++ b/scripts/build_dist.py @@ -13,14 +13,14 @@ place a skill is ever hand-edited, its include regions excepted, since those are generated from the files they name. -A skill reads whole in isolation and a rule has one home, so the text a skill needs from that -home is generated into it rather than copied: a region between `` -and `` is filled with the body under that heading, and --check fails when a region +A skill reads whole in isolation, and a rule has one home. So the text a skill needs from that +home is generated into it rather than copied. A region between `` +and `` is filled with the body under that heading. --check fails when a region differs from what its source renders now. The key is the root-relative path, then ` > `, then the heading text, the delimiter canonical_review.py also keys a unit on. A region sits in a file this -script walks, which is the Markdown under each .agents/skills// directory carrying a SKILL.md, -plus the files INCLUDE_DESTINATIONS declares, which is how a surface the walk would otherwise miss -stops restating a rule by hand. +script walks. That is the Markdown under each .agents/skills// directory carrying a SKILL.md, +plus the files INCLUDE_DESTINATIONS declares. The declared files are how a surface the walk would +otherwise miss stops restating a rule by hand. Usage: uv run --no-project --script scripts/build_dist.py [--check] @@ -66,11 +66,11 @@ def reject_symlinks(skill_dir): .agents/skills/ is the one hand-authored source, with no legitimate reason to hold a symlink. Path.is_file() and read_bytes() both dereference one, and so does shutil.copytree() by - default, so an unnoticed symlink there would let the digest and the generated plugin silently + default. So an unnoticed symlink there would let the digest and the generated plugin silently include content from outside this tree, tracked or not. `skill_dir` itself needs its own - check: rglob("*") only yields paths *inside* the directory it walks, so a skill directory that - is itself a symlink to another tree would walk straight into that tree without the walk ever - seeing (or flagging) the root symlink node. + check. rglob("*") only yields paths *inside* the directory it walks. So a skill directory that + is itself a symlink to another tree would walk straight into that tree. The walk would never + see (or flag) the root symlink node. """ if skill_dir.is_symlink(): raise ValueError(f"{skill_dir} is a symlink; .agents/skills/ must contain only real files") @@ -82,12 +82,12 @@ def reject_symlinks(skill_dir): def tree_digest(root, names): """One digest over every file under `root/` for each name, order-independent per skill. - Rooted at an arbitrary directory rather than hardcoding SKILLS_SRC, so the same function - hashes both the source tree and the generated tree, and is_stale() can compare the two + Rooted at an arbitrary directory rather than hardcoding SKILLS_SRC. So the same function + hashes both the source tree and the generated tree. That lets is_stale() compare the two directly instead of trusting a stored digest to still describe what was actually generated. Names are hashed in the fixed sorted order the caller already produced. Each skill's own files - are hashed in a second stable sort so an unrelated filesystem listing order never changes the + are hashed in a second stable sort. So an unrelated filesystem listing order never changes the digest on a machine where nothing changed. """ h = hashlib.sha256() @@ -97,7 +97,8 @@ def tree_digest(root, names): h.update(name.encode("utf-8")) # Sorted by the same .as_posix() key that gets hashed, not by raw Path comparison. # Path ordering follows the platform's native separator. - # Two OSes can sort an identical file set into a different order and hash it into a different digest, even though the bytes hashed per file already agree via .as_posix() below. + # Two OSes can sort an identical file set into a different order and hash it into a different digest. + # That holds even though the bytes hashed per file already agree via .as_posix() below. files = sorted( (f for f in skill_dir.rglob("*") if f.is_file()), key=lambda f: f.relative_to(root).as_posix(), @@ -134,7 +135,7 @@ def has_exact_skill_directories(root, names): def expected_manifest(names): """The plugin.json content `names` should produce, entirely deterministic. - The one source is_stale() compares the actual manifest against, so a hand-edited field of + The one source is_stale() compares the actual manifest against. So a hand-edited field of any kind (not only "skills") is caught the same way a hand-edited "skills" list already was. """ return { @@ -151,17 +152,21 @@ def write_plugin_manifest(names): PLUGIN_MANIFEST.parent.mkdir(parents=True, exist_ok=True) manifest = expected_manifest(names) # LF, matching this repo's JSON default (.editorconfig `[*] end_of_line = lf`). - # Explicit, not the platform default: a Windows host writing plain LF (newline=None) would translate it to CRLF on write, which disagrees with this repo's LF default. + # Explicit, not the platform default. + # A Windows host writing plain LF (newline=None) would translate it to CRLF on write, which disagrees with this repo's LF default. PLUGIN_MANIFEST.write_text( json.dumps(manifest, indent=2) + "\n", encoding="utf-8", newline="\n" ) # --- Include regions ------------------------------------------------------------------------- -# A skill has to read whole in isolation, and a rule has one home, so the text a skill needs from that home is generated into it rather than copied. -# The region is filled in the authored tree itself, because Codex and opencode read .agents/skills/ directly and a region left empty there would hand them a skill with a hole in it. +# A skill has to read whole in isolation, and a rule has one home. +# So the text a skill needs from that home is generated into it rather than copied. +# The region is filled in the authored tree itself, because Codex and opencode read .agents/skills/ directly. +# So a region left empty there would hand them a skill with a hole in it. # The generated trees then mirror the filled source. -# A file the walk would otherwise miss holds a region only where INCLUDE_DESTINATIONS names it, which is the mechanism for a rule with one home that a hub surface outside the skills tree would otherwise restate. +# A file the walk would otherwise miss holds a region only where INCLUDE_DESTINATIONS names it. +# That is the mechanism for a rule with one home that a hub surface outside the skills tree would otherwise restate. # .agents/skills/README.md is such a file despite sitting in that tree, because the walk visits the skill directories rather than the tree containing them. # The same ` > ` vocabulary canonical_review.py keys a unit on, defined here because that engine imports this module. @@ -169,18 +174,33 @@ def write_plugin_manifest(names): # Sources resolve against the repository root, so a key reads the same in a skill, a finding, and the review ledger. INCLUDE_ROOT = ROOT # The files the walk would otherwise miss that may hold an include region, declared one at a time rather than discovered. -# Only a walked file is written, so a region in a file on neither this tuple nor skill_documents() reads filled to whatever includes it while staying empty on disk, which is the refusal filled_lines() raises. -# Discovery is wrong here even though it would spare the maintenance: whether a file may hold generated text is a judgment about where that text ends up, not a property of where the file sits. +# Only a walked file is written. +# So a region in a file on neither this tuple nor skill_documents() reads filled to whatever includes it while staying empty on disk. +# That is the refusal filled_lines() raises. +# Discovery is wrong here even though it would spare the maintenance. +# Whether a file may hold generated text is a judgment about where that text ends up. +# It is not a property of where the file sits. # Nothing spec/files.json carries hub content from belongs here, and manifest_carried_destination() refuses one rather than leaving the rule to a reader. -# A carrier receives the filled text either way, since carriage copies this repository's committed bytes and a fill has already written them, so a region never arrives empty and markers reaching a carrier are not themselves the problem. -# The generated mirrors already carry live markers to every repository, and are safe doing it because regenerate() rebuilds them wholesale from the authored tree on every run, so nothing in them can hold text its source no longer renders. -# A carried authored file is copied rather than rebuilt, so a region in one would put generated text into a file its carrier holds as its own, which is what this refuses. -# Empty today, deliberately: the surfaces that drift restate a rule in their own words for their own audience rather than copying it, and an include fills a region with a heading's whole body, so every one of them is a pointer instead. -# Declared empty rather than left out, because the mechanism is what a restatement measured as copy-shaped needs, and the tuple is where that first one is named. +# A carrier receives the filled text either way, since carriage copies this repository's committed bytes and a fill has already written them. +# So a region never arrives empty, and markers reaching a carrier are not themselves the problem. +# The generated mirrors already carry live markers to every repository, and are safe doing it. +# That is because regenerate() rebuilds them wholesale from the authored tree on every run. +# So nothing in them can hold text its source no longer renders. +# A carried authored file is copied rather than rebuilt. +# So a region in one would put generated text into a file its carrier holds as its own. +# That is what this refuses. +# Empty today, deliberately. +# The surfaces that drift restate a rule in their own words for their own audience rather than copying it. +# An include fills a region with a heading's whole body. +# Given both, every one of those surfaces is a pointer instead. +# Declared empty rather than left out. +# That is because the mechanism is what a restatement measured as copy-shaped needs, and the tuple is where that first one is named. INCLUDE_DESTINATIONS: tuple[str, ...] = () _INCLUDE_START = re.compile(r"^$") _INCLUDE_END = re.compile(r"^$") -# A near miss: a comment beginning like a marker that neither pattern above accepts, case-folded so a capitalized one is caught too, and on any run of dashes so the three-dash opener a hand types on both markers is caught rather than read as content. +# A near miss: a comment beginning like a marker that neither pattern above accepts. +# The near-miss pattern is case-folded, so a capitalized one is caught too. +# It also matches on any run of dashes, so the three-dash opener a hand types on both markers is caught rather than read as content. _INCLUDE_LIKE = re.compile(r"^ -`previous=` names the closed issue the track continues from, an issue filed per session before -the track's issue became a standing one, or a closed-out lane the track reopens, and `none` -otherwise. It stays fixed while the round moves. Each archived round carries its own line: +`previous=` names the closed issue the track continues from, and `none` otherwise. +That issue is either an issue filed per session before the track's issue became a standing one, +or a closed-out lane the track reopens. `previous=` stays fixed while the round moves. Each archived round carries its own line: The title is for humans and nothing parses it. The invariant a reader checks is that exactly one -open issue carries the `handoff` label for a given track in a given repository, read from the -metadata block rather than from the label, since an issue carrying the label and no block belongs -to no readable track. Zero open is two states rather than one: a track that has never had a -handoff, and a lane closed out whose newest issue is closed. `new` continues from that closed -issue where one exists. Two or more open on one track is a defect to report, never one to resolve -by picking, so every command that would have to choose refuses and names both. - -A head resolved that way can still be one the track has moved past, where another issue already -names it as predecessor, holds a higher round, or holds the same round. `new` and `close` refuse -onto such a head, and `current`, `resume`, and `chain` answer from it with a notice naming each -such issue, since answering from it silently is how a search reads "no match" over text in the -lane. +open issue carries the `handoff` label for a given track in a given repository. The track is read from +the metadata block rather than from the label. That is because an issue carrying the label and no +block belongs to no readable track. Zero open is two states rather than one: + + - a track that has never had a handoff + - a lane closed out whose newest issue is closed + +In the second, `new` continues from that closed issue where one exists. Two or more open on one +track is a defect to report, never one to resolve by picking. So every command that would have to +choose refuses and names both. + +A head resolved that way can still be one the track has moved past. That is a head where another +issue: + + - already names it as predecessor + - holds a higher round + - holds the same round + +`new` and `close` refuse onto such a head. `current`, `resume`, and `chain` answer from it with a +notice naming each such issue. That notice exists because answering from it silently is how a search +reads "no match" over text in the lane. Subcommands current The open handoff issue for a track, number and URL. Read-only. resume That issue's body, then a one-line index of the last N earlier rounds. Read-only. chain List one track's rounds newest first, the current body, then each archived round, - then the closed issue `previous=` names, optionally only those whose text matches a + then the closed issue `previous=` names. Optionally list only those whose text matches a regular expression, which is how a session answers "has this already been tried" without reading every round. A walk that stopped at `--limit` names the round it - stopped short of, since a capped search reading exactly like an exhaustive one is the - false "not tried yet" this exists to prevent. Read-only. + stopped short of. That is because a capped search reading exactly like an exhaustive + one is the false "not tried yet" this exists to prevent. Read-only. new Create the track's issue where it has none open. Otherwise append the outgoing body as a comment, then replace the body and bump the round. Either way it prints the issue number. A rerun after an interrupted run finishes it rather than repeating it. @@ -61,41 +70,47 @@ Exit codes 0 the command did what it says. - 1 a refusal the caller can act on: the label is missing, other than where `tracks` answers - an unregistered fork under the fleet's owner or `new --create-label` creates the label there, - no handoff is open on the track, a track is ambiguous, a handoff carries no metadata block, a - body is over the hard cap, or the command line itself was wrong. A usage error is a refusal, so - `Parser` below moves it here off argparse's own 2. + 1 a refusal the caller can act on: + - the label is missing, other than where `tracks` answers an unregistered fork under the + fleet's owner or `new --create-label` creates the label there + - no handoff is open on the track + - a track is ambiguous + - a handoff carries no metadata block + - a body is over the hard cap + - the command line itself was wrong + A usage error is a refusal. So `Parser` below moves it here off argparse's own 2. 2 the command did not run to an answer: `gh` failed, a write did not confirm, or an exception nobody modeled reached the top. A refusal and a failure to reach one never share a code, which is the whole reason the two are separated. Usage - python3 scripts/handoff.py current --repo OWNER/NAME [--track T] - python3 scripts/handoff.py resume --repo OWNER/NAME [--track T] [--history N] - python3 scripts/handoff.py chain --repo OWNER/NAME [--track T] [--limit N] [--grep PATTERN] - python3 scripts/handoff.py new --repo OWNER/NAME [--track T] --title S --body-file PATH - [--create-label] - python3 scripts/handoff.py close --repo OWNER/NAME [--track T] --body-file PATH - python3 scripts/handoff.py tracks --repo OWNER/NAME [--json] - -Every subcommand takes `--repo`, with no default, for the reason `pr_review.py` requires one: an -issue number resolves in every repository, and a chain read out of the wrong one is well formed. + - python3 scripts/handoff.py current --repo OWNER/NAME [--track T] + - python3 scripts/handoff.py resume --repo OWNER/NAME [--track T] [--history N] + - python3 scripts/handoff.py chain --repo OWNER/NAME [--track T] [--limit N] [--grep PATTERN] + - python3 scripts/handoff.py new --repo OWNER/NAME [--track T] --title S --body-file PATH + [--create-label] + - python3 scripts/handoff.py close --repo OWNER/NAME [--track T] --body-file PATH + - python3 scripts/handoff.py tracks --repo OWNER/NAME [--json] + +Every subcommand takes `--repo`, with no default, for the reason `pr_review.py` requires one. +That is because an issue number resolves in every repository, and a chain read out of the wrong +one is well formed. Add `--dry-run` to any writing subcommand to print the calls it would make and write nothing. A repository missing the label is drift on a fleet repository, one `registry/repos.json` lists, and -every subcommand refuses there. A fork under the registry's owner that the registry does not list, -such as one kept for an upstream contribution, can still use a chain to hold state, and the fleet -label set does not belong on it. There the reads warn and answer as an empty chain does, and -`new --create-label` creates the one `handoff` label and nothing else. Every other repository -missing the label refuses, a repository under another owner and an unregistered repository of the -owner's that is not a fork among them. - -The two writing subcommands, `new` and `close`, are bounded whatever the label state, an -in-process refusal of the kind `pr_review.py` makes, with the owner read from the registry. They +every subcommand refuses there. A fork under the registry's owner that the registry +does not list can still use a chain to hold state. One example is a fork kept for an upstream +contribution. The fleet label set does not belong on such a fork. On such a fork the reads warn +and answer as an empty chain does. +On such a fork `new --create-label` creates the one `handoff` label and nothing else. Every other repository +missing the label refuses. That includes a repository under another owner and an unregistered +repository of the owner's that is not a fork. + +The two writing subcommands, `new` and `close`, are bounded whatever the label state. The bound is +an in-process refusal of the kind `pr_review.py` makes, with the owner read from the registry. They refuse a repository under another owner before any call, and an unregistered repository of the -owner's unless it is a fork, since these writes run as subprocesses a write guard on the caller -never sees. That write scope bounds no read. +owner's unless it is a fork. That is because these writes run as subprocesses a write guard on the +caller never sees. That write scope bounds no read. """ from __future__ import annotations @@ -247,8 +262,8 @@ def run_gh(argv: list[str]) -> str: def gh(argv: list[str], *, dry_run: bool = False) -> str: """`run_gh`, or, under a dry run, the call printed and nothing sent. - The dry run is the first thing anyone wants before trusting this to close an issue, so it sits - on the one path every read and every write goes through rather than on each write separately. + The dry run is the first thing anyone wants before trusting this to close an issue. + So it sits on the one path every read and every write goes through rather than on each write separately. """ if dry_run: print(f" would run: gh {' '.join(argv)}") @@ -269,11 +284,11 @@ def rows_of(data: object, what: str, field: str) -> list[dict]: """`data` as a list of rows each carrying `field`, refusing every other shape. A row that is not an object, or one missing the field its reader asks `gh` for, would - otherwise raise out of a comprehension and reach the blind catch as an unmodeled error, which - reports exit 2 without saying what `gh` actually returned. + otherwise raise out of a comprehension. It would reach the blind catch as an unmodeled error, + which reports exit 2 without saying what `gh` actually returned. - The guarantee stops at that one field. A command consuming these rows reads fields of its own, - and an absent one still lands on the blind catch, which is the honest place for a shape nobody + The guarantee stops at that one field. A command consuming these rows reads fields of its own. + An absent one still lands on the blind catch, which is the honest place for a shape nobody named. """ if not isinstance(data, list): @@ -308,8 +323,8 @@ def label_present(repo: str) -> bool: def registry() -> tuple[str, set[str]]: """The registry's owner and its repository names, lowercased, as GitHub compares them. - An unreadable registry is a failure to answer rather than an answer, since reading it as empty - would move every fleet repository onto the warning path. + An unreadable registry is a failure to answer rather than an answer. That is because reading it + as empty would move every fleet repository onto the warning path. """ try: data = json.loads(REGISTRY.read_text(encoding="utf-8")) @@ -412,16 +427,18 @@ def create_label(repo: str, dry_run: bool) -> None: def without_label(a: argparse.Namespace) -> int | None: """What a repository missing the label gets, or None where the subcommand goes on to run. - On a fleet repository it is drift, and every subcommand refuses, since degrading there would - report an empty chain on a repository that has one, the failure `decision` demonstrated. + On a fleet repository it is drift, and every subcommand refuses. That is because degrading + there would report an empty chain on a repository that has one, the failure `decision` + demonstrated. On an unregistered fork under the registry's owner no issue can carry a label that does not - exist, so an empty chain is the true answer there rather than a degraded one, and only + exist. So an empty chain is the true answer there rather than a degraded one, and only `new --create-label` writes anything. - The body is read once, before the label is created, and that read is the one `new` files, so a - body `new` would refuse leaves no label behind. Everything else refuses before any write. A - repository under another owner, or an unregistered one of the owner's that is not a fork, - refuses here for a read and in `require_write_scope` for a write, which runs first. + The body is read once, before the label is created, and that read is the one `new` files. + So a body `new` would refuse leaves no label behind. Everything else refuses before any write. + A repository under another owner refuses here for a read and in `require_write_scope` for a + write. So does an unregistered one of the owner's that is not a fork. `require_write_scope` + runs first. """ repo = a.repo create = getattr(a, "create_label", False) @@ -484,8 +501,8 @@ def without_label(a: argparse.Namespace) -> int | None: def parse_marker(body: str, number: int) -> dict[str, str] | None: """The metadata block in one body, or None where it carries none. - More than one block is a refusal rather than a pick, since the two can disagree about the track - and nothing else in the body decides between them. + More than one block is a refusal rather than a pick. That is because the two can disagree about + the track and nothing else in the body decides between them. """ found = MARKER.findall(body or "") if not found: @@ -531,8 +548,8 @@ def rounds_of(comments: list[dict], track: str) -> list[dict]: A comment carrying no mark is an ordinary comment, such as a parking note, and is not a round. One carrying several is not a round either, since its round cannot be read. On a public - repository anyone can comment, so a mark counts only from an account with write access and - only on a comment nobody has hidden, which keeps an outsider from writing a round into the + repository anyone can comment. So a mark counts only from an account with write access and + only on a comment nobody has hidden. That keeps an outsider from writing a round into the track's history or blocking `new` with a conflicting one. """ found = [] @@ -552,9 +569,9 @@ def with_marker(body: str, track: str, round_: int, previous: int | None) -> str """`body` with the metadata block as its last line, replacing any block already in it. Only newlines are stripped from the end. `MARKER`'s tail is an ordered suffix rather than a - set of characters, so removing any character that suffix may itself hold can turn a line the - grammar rejects into one it accepts: `-->\\r ` is not a block and `-->\\r` is, so taking off - one space revives it, and the revived line then sits beside the one this appends and leaves a + set of characters. So removing any character that suffix may itself hold can turn a line the + grammar rejects into one it accepts. Here `-->\\r ` is not a block and `-->\\r` is, so taking off + one space revives it. The revived line then sits beside the one this appends and leaves a body no command can read. A newline is the one thing the tail can never hold, since `$` sits before it, so stripping newlines alone can revive nothing. """ @@ -668,9 +685,9 @@ def open_handoffs(repo: str) -> list[dict]: def require_marked(rows: list[dict]) -> None: """Refuse while any open handoff carries no metadata block. - Such an issue belongs to an unknown track, so it could be the one the caller asked about, and + Such an issue belongs to an unknown track. So it could be the one the caller asked about, and picking around it would answer a question the data does not settle. Settling it is a hand edit, - adding the block to that issue's body or taking the label off it, and `tracks` surveys without + adding the block to that issue's body or taking the label off it. `tracks` surveys without refusing so an operator can see the state at all. """ broken = [row["malformed"] for row in rows if row.get("malformed")] @@ -717,23 +734,29 @@ def walk( """The track's rounds from issue `start` backwards, at most `limit` of them. Each issue gives its body, which is the round it holds now, then its archived rounds newest - first, and the walk then moves to the issue its block names as `previous=`. `skip_body` leaves + first. The walk then moves to the issue its block names as `previous=`. `skip_body` leaves out the start issue's own body, for a caller that has printed it already. Returns the rounds and, where the walk ended anywhere but at a `previous=none`, a phrase saying where and why. A caller that printed only the rounds could not tell a chain of four from the - first four of forty, and a search reporting no match over a walk that stopped early is the + first four of forty. A search reporting no match over a walk that stopped early is the false "not tried yet" this whole mechanism exists to prevent. `cap` is the flag the caller bounded the walk with, `--limit` for `chain` and `--history` for - `resume`, since a message naming the other one sends a reader to a flag that subcommand - rejects. - - Five things end a walk short. The cap, a link whose body carries no metadata block, which - names nothing before it and is not guessed past, a link whose block cannot be read at all, an - ancestor `gh` cannot resolve, and a number already seen, since a cycle would otherwise read as - an endless chain. Each returns its own phrase rather than the clean end's None, because all - five leave links unread and only the first is a number the caller chose. + `resume`. That is because a message naming the other one sends a reader to a flag that + subcommand rejects. + + Five things end a walk short: + + - The cap. + - A link whose body carries no metadata block, which names nothing before it and is not + guessed past. + - A link whose block cannot be read at all. + - An ancestor `gh` cannot resolve. + - A number already seen, since a cycle would otherwise read as an endless chain. + + Each returns its own phrase rather than the clean end's None. That is because all five leave + links unread and only the first is a number the caller chose. """ links: list[dict] = [] seen: set[int] = set() @@ -804,8 +827,8 @@ def day(stamp: str | None) -> str: def describe(link: dict) -> str: """One walked round as a line, carrying its own track. - The track is printed rather than assumed from the track the walk started on, because `walk` - follows each marker's `previous=` and nothing stops a hand-edited issue naming a + The track is printed rather than assumed from the track the walk started on. That is because + `walk` follows each marker's `previous=` and nothing stops a hand-edited issue naming a predecessor on another lane. Printing it is what makes that visible to the reader. An archived round reads `ROUND` in the state column and its close date in the date column. """ @@ -821,7 +844,7 @@ def describe(link: dict) -> str: def body_from(path: Path) -> str: """The body a `new` will file, refused where it is over the hard cap or already carries a block. - A body carrying its own block would leave two after the append, which `parse_marker` then reads + A body carrying its own block would leave two after the append. `parse_marker` then reads that as the refusal it is, one round later and on a live issue rather than here. """ try: @@ -855,8 +878,8 @@ def body_arg(body: str, dry_run: bool): """The `--body-file` argument for one write, as a real temporary file or as a placeholder. A dry run writes no file, because the path it would print names a temporary directory the - preview has already removed, and a reader checking a previewed call should find every part of - it legible rather than one part pointing at nothing. + preview has already removed. It is also because a reader checking a previewed call should find + every part of it legible rather than one part pointing at nothing. """ if dry_run: yield "" @@ -1056,15 +1079,15 @@ def newest_closed(repo: str, track: str, closed: list[dict] | None = None) -> di """The newest closed handoff on a track, or None where the track has never had one. A track with no open handoff is either one that has never had a handoff or one whose lane was - closed out, and the two are not the same. Reading the second as the first makes the next `new` - start a second chain at round 1 with `previous=none`, orphaning every link already written, - which is the failure the chain exists to prevent. - - The head is the highest round rather than the highest issue number, because a chain is ordered - by round everywhere else and an issue number only agrees with that where the links were filed - in order. They disagree wherever a lower-numbered issue joined the lane later, and picking by - number there hands `new` a predecessor that is not the head, so it files a second link at a - round already taken and the real chain goes unreachable while every step exits 0. The number + closed out. The two are not the same. Reading the second as the first makes the next `new` + start a second chain at round 1 with `previous=none`, orphaning every link already written. + That is the failure the chain exists to prevent. + + The head is the highest round rather than the highest issue number. That is because a chain is + ordered by round everywhere else. It is also because an issue number only agrees with that + where the links were filed in order. They disagree wherever a lower-numbered issue joined the lane later. + Picking by number there hands `new` a predecessor that is not the head. So it files a second + link at a round already taken, and the real chain goes unreachable while every step exits 0. The number breaks a tie between two links claiming one round, which is itself a defect the reader should not have to resolve. @@ -1157,18 +1180,18 @@ def cmd_tracks(a: argparse.Namespace) -> int: def cmd_new(a: argparse.Namespace) -> int: """Create the track's issue, or archive its outgoing round and replace the body in place. - The archive comment is written before the body is replaced, so a failure between the two + The archive comment is written before the body is replaced. So a failure between the two leaves the outgoing round both archived and still in the body rather than in neither. A rerun - then finds that round archived and only replaces the body, and a rerun after a run that wrote + then finds that round archived and only replaces the body. A rerun after a run that wrote everything finds the body already current and writes nothing. - The read of the head and the writes that follow it are not serialized, so two sessions closing + The read of the head and the writes that follow it are not serialized. So two sessions closing on one track at once can both archive the same round. The body is re-read immediately before it is replaced, and a change since the archive refuses rather than overwriting text nobody here has seen. Naming a track per lane is what keeps two sessions off one issue in the first place. After `--create-label` the chain is empty by construction, since a label this run just created - sits on no issue yet, so no chain read is made, which also keeps a dry run from querying a label + sits on no issue yet. So no chain read is made. That also keeps a dry run from querying a label it never created. """ body = getattr(a, "body", None) @@ -1209,7 +1232,7 @@ def cmd_new(a: argparse.Namespace) -> int: def replace_round(a: argparse.Namespace, head: dict, title: str, body: str) -> int: """Archive the head's outgoing round as a comment, then write the next round over its body. - Every identifier written to is the head's own number, read in this run, and the outgoing body + Every identifier written to is the head's own number, read in this run. The outgoing body archived is the one read immediately before, never one carried from earlier in the run. """ number = int(head["number"]) @@ -1287,7 +1310,7 @@ def cmd_close(a: argparse.Namespace) -> int: """Comment the track's outcome on its issue, then close it, which is how a track ends. A rerun after the comment landed and the close did not finds the same text as the newest - comment and goes straight to the close rather than posting it twice. + comment. The rerun goes straight to the close rather than posting it twice. """ text = outcome_from(Path(a.body_file)) found, doubts = current_or_refuse(a.repo, a.track) @@ -1325,7 +1348,7 @@ def track_links(repo: str, track: str, rows: list[dict], closed: list[dict] | No """The links on `track`, the open ones taken from `rows` and the closed ones from `closed`. A row whose block is absent or cannot be read could be on this track, so it is named in - `unreadable` rather than skipped, and a closed read that fills its window sets `full`, since a + `unreadable` rather than skipped. A closed read that fills its window sets `full`, since a link past it is unseen. Each caller decides whether those refuse or are reported. A caller that already read the closed list passes it as `closed`, and one that passes none has it read here. """ @@ -1357,10 +1380,10 @@ def head_doubts(head: dict, lane: Lane) -> list[str]: """Each reason `head` may not be its track's head, one sentence apiece, empty where none. `on_track` takes an open link whatever round it holds, and `newest_closed` breaks a tie on one - round by issue number, so either can resolve a link the chain has already moved past. Three + round by issue number. So either can resolve a link the chain has already moved past. Three states show it: a link already naming it as predecessor, a link at a higher round, and another link at its own round. Answering from such a head is how a search reads "no match" over text - in the lane, so the reads print these, and `new` and `close` refuse on them. + in the lane. So the reads print these, and `new` and `close` refuse on them. """ number = int(head["number"]) round_ = int(head["marker"]["round"]) diff --git a/scripts/skills_install.ps1 b/scripts/skills_install.ps1 index 42b1acd79..c48fd62a8 100644 --- a/scripts/skills_install.ps1 +++ b/scripts/skills_install.ps1 @@ -10,7 +10,8 @@ $script = Join-Path $here "skills_install.py" # Try each candidate in order, version-checking it before committing to it. # None of these launchers guarantees 3.7+ by construction. # `py -3` and `python3` can both resolve to an old 3.6 interpreter on some systems. -# Picking the first *available* launcher and checking only that one would fail the whole install on a machine where an earlier candidate is stale but a later one is fine. +# Picking the first *available* launcher and checking only that one would fail the whole install. +# That happens on a machine where an earlier candidate is stale but a later one is fine. $candidates = @( @{ Exe = "py"; Args = @("-3") }, @{ Exe = "python3"; Args = @() }, diff --git a/scripts/skills_install.py b/scripts/skills_install.py index 431697853..1b3a3d1e1 100755 --- a/scripts/skills_install.py +++ b/scripts/skills_install.py @@ -9,19 +9,19 @@ installer materializes so every repo on this machine benefits, not only this checkout. - Claude Code never scans .agents/skills/, only .claude/skills/ or a plugin's own skills/. This installer registers this repo's marketplace (built at .claude-plugin/fleet-skills/, see - build_dist.py) and installs its plugin via the `claude` CLI, so Claude Code loads the same + build_dist.py) and installs its plugin via the `claude` CLI. So Claude Code loads the same content the other two tools read directly. Both wrappers (skills_install.sh, skills_install.ps1) call this, so every OS runs one tested code path. The two channels hold different things. The Codex and opencode copy is a snapshot, so it keeps the revision it was taken from. The Claude Code marketplace is a directory source that loads the hub -checkout in place, so it serves whatever that checkout holds at read time and holds no revision. +checkout in place. So it serves whatever that checkout holds at read time and holds no revision. Every run records a stamp at ~/.agents/skills-install-stamp.json naming the machine, what was installed, and the hub commit it came from. `--report` answers each channel by name, without changing anything. For the snapshot, it says which commit the copy was taken from and whether that -is the intended revision, the promoted `main` unless `--intended` names another. For the live +is the intended revision. The intended revision is the promoted `main` unless `--intended` names another. For the live channel, it says which branch and commit the registered checkout is serving now. The exit code is the snapshot's verdict alone, since the live channel following its checkout is the design. @@ -141,7 +141,7 @@ def git(*args): def skill_source_dirs(): """Every .agents/skills// directory that actually carries a SKILL.md. - Matches build_dist.skill_names()'s own definition of a skill, so a stray non-skill directory + Matches build_dist.skill_names()'s own definition of a skill. So a stray non-skill directory under .agents/skills/ (a cache folder, a scratch dir) is never treated as one here either. """ if not SKILLS_SRC.is_dir(): @@ -154,20 +154,20 @@ def materialize_global_skills(target): Only this fleet's own skill names are touched under `target`. `~/.agents/skills/` is a shared convention, not this fleet's own directory, so a machine can have skills installed there from - other sources, and replacing the whole directory would delete those as a side effect of - installing this fleet's skills. A plain copy rather than a symlink per skill: a symlink to a + other sources. Replacing the whole directory would delete those as a side effect of + installing this fleet's skills. A plain copy rather than a symlink per skill. A symlink to a checkout that later moves or is deleted leaves every repo on the machine silently unable to - resolve that skill, where a copy just goes stale (caught by --report) instead of missing + resolve that skill. A copy just goes stale (caught by --report) instead of missing outright. - Raises ValueError (via build_dist.reject_symlinks) if a skill directory contains a symlink, - since shutil.copytree() follows one by default and would silently pull in content from + Raises ValueError (via build_dist.reject_symlinks) if a skill directory contains a symlink. + That is because shutil.copytree() follows one by default and would silently pull in content from outside the tracked source tree. - Each installed skill carries an INSTALLED_MARKER file, so a later run can tell "a fleet skill + Each installed skill carries an INSTALLED_MARKER file. So a later run can tell "a fleet skill that got retired" apart from "someone else's skill that happens to share this directory." Only a directory carrying the marker is ever removed for no longer being in the current - source set; a directory without one is never touched, retired-looking name or not. + source set. A directory without one is never touched, retired-looking name or not. """ if target.is_symlink() or target.is_file(): target.unlink() @@ -233,8 +233,8 @@ def marketplace_entry(): def plugin_state(): """Whether the fleet plugin is installed and enabled at user scope, each None where unreadable. - A registered marketplace serves nothing until its plugin is installed, and a plugin the user - later disabled or uninstalled leaves the marketplace registered, so the listing alone proves neither. + A registered marketplace serves nothing until its plugin is installed. A plugin the user + later disabled or uninstalled leaves the marketplace registered. So the listing alone proves neither. The installer installs at user scope, so a project or local install of the same id says nothing here. """ entries = claude_json("plugin", "list", "--json") @@ -282,7 +282,7 @@ def existing_registration(): """This marketplace's current registration, as (where it is, whether an add may move it). `claude plugin marketplace add` re-points an existing registration at the new directory - without failing, so running it from a worktree would leave Claude Code loading a tree that is + without failing. So running it from a worktree would leave Claude Code loading a tree that is about to be removed. Only a directory registration whose directory is gone may be moved, since it serves nothing. A directory that cannot be read is kept, as a present one is. Returns None where nothing is registered, and raises LookupError where the CLI gives no listing. @@ -304,12 +304,13 @@ def register_claude_marketplace(): """Add this repo's marketplace and install its plugin via the `claude` CLI. Shells out to `claude plugin marketplace add`/`install` rather than writing - ~/.claude/plugins/known_marketplaces.json directly: that file's shape is the CLI's own - internal state, not a documented contract, so writing it by hand risks silently drifting from + ~/.claude/plugins/known_marketplaces.json directly. That file's shape is the CLI's own + internal state, not a documented contract. So writing it by hand risks silently drifting from whatever the CLI actually expects on the next release. An existing registration the add would move is left in place, with a warning naming the command - that moves it, unless its directory is gone, where the move is made and announced. + that moves it. The exception is a registration whose directory is gone, where the move is made + and announced. """ try: existing = existing_registration() @@ -385,8 +386,8 @@ def build_stamp(claude_registered): def intended_commit(rev=None): """The commit the snapshot is meant to hold, and the ref that named it. - Step 7 of merge-and-release installs from the promoted `main`, so that is the default: the - remote-tracking ref first, as last fetched, since that step fetches it just before + Step 7 of merge-and-release installs from the promoted `main`, so that is the default. + The default is the remote-tracking ref first, as last fetched, since that step fetches it just before installing, then a local `main`. Nothing here fetches, so a caller whose `origin/main` may have moved fetches first. A fetched tarball has no git to ask, and the commit its loader resolved and handed in is the revision it was fetched to install. @@ -424,8 +425,8 @@ def live_channel(): def marketplace_channel(): """What the registered marketplace serves now, read from the checkout the marketplace names. - The checkout running this report is not necessarily the one registered, a worktree or a fresh - clone being the ordinary cases, so the registered path is read back from the CLI. + The checkout running this report is not necessarily the one registered. A worktree or a fresh + clone is the ordinary case. So the registered path is read back from the CLI. """ if not claude_available(): return {"registered": None, "reason": "`claude` not found on PATH"} diff --git a/tests/test_build_dist.py b/tests/test_build_dist.py index e6b5d1788..7e595be25 100755 --- a/tests/test_build_dist.py +++ b/tests/test_build_dist.py @@ -28,7 +28,7 @@ class TreeCase(unittest.TestCase): """Redirects every module path onto a temp tree so a case never touches this repo's own. The declared destinations are emptied along with the paths, since they name this repository's - own files and validating them against a temp tree would fail every case here for a reason + own files. Validating them against a temp tree would fail every case here for a reason none of them is about. A case that wants one declares it. """ @@ -107,9 +107,9 @@ def test_a_symlink_in_a_skill_directory_is_rejected_by_skill_digest(self) -> Non @requires_symlink def test_a_skill_directory_that_is_itself_a_symlink_is_rejected(self) -> None: - """rglob("*") only yields paths inside the directory it walks, so a skill directory that - is itself a symlink to another tree would otherwise walk straight into that tree without - the walk ever seeing the root symlink node.""" + """rglob("*") only yields paths inside the directory it walks. So a skill directory that + is itself a symlink to another tree would otherwise walk straight into that tree. The walk + would never see the root symlink node.""" outside = self.tmp / "outside-the-repo" outside.mkdir() (outside / "SKILL.md").write_text("not tracked here", encoding="utf-8") @@ -133,7 +133,7 @@ def test_a_skill_directory_without_skill_md_is_not_discovered(self) -> None: def test_digest_stamp_is_lf_only(self) -> None: """Read as bytes, not text: a text-mode read applies universal-newline translation and would hide a stray CR. build_dist.py's explicit `newline="\\n"` is what keeps this LF on - a Windows host too; a Linux-CI runner's platform default is also LF, so this assertion + a Windows host too. A Linux-CI runner's platform default is also LF. So this assertion alone cannot tell "explicit" from "happened to match", only that the shape is right.""" self.make_skill("foo") build_dist.regenerate() @@ -218,8 +218,12 @@ def test_a_deleted_generated_skill_reports_stale_even_with_a_current_digest_stam def test_a_generated_file_edited_in_place_reports_stale(self) -> None: """The stamps, the manifest, and SKILL.md's mere presence can all stay untouched - while a generated file's actual content is edited or a non-SKILL.md file (a bundled - scripts/ or references/ file) is deleted. Only comparing the generated tree's own digest + while one of these happens: + + - a generated file's actual content is edited + - a non-SKILL.md file (a bundled scripts/ or references/ file) is deleted + + Only comparing the generated tree's own digest against the source's catches this, since nothing else here re-reads the generated bytes.""" self.make_skill("foo") (self.skills_src / "foo" / "references").mkdir() @@ -239,9 +243,9 @@ def test_a_generated_extra_file_deleted_reports_stale(self) -> None: self.assertTrue(build_dist.is_stale()) def test_an_orphaned_generated_skill_directory_reports_stale(self) -> None: - """tree_digest() only hashes the expected `names`, so an extra directory under + """tree_digest() only hashes the expected `names`. So an extra directory under DIST_PLUGIN/skills/ (a retired skill left behind, one added by hand) would never be - read and could not affect a pure digest comparison. Checked by name explicitly.""" + read. It could not affect a pure digest comparison. Checked by name explicitly.""" self.make_skill("foo") build_dist.regenerate() (self.dist_plugin / "skills" / "orphan").mkdir() @@ -274,8 +278,8 @@ def stamps(self) -> dict[str, bytes]: return {p.name: p.read_bytes() for p in build_dist.DIGEST_DIR.iterdir()} def test_two_skill_edits_change_two_disjoint_stamp_files(self) -> None: - """One stamp over every skill's bytes made any two concurrent skill edits conflict on it, - so the stamps are one file per skill and an edit to one skill moves only that skill's. + """One stamp over every skill's bytes made any two concurrent skill edits conflict on it. + So the stamps are one file per skill, and an edit to one skill moves only that skill's. Two branches from one base, each editing a different skill, are played out in turn.""" self.make_skill("foo", "foo v1") self.make_skill("bar", "bar v1") @@ -319,8 +323,8 @@ def test_a_stamp_replaced_by_a_symlink_reports_stale(self) -> None: self.assertTrue(build_dist.is_stale()) def test_an_orphaned_stamp_reports_stale(self) -> None: - """A stamp for a retired skill is never read by the per-skill comparison, so it is - caught by name, the way an orphaned generated directory is.""" + """A stamp for a retired skill is never read by the per-skill comparison. + So it is caught by name, the way an orphaned generated directory is.""" self.make_skill("foo") build_dist.regenerate() (build_dist.DIGEST_DIR / "retired").write_text("0000000000000000\n", encoding="utf-8") @@ -370,7 +374,7 @@ def test_main_reports_a_symlink_cleanly_instead_of_a_raw_traceback(self) -> None @requires_symlink def test_check_reports_a_symlink_as_2_not_1(self) -> None: - """1 is --check's own documented "stale" result, so a caller reading the exit code (host-setup/menu.sh among them) needs a different code to tell a real failure apart from that finding. + """1 is --check's own documented "stale" result. So a caller reading the exit code (host-setup/menu.sh among them) needs a different code to tell a real failure apart from that finding. The include walk refuses the symlink before the stamp check, so the clean regenerate() before it is not what reaches the raise. It stays so the case still holds a stamp, which @@ -386,7 +390,7 @@ def test_check_reports_a_symlink_as_2_not_1(self) -> None: self.assertEqual(exit_code, 2) def test_check_reports_an_os_error_as_2_not_1(self) -> None: - """is_stale() reads several files beyond the one already wrapped in its own try/except, and a permissions problem or a file removed out from under it raises OSError there, not ValueError.""" + """is_stale() reads several files beyond the one already wrapped in its own try/except. A permissions problem or a file removed out from under it raises OSError there, not ValueError.""" from unittest import mock with ( @@ -398,7 +402,7 @@ def test_check_reports_an_os_error_as_2_not_1(self) -> None: self.assertEqual(exit_code, 2) def test_check_reports_an_unreadable_manifest_as_2_not_1(self) -> None: - """The manifest read has its own try/except inside is_stale() (JSONDecodeError, a genuinely stale manifest), and an OSError there has to propagate through it rather than being caught by the same clause, or an unreadable file reads as the ordinary stale result this test would otherwise miss.""" + """The manifest read has its own try/except inside is_stale() (JSONDecodeError, a genuinely stale manifest). An OSError there has to propagate through it rather than being caught by the same clause. Otherwise an unreadable file reads as the ordinary stale result this test would otherwise miss.""" if os.name != "posix": self.skipTest( "chmod does not carry POSIX unreadable-file semantics, and os.geteuid() does not exist, on this platform" @@ -611,8 +615,8 @@ def test_a_malformed_region_is_refused(self) -> None: def test_a_near_miss_marker_is_refused_rather_than_read_as_content(self) -> None: """A line that begins like a marker and matches neither form is a failure, not content. - Read as content it would leave the region unfilled while --check exits 0, which is the - hole in a skill the mechanism exists to prevent, so it is refused wherever the scan meets + Read as content it would leave the region unfilled while --check exits 0. That is the + hole in a skill the mechanism exists to prevent. So it is refused wherever the scan meets it: in the skill, and in a source the skill includes. """ cases = { @@ -778,7 +782,7 @@ def test_a_source_with_text_around_its_own_region_renders_single_blank_lines(sel def test_a_region_in_a_declared_destination_is_filled_and_held_to_its_source(self) -> None: """A declared destination is written like a skill document, and --check holds it to its source. - This is the whole point of the declaration: a surface outside the skills tree stops + This is the whole point of the declaration. A surface outside the skills tree stops restating a rule by hand and carries the home's own text instead. """ doc = self.tmp / "docs" / "map.md" @@ -823,8 +827,12 @@ def test_a_declared_destination_that_is_not_in_the_tree_is_refused(self) -> None """A stale declaration fails the build rather than quietly naming nothing. Named at the same rules a source is named at, since filling a region rewrites the file it - sits in, so a destination that is missing, mis-spelled, reached through a symlink, or under - a generated tree is worse to write than it is to read. + sits in. So a destination is worse to write than it is to read when it is: + + - missing + - mis-spelled + - reached through a symlink + - under a generated tree """ self.make_skill("foo", self.region("RULES.md > Alpha")) outside = Path(self.enterContext(tempfile.TemporaryDirectory())) / "outside.md" @@ -847,7 +855,8 @@ def test_a_declared_destination_that_is_not_in_the_tree_is_refused(self) -> None self.declare_destinations(rel) with self.assertRaises(ValueError) as caught: build_dist.regenerate() - # Named, because include_source() raises the same words for a source, so a reader of the bare message cannot tell which of the two named the path. + # Named, because include_source() raises the same words for a source. + # So a reader of the bare message cannot tell which of the two named the path. self.assertIn("INCLUDE_DESTINATIONS", str(caught.exception)) def test_the_refusal_names_the_declaration_that_would_admit_the_file(self) -> None: @@ -937,8 +946,8 @@ def test_a_body_leaving_a_fence_open_is_refused(self) -> None: build_dist.regenerate() def test_the_reader_accessor_adds_the_spec_directory_to_sys_path_once(self) -> None: - """The cache would otherwise hide this: held, the accessor's body runs once per process, so - the `not in sys.path` guard could be deleted and the path still grow by only one. Clearing + """The cache would otherwise hide this. Held, the accessor's body runs once per process. + So the `not in sys.path` guard could be deleted and the path still grow by only one. Clearing between calls makes the body run each time, which is what the guard is there for.""" before = len(sys.path) for _ in range(3): @@ -994,7 +1003,7 @@ def test_a_destination_the_manifest_carries_content_from_is_refused(self) -> Non def test_presence_fidelity_is_admitted_however_it_is_spelled(self) -> None: """presence carries no content and is also the default, so the two spellings are one declaration. - Reading any fidelity as carrying would answer differently for the two, so a manifest edit + Reading any fidelity as carrying would answer differently for the two. So a manifest edit respelling one entry would change what this check says about it. README.md, which spec/fidelity-model.md names as a presence file, is spelled the bare way today. """ @@ -1042,8 +1051,8 @@ def test_a_tree_source_is_matched_whichever_way_its_segments_are_spelled(self) - def test_a_tree_whose_source_is_missing_or_not_a_string_is_refused(self) -> None: """Skipping it would leave the tree unmatched, so its destinations would read as uncarried. - Both top-level keys can be present and well shaped while one tree entry is not, which the - shape check on the containers above does not reach. + Both top-level keys can be present and well shaped while one tree entry is not. The + shape check on the containers above does not reach that. """ (self.tmp / "docs").mkdir(parents=True, exist_ok=True) (self.tmp / "docs" / "map.md").write_text("## Own\n\nOwn.\n", encoding="utf-8") @@ -1108,7 +1117,7 @@ def test_a_manifest_missing_a_required_key_is_refused_rather_than_read_as_empty( """Both keys are required, and a truncated manifest read as empty carries nothing at all. That answer would report every carried file as uncarried, admitting a destination this - check exists to refuse, so a key that is absent is a manifest that cannot be read rather + check exists to refuse. So a key that is absent is a manifest that cannot be read rather than one declaring nothing there. """ for label, payload in { @@ -1139,7 +1148,7 @@ def test_a_manifest_of_the_wrong_shape_refuses_rather_than_raising_past_the_call """json.loads accepts any JSON, and main() catches ValueError and OSError and nothing else. An AttributeError from .get() on a list would leave the run as an uncaught traceback, - exiting 1, which is the code a caller reads as stale rather than as a check that could not + exiting 1. That is the code a caller reads as stale rather than as a check that could not run. """ cases: dict[str, object] = { @@ -1158,8 +1167,8 @@ def test_a_manifest_of_the_wrong_shape_refuses_rather_than_raising_past_the_call def test_a_destination_that_is_a_reference_target_is_refused(self) -> None: """A reference names the hub file a carrier's own copy is made from, under another name there. - The entry's own path is the carrier's name for it, so testing that alone leaves the hub - file the copy is made from admitted, which hands every carrier of that entry a region. + The entry's own path is the carrier's name for it. So testing that alone leaves the hub + file the copy is made from admitted. That hands every carrier of that entry a region. """ (self.tmp / "catalog").mkdir(parents=True, exist_ok=True) (self.tmp / "catalog" / "snippet.md").write_text("## Own\n\nOwn.\n", encoding="utf-8") @@ -1181,7 +1190,7 @@ def test_a_destination_that_is_a_reference_target_is_refused(self) -> None: def test_a_destination_under_a_carried_tree_is_refused(self) -> None: """A tree reaches beneath itself, so the manifest need not name the destination file. - Its `include` globs can narrow what it actually carries, and this refuses on the source + Its `include` globs can narrow what it actually carries. This refuses on the source alone rather than reading them, which over-refuses in the direction that is safe. """ (self.tmp / "docs").mkdir(parents=True, exist_ok=True) diff --git a/tests/test_carry.py b/tests/test_carry.py index 1fd97b870..5b13641cb 100755 --- a/tests/test_carry.py +++ b/tests/test_carry.py @@ -554,8 +554,8 @@ def test_replace_sections_preserves_the_targets_line_endings(self) -> None: self.assertIn("The hub's current wording.\r\n", result) def test_replace_sections_reads_the_ending_from_a_file_whose_heading_ends_it(self) -> None: - """A heading that is the last line of a file with no final newline carries no terminator, so - the ending has to come from the file rather than defaulting to LF and leaving it mixed.""" + """A heading that is the last line of a file with no final newline carries no terminator. + So the ending has to come from the file rather than defaulting to LF and leaving it mixed.""" hub = carry.split_lines("# T\n\n## X\n\nhub body\n") target = carry.split_lines("# T\r\n\r\n## X") @@ -613,8 +613,8 @@ def test_plan_unit_refuses_a_section_absent_from_the_target(self) -> None: def test_a_section_ending_the_hub_file_keeps_a_blank_line_before_what_follows(self) -> None: """The hub's last section carries no blank line before a following heading, because it has - none. `spec/audit.py` reads that and one blank line as the same content, so the boundary is - reconciled to what the target document needs rather than copied byte-for-byte.""" + none. `spec/audit.py` reads that and one blank line as the same content. + So the boundary is reconciled to what the target document needs rather than copied byte-for-byte.""" hub = carry.split_lines(HUB_DOC) target = carry.split_lines( TARGET_DOC + "\n## Repository Local Rule\n\nWritten after a local fault.\n" @@ -647,7 +647,8 @@ def test_a_section_ending_the_target_file_keeps_no_trailing_blank_line(self) -> def test_comparable_region_matches_the_fidelity_check_across_the_file_boundary(self) -> None: """A region's raw bytes and `extract_section` differ by one newline exactly when one copy's - section ends its file and the other's does not, which is what this form reconciles.""" + section ends its file and the other's does not. + This form reconciles that difference.""" hub = "# T\n\n## X\n\nbody\n" padded = "# T\n\n## X\n\nbody\n\n## Y\n\nlocal\n" diff --git a/tests/test_handoff.py b/tests/test_handoff.py index 519dd30c7..013c8255e 100755 --- a/tests/test_handoff.py +++ b/tests/test_handoff.py @@ -1,15 +1,15 @@ #!/usr/bin/env python3 """Exercise handoff.py's chain model, refusals, and write ordering against a stubbed `gh`. -Every case here drives the real subcommands through a fake `gh` holding an in-memory repository, -because the properties under test are about what the script decides and in what order it writes, -not about GitHub. The one thing a stub cannot prove is that `gh` accepts these argument lists, so -each write case asserts the argv it produced rather than only its effect. +Every case here drives the real subcommands through a fake `gh` holding an in-memory repository. +That is because the properties under test are about what the script decides and in what order it +writes, not about GitHub. The one thing a stub cannot prove is that `gh` accepts these argument lists. +So each write case asserts the argv it produced rather than only its effect. Two refusals earn cases of their own because the chain exists to fix them. An ambiguous track is -never resolved by picking, and a fleet repository missing the label refuses rather than reporting +never resolved by picking. A fleet repository missing the label refuses rather than reporting an empty chain, which is the silent failure the `decision` label already demonstrated fleet-wide. -A repository outside the fleet warns instead, and its cases prove the warning path never writes +A repository outside the fleet warns instead. Its cases prove the warning path never writes anything but the one label `new --create-label` asks for. Run as `python3 tests/test_handoff.py`, or under @@ -41,8 +41,8 @@ def setUpModule() -> None: """Read `o/r` as a fleet repository unless a case says otherwise. - The write scope reads the registry before every write, and the real one lists no `o`, so - without this every write case would test the scope refusal instead of what it names. + The write scope reads the registry before every write, and the real one lists no `o`. + So without this every write case would test the scope refusal instead of what it names. """ patcher = unittest.mock.patch.object(handoff, "registry", lambda: ("o", {"r"})) patcher.start() @@ -71,9 +71,9 @@ def subcommands() -> dict[str, argparse.ArgumentParser]: def projected(row: dict, argv: list[str]) -> dict: """`row` cut down to the fields `--json` asked for, the way real `gh` answers. - A fake returning every field regardless is a fake that models a superset of the tool, and a - caller reading a field it never asked for then passes here and raises against `gh`. That is - exactly what happened: `open_handoffs` did not request `state`, `cmd_new` read it, and 65 + A fake returning every field regardless is a fake that models a superset of the tool. + A caller reading a field it never asked for then passes here and raises against `gh`. That is + exactly what happened. `open_handoffs` did not request `state`, and `cmd_new` read it. 65 green cases said nothing because this stub handed the field over anyway. """ if "--json" not in argv: @@ -108,8 +108,8 @@ class FakeGh: states, page size, cursor, and node selection. A stub looser than the tool it stands in for is a stub that green-lights a crash. - It also refuses any call carrying no `--repo`, apart from `repo view`, which names its - repository positionally and is refused instead where that name is no OWNER/NAME. Real `gh` + It also refuses any call carrying no `--repo`, apart from `repo view`. + That call names its repository positionally and is refused instead where that name is no OWNER/NAME. Real `gh` would answer a call missing its repository by resolving it from the working directory's remote. That is the failure this whole script is built against. Without this the argument could be dropped from any call site with every case still green. The GraphQL list names its @@ -1126,7 +1126,7 @@ def test_a_closed_out_lane_chains_on_rather_than_starting_a_second_chain(self) - """The orphan case: no open handoff on a track is not the same as no handoff at all. Reading the second as the first files round 1 with `previous=none` beside a chain that - already exists, which is exactly the lost history the chain replaces a file to avoid. + already exists. That is exactly the lost history the chain replaces a file to avoid. """ fake = FakeGh({30: link(30, "default", 4, 29, state="CLOSED")}) code, out, _ = run( @@ -1173,7 +1173,7 @@ def test_the_first_handoff_on_a_track_closes_nothing(self) -> None: def test_new_refuses_an_open_head_that_already_has_a_successor(self) -> None: """The open head takes precedence over the closed side, and it can still be succeeded. - A closed link naming the open head as its predecessor is the reachable shape: filing onto + A closed link naming the open head as its predecessor is the reachable shape. Filing onto that head again would put two links at one round and leave the closed one unreachable. """ fake = FakeGh( @@ -1203,7 +1203,7 @@ def test_new_refuses_over_a_bare_closed_row(self) -> None: `on_track` takes precedence unconditionally, and the closed side is scanned only when it returns nothing. So the head check is the one read that meets a bare closed row on this - path, and reading it as "nothing succeeds the open head" is the guess that forks the lane. + path. Reading it as "nothing succeeds the open head" is the guess that forks the lane. """ fake = FakeGh( { @@ -1730,9 +1730,13 @@ def test_archived_rounds_index_newest_first_and_the_body_is_not_repeated(self) - class HeadCheckCase(unittest.TestCase): """A read resolving a head the chain has moved past says so, rather than answering over it. - Each state is one `new` refuses over and the reads once answered silently: a successor closed - by hand while its predecessor stayed open, an open head below the closed side's round, and two - links at one round. The text a search is for sits in the link the head does not reach. + Each state is one `new` refuses over and the reads once answered silently: + + - a successor closed by hand while its predecessor stayed open + - an open head below the closed side's round + - two links at one round + + The text a search is for sits in the link the head does not reach. """ def successor_closed_by_hand(self) -> FakeGh: @@ -1981,7 +1985,7 @@ def test_json_refuses_a_filled_window_rather_than_printing_a_partial_array(self) class GhBoundaryCase(unittest.TestCase): """`run_gh` and the write confirmations, which patching `run_gh` wholesale never executes. - Every other case here replaces `run_gh`, so its own body, and the confirmations the write + Every other case here replaces `run_gh`. So its own body, and the confirmations the write helpers make on top of it, ran in no test at all. Four guards could be deleted outright with the suite green, one of them the close read-back `GOVERNANCE.md` "Repository Boundaries and Write Safety" requires. @@ -2145,8 +2149,8 @@ def test_gh_output_is_decoded_as_utf8_rather_than_the_platform_locale(self) -> N def test_a_body_file_is_written_with_lf_whatever_the_platform_does(self) -> None: """Text mode translates LF to CRLF on Windows, and the marker would stop parsing. - The newline argument is asserted rather than the bytes, because on Linux the two are - identical and the check would pass without the argument on the only platform CI runs. + The newline argument is asserted rather than the bytes. That is because on Linux the two are + identical, and the check would pass without the argument on the only platform CI runs. """ with ( unittest.mock.patch.object(Path, "write_text") as wrote, @@ -2242,7 +2246,7 @@ def test_a_carriage_return_before_a_space_is_not_revived_either(self) -> None: def test_a_marker_line_the_grammar_rejects_is_not_revived_by_the_strip(self) -> None: """`str.rstrip()` removes 25 whitespace characters where `MARKER` tolerates three. - Stripping wider turns a line the grammar counts as ordinary text back into a block, which + Stripping wider turns a line the grammar counts as ordinary text back into a block. That block then sits beside the one `with_marker` appends and leaves a body no command can read. """ quoted = handoff.render_marker("lane", 4, 900) + "\u00a0" diff --git a/tests/test_skills_install.py b/tests/test_skills_install.py index ae8340e40..7bad26413 100755 --- a/tests/test_skills_install.py +++ b/tests/test_skills_install.py @@ -37,7 +37,7 @@ def test_a_tilde_override_expands_to_the_real_home_directory(self) -> None: class SourceRefCase(unittest.TestCase): """source_ref()'s dirty check must watch every path this installer actually reads from, - not only .agents/skills/, or a modified marketplace.json/generated plugin would report + not only .agents/skills/. Otherwise a modified marketplace.json/generated plugin would report dirty=False over bytes that were never installed.""" def test_git_status_is_scoped_to_both_watched_paths(self) -> None: @@ -64,7 +64,7 @@ def fake_run(args, **kwargs): def test_a_handed_in_commit_stands_in_where_git_cannot_answer(self) -> None: """A bootstrap runs the installer from a tarball tree with no .git, and hands in the commit - it resolved before downloading, so the stamp stays checkable rather than permanently stale.""" + it resolved before downloading. So the stamp stays checkable rather than permanently stale.""" def no_git(args, **kwargs): result = mock.Mock() @@ -83,7 +83,7 @@ def no_git(args, **kwargs): def test_a_bootstrap_tree_is_never_asked_of_git(self) -> None: """A kept tree inside a home directory kept in git would otherwise stamp that repository's - commit, and read dirty for every path it does not track.""" + commit. It would also read dirty for every path it does not track.""" tree = Path(self.enterContext(tempfile.TemporaryDirectory())) (tree / skills_install.BOOTSTRAP_OWNED_MARKER).write_text("", encoding="utf-8") (tree / skills_install.BOOTSTRAP_COMMIT_MARKER).write_text("cafe1234\n", encoding="utf-8") @@ -98,8 +98,8 @@ def test_a_bootstrap_tree_is_never_asked_of_git(self) -> None: ) def test_a_bootstrap_tree_with_no_commit_is_an_archive_judged_by_nothing(self) -> None: - """A loader whose resolve failed writes no commit, and the tree is still no checkout, so - the intended revision must not be read from whatever repository encloses it.""" + """A loader whose resolve failed writes no commit, and the tree is still no checkout. + So the intended revision must not be read from whatever repository encloses it.""" tree = Path(self.enterContext(tempfile.TemporaryDirectory())) (tree / skills_install.BOOTSTRAP_OWNED_MARKER).write_text("", encoding="utf-8") with ( @@ -221,8 +221,9 @@ def test_a_retired_fleet_skill_is_removed_on_the_next_install(self) -> None: @requires_symlink def test_a_symlinked_directory_in_the_cleanup_scan_is_skipped_not_crashed_on(self) -> None: """is_dir() alone follows a symlink, and shutil.rmtree() refuses a top-level symlink - with an uncaught OSError. A stray symlink under the shared target, even one whose real - target happens to carry the marker, must be skipped rather than blow up the install.""" + with an uncaught OSError. A stray symlink under the shared target must be skipped rather + than blow up the install. That holds even for one whose real target happens to carry the + marker.""" real = self.tmp / "elsewhere" real.mkdir() (real / skills_install.INSTALLED_MARKER).write_text("", encoding="utf-8") @@ -238,8 +239,8 @@ def test_a_symlinked_directory_in_the_cleanup_scan_is_skipped_not_crashed_on(sel def test_a_same_named_third_party_skill_is_never_removed_as_if_retired(self) -> None: """The marker, not the name, decides what this installer may remove. A third-party - skill happening to share a name with something the fleet once published must survive, - even though a name-only check would read it as "our old content, now gone".""" + skill happening to share a name with something the fleet once published must survive. + That holds even though a name-only check would read it as "our old content, now gone".""" target = self.tmp / "target" target.mkdir(parents=True) (target / "bar").mkdir() @@ -281,8 +282,8 @@ def test_a_symlink_in_a_skill_directory_is_rejected(self) -> None: class ReportCase(unittest.TestCase): - """--report answers the snapshot copy and the live checkout separately, and exits on the - snapshot's verdict alone, since the live channel following its checkout is the design.""" + """--report answers the snapshot copy and the live checkout separately. + It exits on the snapshot's verdict alone, since the live channel following its checkout is the design.""" def setUp(self) -> None: self.tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) @@ -389,7 +390,7 @@ def test_a_dict_stamp_with_a_non_dict_source_reports_not_current_instead_of_cras self.assertEqual(exit_code, 1) def test_every_cannot_read_path_emits_the_judged_snapshot_shape(self) -> None: - """A parser reads one shape, so a missing, unreadable, or unrecognized stamp carries every + """A parser reads one shape. So a missing, unreadable, or unrecognized stamp carries every key a judged snapshot does, null where the stamp would have said, and resolves intended.""" keys = {"installedFrom", "installedDirty", "intended", "intendedRef", "current", "reason"} self.write_stamp({"commit": "abc"}) @@ -466,8 +467,8 @@ def test_origin_main_is_preferred_over_a_local_main(self) -> None: def test_a_fetched_tarball_is_judged_against_the_commit_its_loader_resolved(self) -> None: """A bootstrap reports from a tarball tree with no git to ask, handing in the commit it - resolved. Judging that host against a main it cannot resolve would fail every fresh host, - and one bootstrapped from another ref would be judged against the wrong revision.""" + resolved. Judging that host against a main it cannot resolve would fail every fresh host. + And one bootstrapped from another ref would be judged against the wrong revision.""" with ( mock.patch( "skills_install.source_ref", @@ -646,7 +647,8 @@ def fake(root, *args): def test_the_dirty_check_counts_ignored_and_untracked_files(self) -> None: """Claude Code loads an ignored or untracked file under the plugin tree the same as a - tracked one, so a status that hides either would call that content clean.""" + tracked one. + So a status that hides either would call that content clean.""" self.listing( json.dumps( [{"name": skills_install.MARKETPLACE_NAME, "installLocation": "/hub/checkout"}] @@ -725,7 +727,7 @@ def test_a_kept_tree_with_no_commit_marker_names_no_commit(self) -> None: class PluginStateCase(unittest.TestCase): """The marketplace listing proves the directory is registered, not that Claude Code loads - its plugin, so the plugin's install and enabled state is read from `claude plugin list`.""" + its plugin. So the plugin's install and enabled state is read from `claude plugin list`.""" def setUp(self) -> None: self.addCleanup(mock.patch.stopall) @@ -785,8 +787,8 @@ def test_the_live_channel_carries_the_state_only_where_registered(self) -> None: class MainExitCodeCase(unittest.TestCase): """A caller scripting this installer needs the exit code to distinguish a real failure - (claude present but registration failed) from an expected partial install (no claude on - this machine at all).""" + from an expected partial install. A real failure is claude present but registration failed. + An expected partial install is no claude on this machine at all.""" def setUp(self) -> None: self.tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) @@ -1012,7 +1014,7 @@ def test_no_listing_registers_nothing_and_fails(self) -> None: class IntendedInBootstrapTreeCase(unittest.TestCase): """A named revision is resolved by git, which inside a bootstrap tree answers for whatever - repository encloses it, so the report would judge the copy against an unrelated commit.""" + repository encloses it. So the report would judge the copy against an unrelated commit.""" def test_intended_is_refused_in_a_bootstrap_tree(self) -> None: tree = Path(self.enterContext(tempfile.TemporaryDirectory())) @@ -1067,7 +1069,7 @@ def test_a_sudo_run_is_refused_and_names_the_invoking_user(self) -> None: def test_a_root_account_with_no_sudo_user_still_installs(self) -> None: """A container or a Proxmox node whose working account is root installs for root, which - is correct there, so the guard reads SUDO_USER rather than EUID alone.""" + is correct there. So the guard reads SUDO_USER rather than EUID alone.""" r = self.run_wrapper(root=True, sudo_user=None) self.assertEqual(r.returncode, 0) self.assertIn("[dry run]", r.stdout)