napkinstack 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {napkinstack-0.4.0 → napkinstack-0.5.0}/PKG-INFO +25 -6
  2. {napkinstack-0.4.0 → napkinstack-0.5.0}/README.md +24 -5
  3. {napkinstack-0.4.0 → napkinstack-0.5.0}/pyproject.toml +1 -1
  4. {napkinstack-0.4.0 → napkinstack-0.5.0}/pyproject.toml.orig +1 -1
  5. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/cli.py +7 -2
  6. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/compat.py +23 -9
  7. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/doctor.py +38 -2
  8. napkinstack-0.5.0/src/napkinstack/landed.py +122 -0
  9. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/modules.py +8 -3
  10. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/provenance.py +10 -2
  11. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/pull_request.py +11 -3
  12. {napkinstack-0.4.0 → napkinstack-0.5.0}/LICENSE +0 -0
  13. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/__init__.py +0 -0
  14. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/discovery.py +0 -0
  15. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/fitness/__init__.py +0 -0
  16. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/fitness/boundaries.py +0 -0
  17. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/fitness/hygiene.py +0 -0
  18. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/fitness/manifests.py +0 -0
  19. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/fitness/plan.py +0 -0
  20. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/fitness/pr_scope.py +0 -0
  21. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/project.py +0 -0
  22. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/skills.py +0 -0
  23. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
  24. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -0
  25. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/templates/module/README.md +0 -0
  26. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/templates/module/docs/adr/.gitkeep +0 -0
  27. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  28. {napkinstack-0.4.0 → napkinstack-0.5.0}/src/napkinstack/templates/module/tests/.gitkeep +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: napkinstack
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Engineering framework for several teams and their agents working in one repository: modules, contracts, guardrails in CI.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -24,7 +24,11 @@ Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
24
24
  > the framework announces refuses what it claims to — the boundaries read the contracts a
25
25
  > module uses, a contract version someone relies on changes only with a proof, the module
26
26
  > checks and stale approvals are enforced, a verifier is not an author, and every verdict names
27
- > the framework that gave it. A first pilot project, private, starts from it. Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
27
+ > the framework that gave it. Proved on the project that exposed the defects: eight probes
28
+ > replayed, each refused or accepted as announced. A first pilot project, private, starts from
29
+ > v0.5.0, which closes a way around the contract check found while proving it (D44) and says
30
+ > what holds on a repository no plan lets the forge guard (PDR-0006).
31
+ > Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
28
32
  > [move to English](docs/governance/plans/2026-09-16-english-migration.md),
29
33
  > [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
30
34
  > [every barrier refuses](docs/governance/plans/2026-09-19-v0.4.0-every-barrier-refuses.md),
@@ -78,10 +82,25 @@ uv tool install napkinstack --with-executables-from pre-commit # prerequisites
78
82
  nstack init my-project
79
83
  ```
80
84
 
81
- Each project then pins its version and changes it through `nstack update`. Every published
82
- version carries a provenance attestation, visible on PyPI, tying it to the workflow and
83
- the commit of this repository
84
- ([ADR-0002](docs/adr/0002-distribute-napkinstack-on-pypi.md)).
85
+ To try it without installing anything, or to install it from this repository instead of the
86
+ registry — always pinned to a release tag:
87
+
88
+ ```bash
89
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.4.0" nstack init my-project
90
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.4.0"
91
+ ```
92
+
93
+ **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
94
+ `vX.Y.Z` tag carries a provenance attestation tying it to the workflow and the commit of this
95
+ repository. The forge distributes that same tag's source, which is a supported way in and
96
+ never a published one — a tag can be moved, and carries no attestation — so a run installed
97
+ that way says where its rules came from, on every line that judges
98
+ ([ADR-0002](docs/adr/0002-distribute-napkinstack-on-pypi.md),
99
+ [PDR-0005](docs/pdr/0005-work-on-the-framework-while-using-it.md)). The project itself is
100
+ unaffected: created from either channel at the same tag, it records the same version and its
101
+ CI installs from the registry.
102
+
103
+ Each project then pins its version and changes it through `nstack update`.
85
104
 
86
105
  ## The commands
87
106
 
@@ -9,7 +9,11 @@ Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
9
9
  > the framework announces refuses what it claims to — the boundaries read the contracts a
10
10
  > module uses, a contract version someone relies on changes only with a proof, the module
11
11
  > checks and stale approvals are enforced, a verifier is not an author, and every verdict names
12
- > the framework that gave it. A first pilot project, private, starts from it. Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
12
+ > the framework that gave it. Proved on the project that exposed the defects: eight probes
13
+ > replayed, each refused or accepted as announced. A first pilot project, private, starts from
14
+ > v0.5.0, which closes a way around the contract check found while proving it (D44) and says
15
+ > what holds on a repository no plan lets the forge guard (PDR-0006).
16
+ > Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
13
17
  > [move to English](docs/governance/plans/2026-09-16-english-migration.md),
14
18
  > [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
15
19
  > [every barrier refuses](docs/governance/plans/2026-09-19-v0.4.0-every-barrier-refuses.md),
@@ -63,10 +67,25 @@ uv tool install napkinstack --with-executables-from pre-commit # prerequisites
63
67
  nstack init my-project
64
68
  ```
65
69
 
66
- Each project then pins its version and changes it through `nstack update`. Every published
67
- version carries a provenance attestation, visible on PyPI, tying it to the workflow and
68
- the commit of this repository
69
- ([ADR-0002](docs/adr/0002-distribute-napkinstack-on-pypi.md)).
70
+ To try it without installing anything, or to install it from this repository instead of the
71
+ registry — always pinned to a release tag:
72
+
73
+ ```bash
74
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.4.0" nstack init my-project
75
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.4.0"
76
+ ```
77
+
78
+ **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
79
+ `vX.Y.Z` tag carries a provenance attestation tying it to the workflow and the commit of this
80
+ repository. The forge distributes that same tag's source, which is a supported way in and
81
+ never a published one — a tag can be moved, and carries no attestation — so a run installed
82
+ that way says where its rules came from, on every line that judges
83
+ ([ADR-0002](docs/adr/0002-distribute-napkinstack-on-pypi.md),
84
+ [PDR-0005](docs/pdr/0005-work-on-the-framework-while-using-it.md)). The project itself is
85
+ unaffected: created from either channel at the same tag, it records the same version and its
86
+ CI installs from the registry.
87
+
88
+ Each project then pins its version and changes it through `nstack update`.
70
89
 
71
90
  ## The commands
72
91
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Engineering framework for several teams and their agents working in one repository: modules, contracts, guardrails in CI."
5
5
  requires-python = ">=3.12"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Engineering framework for several teams and their agents working in one repository: modules, contracts, guardrails in CI."
5
5
  requires-python = ">=3.12"
6
6
  license = "MIT"
@@ -6,7 +6,8 @@ import argparse
6
6
  import json
7
7
  from pathlib import Path
8
8
 
9
- from napkinstack import __version__, compat, discovery, doctor, modules, provenance, pull_request, skills
9
+ from napkinstack import (__version__, compat, discovery, doctor, landed, modules, provenance,
10
+ pull_request, skills)
10
11
  from napkinstack.fitness import boundaries, hygiene, manifests, plan, pr_scope
11
12
 
12
13
 
@@ -105,6 +106,10 @@ def build_parser() -> argparse.ArgumentParser:
105
106
  lambda a: pull_request.run(a.root, a.base, a.body_file))
106
107
  pc.add_argument("--base", default="origin/main")
107
108
  pc.add_argument("--body-file", type=Path, help="the description, when PR_BODY is not set")
109
+ ld = _add(sub, "landed", "what reached this branch outside a pull request, recorded (W1)",
110
+ lambda a: landed.run(a.root, a.span, landed.from_environment(a.root)))
111
+ ld.add_argument("--span", default="HEAD~1..HEAD",
112
+ help="the commits to read, BEFORE..AFTER (default: the last commit)")
108
113
  ds = _add(sub, "discover", "starts a discovery from an idea file, for the team's agent (PDR-0002)",
109
114
  lambda a: discovery.run(a.root, a.idea))
110
115
  ds.add_argument("idea", type=Path, help="the idea, a .md or .txt file")
@@ -124,7 +129,7 @@ def build_parser() -> argparse.ArgumentParser:
124
129
 
125
130
 
126
131
  JUDGES = {"manifests", "boundaries", "plan", "skills", "hygiene", "fitness", "doctor", "pr-scope",
127
- "pr-check", "compat"} # the commands whose verdict depends on the framework's rules
132
+ "pr-check", "compat", "landed"} # the commands whose verdict depends on the framework's rules
128
133
 
129
134
 
130
135
  def main(argv: list[str] | None = None) -> int:
@@ -98,12 +98,15 @@ def _holder(root: Path, path: str) -> tuple[str, Path] | None:
98
98
  return None
99
99
 
100
100
 
101
- def _extract(root: Path, base: str, path: str, into: Path) -> Path:
102
- archive = subprocess.run(["git", "archive", "--format=tar", base, "--", path], cwd=root,
101
+ def _base_tree(root: Path, base: str, into: Path) -> Path:
102
+ """The whole repository as it is at the base. The proof runs here, so the change it judges
103
+ cannot rewrite what judges it — a script, a fixture, anything it reads (D44). Measured on
104
+ 2026-09-20: 7 ms for 1.6 MB."""
105
+ archive = subprocess.run(["git", "archive", "--format=tar", base], cwd=root,
103
106
  capture_output=True, check=True).stdout
104
107
  with tarfile.open(fileobj=io.BytesIO(archive)) as tar:
105
108
  tar.extractall(into, filter="data")
106
- return into / path
109
+ return into
107
110
 
108
111
 
109
112
  def run(root: Path, module: str | None, base: str) -> int:
@@ -152,15 +155,26 @@ def run(root: Path, module: str | None, base: str) -> int:
152
155
  "a new version beside it (docs/os/03-contracts.md §4).")
153
156
  continue
154
157
  with tempfile.TemporaryDirectory() as temporary:
158
+ tree = _base_tree(root, fork, Path(temporary))
159
+ where_it_runs = tree / folder.relative_to(root)
160
+ if not where_it_runs.is_dir():
161
+ failures.append(f"[V1] {label}: module '{name}' has no folder at the base, so its "
162
+ f"compatibility command cannot be run as merged.\n Action: "
163
+ "declare the command where the module now lives, in a pull request "
164
+ "of its own (docs/os/03-contracts.md §4).")
165
+ continue
155
166
  env = {**os.environ, "NSTACK_CONTRACT": contract, "NSTACK_VERSION": version,
156
- "NSTACK_BASE_PATH": str(_extract(root, fork, path, Path(temporary))),
157
- "NSTACK_HEAD_PATH": str(root / target)}
167
+ "NSTACK_BASE_PATH": str(tree / path), "NSTACK_HEAD_PATH": str(root / target)}
158
168
  print(f"-> {name}: {command}", flush=True)
159
- code = subprocess.run(command, shell=True, cwd=folder, env=env).returncode
169
+ code = subprocess.run(command, shell=True, cwd=where_it_runs, env=env).returncode
160
170
  if code:
161
- failures.append(f"[V1] {label}: `{command}` exited with {code}, a breaking change.\n"
162
- " Action: publish it as a new version beside it, then migrate its "
163
- "consumers (expand/contract, docs/os/03-contracts.md §4).")
171
+ failures.append(f"[V1] {label}: the compatibility command of module '{name}' ({where}) "
172
+ f"did not prove the change compatible (exit {code}).\n"
173
+ " Action: read the comparator's output above. If the change is "
174
+ "breaking, publish it as a new version beside this one and migrate its "
175
+ "consumers (expand/contract, docs/os/03-contracts.md §4). If the command "
176
+ "could not run — a missing tool, no network — fix the command: V1 cannot "
177
+ "pass without a proof.")
164
178
  else:
165
179
  checked.append(f"{label}: proven compatible")
166
180
  for line in checked:
@@ -44,6 +44,10 @@ from napkinstack import __version__, provenance
44
44
  from napkinstack.project import ANSWERS
45
45
 
46
46
  OK, GAP, UNKNOWN, NOT_APPLICABLE = "OK", "FAIL", "NOT VERIFIED", "NOT APPLICABLE"
47
+ OUT_OF_REACH = "OUT OF REACH" # the plan forbids it: a state, not a fault (PDR-0006)
48
+ # The settings that make the forge refuse a merge. A repository is guarded when they are
49
+ # all in force; the others matter and none of them stops a merge.
50
+ BLOCKING = ("G1", "G2", "G3", "G4", "G12", "G13")
47
51
  API_VERSION = "2026-03-10"
48
52
  PLACEHOLDER = "<One sentence: what this project does.>"
49
53
  JOBS = ("Fitness functions", "PR scope and review budget", "Hooks and secrets", "Test sheet and cycle",
@@ -287,6 +291,35 @@ def _display(rule: str, setting: str, status: str, detail: str) -> None:
287
291
  print(f" {line}")
288
292
 
289
293
 
294
+ def _state(results: list[tuple[str, str, str, str]]) -> None:
295
+ """Guarded when every setting that refuses a merge is in force; unguarded otherwise, with
296
+ what a plan forbids kept apart from what is not yet done; never guarded on what could not
297
+ be read (PDR-0006)."""
298
+ status_of = {rule: status for rule, _, status, _ in results}
299
+ blocking = [status_of.get(rule, UNKNOWN) for rule in BLOCKING]
300
+ if any(status == UNKNOWN for status in blocking):
301
+ print("\nThis repository: not verified — what cannot be read is never reported as guarded.")
302
+ return
303
+ if all(status == OK for status in blocking):
304
+ print("\nThis repository: guarded — the forge refuses what the checklist asks it to refuse.")
305
+ return
306
+ groups = {name: [rule for rule, _, status, _ in results
307
+ if status == name and rule.startswith("G")]
308
+ for name in (OK, GAP, OUT_OF_REACH)}
309
+ print("\nThis repository: unguarded — nothing here refuses a merge.")
310
+ for label, name in (("In force", OK), ("Not yet in place", GAP),
311
+ ("Out of reach on this plan", OUT_OF_REACH)):
312
+ if groups[name]:
313
+ print(f" {label:<26}: {', '.join(groups[name])}")
314
+ if groups[OUT_OF_REACH]:
315
+ print(" What it would take : make the repository public, where these work at no "
316
+ "cost on any plan;\n or move the private repository to a "
317
+ "plan that enforces rules;\n or keep working here, "
318
+ "knowing that nothing refuses.")
319
+ if groups[GAP]:
320
+ print(" Each setting not yet in place carries its action above.")
321
+
322
+
290
323
  def run(root: Path) -> int:
291
324
  if not (root / ANSWERS).is_file():
292
325
  print(f"FAIL [doctor] {ANSWERS} not found in {root}: this folder is not a project "
@@ -324,16 +357,19 @@ def run(root: Path) -> int:
324
357
  except Gap as action:
325
358
  status, detail = GAP, f"Action: {action}"
326
359
  if private and status != OK and rule in PRIVATE_PLAN:
327
- detail += f"\n{PRIVATE_PLAN[rule]}"
360
+ status, detail = OUT_OF_REACH, f"Reason: {PRIVATE_PLAN[rule]}"
328
361
  results.append((rule, setting, status, detail))
329
362
  _display(rule, setting, status, detail)
330
363
 
331
364
  gaps = sum(status == GAP for _, _, status, _ in results)
332
365
  unknown = sum(status == UNKNOWN for _, _, status, _ in results)
333
366
  skipped = sum(status == NOT_APPLICABLE for _, _, status, _ in results)
367
+ out_of_reach = sum(status == OUT_OF_REACH for _, _, status, _ in results)
368
+ _state(results)
334
369
  suffix = f", {skipped} not applicable" if skipped else ""
335
370
  if not gaps and not unknown:
336
- print(f"\nnstack doctor: compliant{suffix}.")
371
+ reach = f"; {out_of_reach} setting(s) out of reach on this plan" if out_of_reach else ""
372
+ print(f"\nnstack doctor: compliant{suffix}{reach}.")
337
373
  return 0
338
374
  print(f"\nnstack doctor: {gaps} gap(s), {unknown} not verified{suffix}.\nWorkflows inform; "
339
375
  "it is the GitHub settings that block, and they are not copied with the project.")
@@ -0,0 +1,122 @@
1
+ """
2
+ What reached the default branch (PDR-0006).
3
+
4
+ Rule:
5
+ W1 a commit on the default branch arrived through a pull request
6
+
7
+ This is a record, not a barrier. Where the forge can refuse — a ruleset requiring a pull
8
+ request — nothing reaches the default branch any other way, and this says so. Where it
9
+ cannot, because the repository's plan does not allow it, a commit pushed straight to the
10
+ branch is the one thing nobody would otherwise be told about. A record that fires late is
11
+ worth more than a silence.
12
+
13
+ It says nothing about what predates it: the commit that created the repository, and
14
+ everything the repository already held when the record arrived, are not reported. A record
15
+ that greets a project by failing on its own arrival teaches people to ignore it.
16
+
17
+ The forge is read with a token supplied by the run (GH_TOKEN, otherwise GITHUB_TOKEN), which
18
+ needs no write access of any kind: the project's automation token stays read-only (G10).
19
+ Without a token, or when the span cannot be read, nothing is claimed.
20
+
21
+ Usage : nstack landed [--root ROOT] [--span BEFORE..AFTER]
22
+ Output: 0 when every commit of the span arrived through a pull request, or when nothing
23
+ could be verified; 1 when one did not.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import subprocess
30
+ from pathlib import Path
31
+
32
+ import yaml
33
+
34
+ from napkinstack.doctor import GitHub, NotVerified
35
+ from napkinstack.project import ANSWERS
36
+
37
+ # The workflow that runs this record in a generated project. Its own arrival is what the
38
+ # record treats as its beginning; platform/tests/run.sh checks the two names match.
39
+ RECORD = ".github/workflows/commits-on-main.yml"
40
+ EMPTY = "0" * 40
41
+
42
+
43
+ def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
44
+ return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
45
+
46
+
47
+ def _commits(root: Path, span: str) -> list[str] | None:
48
+ """The commits of the span, newest first; None when the span cannot be read."""
49
+ before, _, after = span.partition("..")
50
+ if not after or before in ("", EMPTY):
51
+ after, before = (after or before), "" # a branch's first push has no before
52
+ read = (_git(root, "rev-list", f"{before}..{after}") if before
53
+ else _git(root, "rev-list", "-1", after))
54
+ return None if read.returncode else read.stdout.split()
55
+
56
+
57
+ def _beginning(root: Path) -> str | None:
58
+ """The commit that brought the record into the repository, if it is there."""
59
+ read = _git(root, "log", "--diff-filter=A", "--format=%H", "--", RECORD)
60
+ return read.stdout.split()[-1] if read.returncode == 0 and read.stdout.split() else None
61
+
62
+
63
+ def _exempt(root: Path, commit: str, beginning: str | None) -> str | None:
64
+ """Why this commit is not recorded, or None when it is to be checked."""
65
+ if _git(root, "rev-parse", "--verify", "--quiet", f"{commit}^").returncode:
66
+ return "the first commit of the repository"
67
+ if beginning and _git(root, "merge-base", "--is-ancestor", commit, beginning).returncode == 0:
68
+ return "older than the record itself"
69
+ return None
70
+
71
+
72
+ def _described(root: Path, commit: str) -> str:
73
+ return _git(root, "show", "-s", "--format=%h (%an, %ad)", "--date=short", commit).stdout.strip()
74
+
75
+
76
+ def run(root: Path, span: str, forge: GitHub | None) -> int:
77
+ commits = _commits(root, span)
78
+ if commits is None:
79
+ print(f"[W1] the commits of '{span}' cannot be read: not verified.\n"
80
+ " Action: fetch the history (fetch-depth: 0), or pass an existing range.")
81
+ return 0
82
+ if forge is None:
83
+ print("[W1] no token (GH_TOKEN or GITHUB_TOKEN): how each commit arrived is not verified.\n"
84
+ " Action: supply a token with read access to the repository's pull requests.")
85
+ return 0
86
+ beginning = _beginning(root)
87
+ outside, checked = [], 0
88
+ for commit in commits:
89
+ why = _exempt(root, commit, beginning)
90
+ if why:
91
+ print(f"Commit {_described(root, commit)}: not recorded, {why}.")
92
+ continue
93
+ try:
94
+ pulls = forge.get(f"/commits/{commit}/pulls")
95
+ except NotVerified as reason:
96
+ print(f"[W1] commit {commit[:12]}: not verified. Reason: {reason}")
97
+ continue
98
+ checked += 1
99
+ if pulls:
100
+ print(f"Commit {_described(root, commit)}: arrived through a pull request.")
101
+ else:
102
+ outside.append(commit)
103
+ for commit in outside:
104
+ print(f"FAIL [W1] commit {_described(root, commit)} reached this branch outside a pull "
105
+ "request.\n This run records it; it does not refuse it — on this repository "
106
+ "nothing could.\n Action: read the commit. If it was not meant to land this "
107
+ "way, revert it through a pull request.")
108
+ if outside:
109
+ return 1
110
+ if checked:
111
+ print(f"Every commit of this push arrived through a pull request ({checked} checked).")
112
+ return 0
113
+
114
+
115
+ def from_environment(root: Path) -> GitHub | None:
116
+ """The forge, read-only, from the project's own answers and the run's token."""
117
+ token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
118
+ if not token:
119
+ return None
120
+ answers = yaml.safe_load((root / ANSWERS).read_text(encoding="utf-8")) or {}
121
+ return GitHub(str(answers.get("github_repo")), token,
122
+ os.environ.get("GITHUB_API_URL") or "https://api.github.com")
@@ -118,10 +118,15 @@ def next_steps(root: Path, name: str, criticality: str, user_facing: bool) -> li
118
118
  why = "user-facing" if user_facing else f"criticality {criticality}"
119
119
  steps.append(f"Every pull request that changes its behaviour carries a test sheet ({why}), "
120
120
  "run by a verifier who is not its author (T1–T5, docs/os/05-workflow.md §7)")
121
- cycle = plan.accepted_cycle(root) if plan.charter_accepted(root) else None
122
- if cycle is None:
123
- steps.append("The project is not framed: delivery work needs an accepted charter and cycle (K1), "
121
+ framed = plan.charter_accepted(root)
122
+ cycle = plan.accepted_cycle(root) if framed else None
123
+ if not framed:
124
+ steps.append("The project is not framed: delivery work needs an accepted charter (K1), "
124
125
  "or the out-of-cycle label with its justification (K4)")
126
+ elif cycle is None:
127
+ # The charter is accepted: only the next cycle is missing (D47).
128
+ steps.append("No accepted cycle: delivery work needs one (K1), or the out-of-cycle label "
129
+ "with its justification (K4)")
125
130
  else:
126
131
  steps.append(f"Delivery work names a ready deliverable of {cycle[0].name} (K3), "
127
132
  "or carries the out-of-cycle label with its justification (K4)")
@@ -3,7 +3,9 @@ Which framework judges a project (PDR-0005): a published version, or something n
3
3
  published — said out loud on every run that judges, never only in a file.
4
4
 
5
5
  The engine's origin is what its installer recorded (PEP 610, direct_url.json): nothing for a
6
- version from the registry; a repository and a commit, or a local checkout, otherwise.
6
+ version from the registry; a repository with the ref it was asked for and the commit that ref
7
+ resolved to, or a local checkout, otherwise. A repository is a supported channel and never a
8
+ published one: a tag can be moved, and carries no attestation (ADR-0002).
7
9
  """
8
10
 
9
11
  from __future__ import annotations
@@ -30,7 +32,13 @@ def engine() -> tuple[str, str | None]:
30
32
  return distribution.version, None
31
33
  data = json.loads(record)
32
34
  if "vcs_info" in data:
33
- return distribution.version, f"{data['url']}@{str(data['vcs_info'].get('commit_id', ''))[:12]}"
35
+ # The installer records the ref it was asked for beside the commit (PEP 610). Naming it
36
+ # says more than a bare sha — and no less: a tag can be moved, so this is still not the
37
+ # published artefact (ADR-0002, clarified 2026-09-20).
38
+ commit = str(data["vcs_info"].get("commit_id", ""))[:12]
39
+ wanted = str(data["vcs_info"].get("requested_revision") or "")
40
+ return distribution.version, (f"{data['url']} at {wanted} (commit {commit})" if wanted
41
+ else f"{data['url']}@{commit}")
34
42
  if "archive_info" in data:
35
43
  return distribution.version, "an archive, not the registry"
36
44
  path = Path(url2pathname(urlparse(data.get("url", "")).path))
@@ -11,7 +11,8 @@ Rules:
11
11
  T3 no scenario passed without its evidence and the commit it was verified on
12
12
  T4 evidence produced on the pull request's head commit: the others are to run again
13
13
  T5 no scenario failed; none left not verified, unless it is human only
14
- K1 delivery work needs an accepted charter and an accepted cycle
14
+ K1 delivery work needs an accepted charter and an accepted cycle, and says which of
15
+ the two is missing
15
16
  K2 delivery work stops once the cycle is past its end date: the circuit breaker
16
17
  K3 delivery work names a ready or in-progress deliverable of the cycle
17
18
  K4 the out-of-cycle label carries its justification
@@ -257,11 +258,18 @@ def check_cycle(root: Path, body: str, labels: set[str], today: datetime.date, f
257
258
  "\"Out of cycle: <reason>\" to the description — an incident, a production defect.")
258
259
  return
259
260
  cycle = plan.accepted_cycle(root)
260
- if not plan.charter_accepted(root) or cycle is None:
261
- fail("K1", "the project is not framed: no accepted charter and cycle in docs/project/.\n"
261
+ if not plan.charter_accepted(root):
262
+ fail("K1", "the project is not framed: no accepted charter in docs/project/.\n"
262
263
  " Action: frame it with your agent (playbooks/framing.md), or add the "
263
264
  f"{LABEL} label with a justification.")
264
265
  return
266
+ if cycle is None:
267
+ # The charter is accepted: sending the reader back to framing would be false (D47).
268
+ fail("K1", "no accepted cycle: the charter is accepted, and no cycle is open.\n"
269
+ " Action: open the next cycle with your agent "
270
+ "(docs/project/cycles/_TEMPLATE.md), or add the "
271
+ f"{LABEL} label with a justification.")
272
+ return
265
273
  path, data = cycle
266
274
  end = plan.as_date(data.get("end"))
267
275
  if end is not None and today > end:
File without changes