napkinstack 0.4.0__tar.gz → 0.6.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.6.0}/PKG-INFO +30 -7
  2. {napkinstack-0.4.0 → napkinstack-0.6.0}/README.md +29 -6
  3. {napkinstack-0.4.0 → napkinstack-0.6.0}/pyproject.toml +1 -1
  4. {napkinstack-0.4.0 → napkinstack-0.6.0}/pyproject.toml.orig +1 -1
  5. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/cli.py +12 -4
  6. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/compat.py +23 -9
  7. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/doctor.py +38 -2
  8. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/fitness/boundaries.py +14 -0
  9. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/fitness/manifests.py +29 -1
  10. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/fitness/plan.py +11 -8
  11. napkinstack-0.6.0/src/napkinstack/landed.py +122 -0
  12. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/modules.py +49 -8
  13. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/project.py +15 -3
  14. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/provenance.py +10 -2
  15. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/pull_request.py +11 -3
  16. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -6
  17. {napkinstack-0.4.0 → napkinstack-0.6.0}/LICENSE +0 -0
  18. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/__init__.py +0 -0
  19. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/discovery.py +0 -0
  20. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/fitness/__init__.py +0 -0
  21. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/fitness/hygiene.py +0 -0
  22. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/fitness/pr_scope.py +0 -0
  23. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/skills.py +0 -0
  24. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
  25. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/README.md +0 -0
  26. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/docs/adr/.gitkeep +0 -0
  27. {napkinstack-0.4.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  28. {napkinstack-0.4.0 → napkinstack-0.6.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.6.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
@@ -20,14 +20,21 @@ modules, contracts, guardrails in CI. On the Django or Rails model, one command
20
20
  the project, which then receives new versions on demand; no application stack is imposed.
21
21
  Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
22
22
 
23
- > **Status: v0.4.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): every barrier
23
+ > **Status: v0.5.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): every barrier
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. The contract proof now runs in the base's
29
+ > tree, so a change cannot rewrite what judges it; the diagnosis says whether anything refuses
30
+ > at all; and a record names what reached the default branch outside a pull request — it
31
+ > records, it never refuses. A first pilot project, private, starts from here.
32
+ > Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
28
33
  > [move to English](docs/governance/plans/2026-09-16-english-migration.md),
29
34
  > [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
30
35
  > [every barrier refuses](docs/governance/plans/2026-09-19-v0.4.0-every-barrier-refuses.md),
36
+ > [a proof that cannot be rewritten](docs/governance/plans/2026-09-20-v0.5.0-a-proof-that-cannot-be-rewritten.md),
37
+ > [the conformance suite](docs/governance/plans/2026-09-20-m12-conformance-suite.md),
31
38
  > [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
32
39
 
33
40
  ## A project's journey
@@ -78,10 +85,25 @@ uv tool install napkinstack --with-executables-from pre-commit # prerequisites
78
85
  nstack init my-project
79
86
  ```
80
87
 
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)).
88
+ To try it without installing anything, or to install it from this repository instead of the
89
+ registry — always pinned to a release tag:
90
+
91
+ ```bash
92
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.5.0" nstack init my-project
93
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.5.0"
94
+ ```
95
+
96
+ **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
97
+ `vX.Y.Z` tag carries a provenance attestation tying it to the workflow and the commit of this
98
+ repository. The forge distributes that same tag's source, which is a supported way in and
99
+ never a published one — a tag can be moved, and carries no attestation — so a run installed
100
+ that way says where its rules came from, on every line that judges
101
+ ([ADR-0002](docs/adr/0002-distribute-napkinstack-on-pypi.md),
102
+ [PDR-0005](docs/pdr/0005-work-on-the-framework-while-using-it.md)). The project itself is
103
+ unaffected: created from either channel at the same tag, it records the same version and its
104
+ CI installs from the registry.
105
+
106
+ Each project then pins its version and changes it through `nstack update`.
85
107
 
86
108
  ## The commands
87
109
 
@@ -99,6 +121,7 @@ the commit of this repository
99
121
  | `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
100
122
  | `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
101
123
  | `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
124
+ | `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
102
125
  | `nstack skills` | Exposes the playbooks as skills for the agent |
103
126
  | `nstack update` | Lays the new version on a branch to review |
104
127
 
@@ -5,14 +5,21 @@ modules, contracts, guardrails in CI. On the Django or Rails model, one command
5
5
  the project, which then receives new versions on demand; no application stack is imposed.
6
6
  Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
7
7
 
8
- > **Status: v0.4.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): every barrier
8
+ > **Status: v0.5.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): every barrier
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. The contract proof now runs in the base's
14
+ > tree, so a change cannot rewrite what judges it; the diagnosis says whether anything refuses
15
+ > at all; and a record names what reached the default branch outside a pull request — it
16
+ > records, it never refuses. A first pilot project, private, starts from here.
17
+ > Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
13
18
  > [move to English](docs/governance/plans/2026-09-16-english-migration.md),
14
19
  > [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
15
20
  > [every barrier refuses](docs/governance/plans/2026-09-19-v0.4.0-every-barrier-refuses.md),
21
+ > [a proof that cannot be rewritten](docs/governance/plans/2026-09-20-v0.5.0-a-proof-that-cannot-be-rewritten.md),
22
+ > [the conformance suite](docs/governance/plans/2026-09-20-m12-conformance-suite.md),
16
23
  > [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
17
24
 
18
25
  ## A project's journey
@@ -63,10 +70,25 @@ uv tool install napkinstack --with-executables-from pre-commit # prerequisites
63
70
  nstack init my-project
64
71
  ```
65
72
 
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)).
73
+ To try it without installing anything, or to install it from this repository instead of the
74
+ registry — always pinned to a release tag:
75
+
76
+ ```bash
77
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.5.0" nstack init my-project
78
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.5.0"
79
+ ```
80
+
81
+ **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
82
+ `vX.Y.Z` tag carries a provenance attestation tying it to the workflow and the commit of this
83
+ repository. The forge distributes that same tag's source, which is a supported way in and
84
+ never a published one — a tag can be moved, and carries no attestation — so a run installed
85
+ that way says where its rules came from, on every line that judges
86
+ ([ADR-0002](docs/adr/0002-distribute-napkinstack-on-pypi.md),
87
+ [PDR-0005](docs/pdr/0005-work-on-the-framework-while-using-it.md)). The project itself is
88
+ unaffected: created from either channel at the same tag, it records the same version and its
89
+ CI installs from the registry.
90
+
91
+ Each project then pins its version and changes it through `nstack update`.
70
92
 
71
93
  ## The commands
72
94
 
@@ -84,6 +106,7 @@ the commit of this repository
84
106
  | `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
85
107
  | `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
86
108
  | `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
109
+ | `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
87
110
  | `nstack skills` | Exposes the playbooks as skills for the agent |
88
111
  | `nstack update` | Lays the new version on a branch to review |
89
112
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.4.0"
3
+ version = "0.6.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.6.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
 
@@ -18,7 +19,7 @@ def _root(value: str) -> Path:
18
19
 
19
20
 
20
21
  def _modules(args: argparse.Namespace) -> int:
21
- found = modules.listing(args.root, args.changed_since)
22
+ found = modules.listing(args.root, args.changed_since, args.with_contract_sides)
22
23
  if found is None:
23
24
  print(f"FAIL [modules] base '{args.changed_since}' not found in {args.root}.\n"
24
25
  " Action: fetch the history (fetch-depth: 0), or pass an existing commit.")
@@ -62,7 +63,7 @@ def build_parser() -> argparse.ArgumentParser:
62
63
  sub = parser.add_subparsers(dest="command", required=True, metavar="command")
63
64
  _add(sub, "manifests", "manifests, lifecycles, deprecations (M1-M10)",
64
65
  lambda a: manifests.run(a.root))
65
- _add(sub, "boundaries", "declared graph against real graph (B1-B7)",
66
+ _add(sub, "boundaries", "declared graph against real graph (B1-B8)",
66
67
  lambda a: boundaries.run(a.root))
67
68
  _add(sub, "plan", "the discovery, the charter and the cycles (C1-C7)", lambda a: plan.run(a.root))
68
69
  sk = _add(sub, "skills", "generates or checks the skills (S1-S4)",
@@ -97,6 +98,9 @@ def build_parser() -> argparse.ArgumentParser:
97
98
  rn.add_argument("module")
98
99
  md = _add(sub, "modules", "the project's modules, or those with a file changed since a base", _modules)
99
100
  md.add_argument("--changed-since", metavar="BASE", help="only the modules with a file changed since BASE")
101
+ md.add_argument("--with-contract-sides", action="store_true",
102
+ help="also the producer and the declared consumers of a contract version "
103
+ "this change touches (with --changed-since)")
100
104
  md.add_argument("--json", action="store_true", help="a JSON list, for CI")
101
105
  ps = _add(sub, "pr-scope", "one PR = one module, review budget (P1-P2)",
102
106
  lambda a: pr_scope.run(a.root, a.base))
@@ -105,6 +109,10 @@ def build_parser() -> argparse.ArgumentParser:
105
109
  lambda a: pull_request.run(a.root, a.base, a.body_file))
106
110
  pc.add_argument("--base", default="origin/main")
107
111
  pc.add_argument("--body-file", type=Path, help="the description, when PR_BODY is not set")
112
+ ld = _add(sub, "landed", "what reached this branch outside a pull request, recorded (W1)",
113
+ lambda a: landed.run(a.root, a.span, landed.from_environment(a.root)))
114
+ ld.add_argument("--span", default="HEAD~1..HEAD",
115
+ help="the commits to read, BEFORE..AFTER (default: the last commit)")
108
116
  ds = _add(sub, "discover", "starts a discovery from an idea file, for the team's agent (PDR-0002)",
109
117
  lambda a: discovery.run(a.root, a.idea))
110
118
  ds.add_argument("idea", type=Path, help="the idea, a .md or .txt file")
@@ -124,7 +132,7 @@ def build_parser() -> argparse.ArgumentParser:
124
132
 
125
133
 
126
134
  JUDGES = {"manifests", "boundaries", "plan", "skills", "hygiene", "fitness", "doctor", "pr-scope",
127
- "pr-check", "compat"} # the commands whose verdict depends on the framework's rules
135
+ "pr-check", "compat", "landed"} # the commands whose verdict depends on the framework's rules
128
136
 
129
137
 
130
138
  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.")
@@ -16,6 +16,8 @@ Rules:
16
16
  B5 no direct access to another module's data (tables declared elsewhere)
17
17
  B6 a consumed contract is provided: its contract, version and module match a provides entry
18
18
  B7 a provided contract exists: its path holds its document, inside a module's folder
19
+ B8 a contract consumed from a deprecated module (warning): a deprecated module takes no
20
+ new consumer, and the existing ones migrate before its removal date
19
21
 
20
22
  DETECTION — textual and deliberately simple, with no stack assumed.
21
23
  - A contract is read where a module's file names its path (contracts/billing-api/v1),
@@ -87,10 +89,13 @@ def load_modules(root: Path) -> dict[str, dict]:
87
89
  owns = section.get("owns") if isinstance(section.get("owns"), list) else []
88
90
  name = mod.get("name") if isinstance(mod.get("name"), str) and mod["name"] else manifest.parent.name
89
91
  code_name = mod.get("code_name")
92
+ deprecation = mod.get("deprecation") if isinstance(mod.get("deprecation"), dict) else {}
90
93
  modules[name] = {
91
94
  "path": manifest.parent,
92
95
  "dirname": manifest.parent.name,
93
96
  "code_name": code_name if isinstance(code_name, str) and code_name else name,
97
+ "lifecycle": mod.get("lifecycle") if isinstance(mod.get("lifecycle"), str) else None,
98
+ "removal": deprecation.get("removal_date"),
94
99
  "provides": contract_entries(data, "provides"),
95
100
  "consumes": contract_entries(data, "consumes"),
96
101
  "owns_data": {table for table in owns if isinstance(table, str)},
@@ -204,6 +209,15 @@ def check_contracts(root: Path, modules: dict[str, dict]) -> dict[str, dict[str,
204
209
  elif c.get("module") not in (None, producer):
205
210
  fail("B6", name, f"consumes {key[0]} {key[1]} from '{c.get('module')}', which is "
206
211
  f"provided by '{producer}'.\n Action: name the producer.")
212
+ # B8 - a deprecated producer. Reported, not refused: what is already declared is
213
+ # bounded by the removal date, which M5 turns red once it has passed.
214
+ elif modules[producer]["lifecycle"] == "deprecated":
215
+ when = modules[producer]["removal"]
216
+ warn("B8", name, f"consumes {key[0]} {key[1]} from '{producer}', which is deprecated"
217
+ + (f" (removal {when})" if when else "")
218
+ + ": a deprecated module takes no new consumer.\n Action: "
219
+ "migrate to the module that replaces it before that date "
220
+ "(docs/os/02-modules.md §6); M5 turns red once it has passed.")
207
221
 
208
222
  patterns = contract_patterns(modules)
209
223
  reads: dict[str, dict[str, set[str]]] = {name: {} for name in modules}
@@ -25,6 +25,7 @@ Output: 0 if everything passes, 1 otherwise. Every failure explains the rule br
25
25
  from __future__ import annotations
26
26
  import sys
27
27
  import datetime
28
+ import re
28
29
  import subprocess
29
30
  from pathlib import Path
30
31
 
@@ -86,6 +87,24 @@ def contract_entries(data: dict, section: str) -> list[dict]:
86
87
  and all(isinstance(entry.get(field), (str, type(None))) for field in CONTRACT_FIELDS[section])]
87
88
 
88
89
 
90
+ UNFILLED = re.compile(r"<[^<>\s][^<>]*>") # the angle-bracket placeholder of a template
91
+ TODO = re.compile(r"^TODO\b", re.I)
92
+
93
+
94
+ def unfilled(value) -> bool:
95
+ """Whether a declaration still holds the template's words: empty, carrying a placeholder
96
+ such as <github-handle>, or opening with TODO (D51).
97
+
98
+ A *declaration* is unfilled as soon as it carries a placeholder — nobody writes a success
99
+ criterion around one. A *cell* of a test sheet is unfilled only when it is nothing but a
100
+ placeholder, which is the template's example row: that one is `pull_request.PLACEHOLDER`,
101
+ and the two meanings are deliberately different. A placeholder opens on a non-space, so
102
+ "stays < 200 ms" is a comparison, not a placeholder.
103
+ """
104
+ text = str(value or "").strip()
105
+ return not text or bool(UNFILLED.search(text)) or bool(TODO.match(text))
106
+
107
+
89
108
  def is_description(path: str) -> bool:
90
109
  """A module's description — its manifest, AGENTS.md, README.md, docs/ — or an empty
91
110
  placeholder, `path` relative to the module's folder. Changing it changes no behaviour
@@ -208,9 +227,18 @@ def check_manifest(path: Path, today: datetime.date) -> None:
208
227
  fail(rel, "M6", f"contract {name}: removal date passed ({removal}). "
209
228
  "Finish the contraction (docs/os/03-contracts.md §4).")
210
229
 
211
- # M7 - standard verbs, once there is something to check (D33)
212
230
  commands = data.get("commands") or {}
213
231
  content = module_content(path.parent)
232
+
233
+ # M2 - the responsibility still the template's, once the module holds more than its
234
+ # description: a module is green as created (D33) and says what it does from its first
235
+ # file of code.
236
+ if content and unfilled(resp):
237
+ fail(rel, "M2", f'module.responsibility is still the template\'s: "{resp}"\n'
238
+ " Action: one sentence naming the capability this module covers, "
239
+ "in MANIFEST.yaml (docs/os/02-modules.md §5).")
240
+
241
+ # M7 - standard verbs, once there is something to check (D33)
214
242
  for verb in REQUIRED_COMMANDS:
215
243
  if content and not commands.get(verb):
216
244
  fail(rel, "M7", f"standard verb missing: commands.{verb}, and the module holds more "
@@ -31,6 +31,7 @@ from pathlib import Path
31
31
 
32
32
  import yaml
33
33
 
34
+ from napkinstack.fitness.manifests import unfilled
34
35
  from napkinstack.modules import HANDLE
35
36
 
36
37
  PROJECT = Path("docs") / "project"
@@ -114,8 +115,9 @@ def _check_charter(path: Path, fail) -> bool:
114
115
  fail("C2", path, f"status '{data.get('status')}': expected one of {sorted(CHARTER_STATUSES)}")
115
116
  check_decider("C2", path, data.get("decider"), fail)
116
117
  criteria = data.get("success_criteria")
117
- if not isinstance(criteria, list) or not [c for c in criteria if str(c or "").strip()]:
118
- fail("C2", path, "success_criteria: at least one, they say when the project itself ends")
118
+ if not isinstance(criteria, list) or not [c for c in criteria if not unfilled(c)]:
119
+ fail("C2", path, "success_criteria: at least one, filled in — they say when the project "
120
+ "itself ends. The template's example is not one")
119
121
  return data.get("status") == "accepted"
120
122
 
121
123
 
@@ -135,16 +137,17 @@ def _check_deliverables(path: Path, deliverables, fail) -> None:
135
137
  elif ident in seen:
136
138
  fail("C4", path, f"deliverable {ident}: id used twice")
137
139
  seen.add(ident)
138
- if not str(item.get("title") or "").strip():
139
- fail("C4", path, f"deliverable {where}: title missing")
140
+ if unfilled(item.get("title")):
141
+ fail("C4", path, f"deliverable {where}: title missing, or still the template's")
140
142
  state = item.get("state")
141
143
  if state not in DELIVERABLE_STATES:
142
144
  fail("C4", path, f"deliverable {where}: state '{state}', expected one of {sorted(DELIVERABLE_STATES)}")
143
145
  criteria = item.get("acceptance")
144
- filled = isinstance(criteria, list) and [c for c in criteria if str(c or "").strip()]
146
+ filled = isinstance(criteria, list) and [c for c in criteria if not unfilled(c)]
145
147
  if state in DELIVERABLE_STATES - WITHOUT_CRITERIA and not filled:
146
- fail("C4", path, f"deliverable {where}: state '{state}' requires acceptance criteria — "
147
- "the definition of ready, and the source of its test sheet (PDR-0003)")
148
+ fail("C4", path, f"deliverable {where}: state '{state}' requires acceptance criteria "
149
+ "filled in — the definition of ready, and the source of its test "
150
+ "sheet (PDR-0003)")
148
151
 
149
152
 
150
153
  def _check_cycle(path: Path, fail) -> str | None:
@@ -188,7 +191,7 @@ def _check_discovery(path: Path, fail) -> str | None:
188
191
  check_decider("C7", path, data.get("decider"), fail)
189
192
  if as_date(data.get("decided_on")) is None:
190
193
  fail("C7", path, f"a decision ({decision}) records decided_on, YYYY-MM-DD")
191
- if decision == "go" and not str(data.get("challenger") or "").strip():
194
+ if decision == "go" and unfilled(data.get("challenger")):
192
195
  fail("C7", path, "a go names its challenger: another session, or a human, challenged the "
193
196
  "document first (playbooks/discovery.md, stage 5)")
194
197
  return decision if isinstance(decision, str) else None
@@ -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")
@@ -17,7 +17,8 @@ from pathlib import Path
17
17
 
18
18
  import yaml
19
19
 
20
- from napkinstack.fitness.manifests import changed_files, find_manifests, module_content
20
+ from napkinstack.fitness.manifests import (changed_files, contract_entries, find_manifests,
21
+ module_content)
21
22
 
22
23
  TEMPLATE = Path(__file__).resolve().parent / "templates" / "module"
23
24
  NAME = re.compile(r"[a-z][a-z0-9-]*")
@@ -118,10 +119,15 @@ def next_steps(root: Path, name: str, criticality: str, user_facing: bool) -> li
118
119
  why = "user-facing" if user_facing else f"criticality {criticality}"
119
120
  steps.append(f"Every pull request that changes its behaviour carries a test sheet ({why}), "
120
121
  "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), "
122
+ framed = plan.charter_accepted(root)
123
+ cycle = plan.accepted_cycle(root) if framed else None
124
+ if not framed:
125
+ steps.append("The project is not framed: delivery work needs an accepted charter (K1), "
124
126
  "or the out-of-cycle label with its justification (K4)")
127
+ elif cycle is None:
128
+ # The charter is accepted: only the next cycle is missing (D47).
129
+ steps.append("No accepted cycle: delivery work needs one (K1), or the out-of-cycle label "
130
+ "with its justification (K4)")
125
131
  else:
126
132
  steps.append(f"Delivery work names a ready deliverable of {cycle[0].name} (K3), "
127
133
  "or carries the out-of-cycle label with its justification (K4)")
@@ -129,9 +135,40 @@ def next_steps(root: Path, name: str, criticality: str, user_facing: bool) -> li
129
135
  return steps
130
136
 
131
137
 
132
- def listing(root: Path, base: str | None = None) -> list[dict[str, str]] | None:
133
- """Every module — a folder holding a MANIFEST.yaml, contracts/ and platform/ included —
134
- or, given a base, those with a file changed since it; None when the base is unknown."""
138
+ def _load(manifest: Path) -> dict:
139
+ try:
140
+ data = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {}
141
+ except yaml.YAMLError:
142
+ return {} # reported by nstack manifests (M2)
143
+ return data if isinstance(data, dict) else {}
144
+
145
+
146
+ def _contract_sides(root: Path, changed: list[str]) -> set[str]:
147
+ """The folders of the modules on either side of a contract version the change touches: the
148
+ module that provides it, and those that declare it in consumes. The handbook asks for the
149
+ checks of both sides (docs/os/03-contracts.md §5); the declared graph says who they are, so
150
+ one repository needs no broker to know (D53)."""
151
+ provided: dict[tuple[str, str], tuple[str, str]] = {}
152
+ declared: list[tuple[str, list[dict]]] = []
153
+ for manifest in find_manifests(root):
154
+ data = _load(manifest)
155
+ folder = manifest.parent.relative_to(root).as_posix()
156
+ for entry in contract_entries(data, "provides"):
157
+ key, path = (entry.get("contract"), entry.get("version")), entry.get("path")
158
+ if isinstance(path, str) and all(isinstance(part, str) for part in key):
159
+ provided[key] = (path.strip("/"), folder)
160
+ declared.append((folder, contract_entries(data, "consumes")))
161
+ touched = {key: folder for key, (path, folder) in provided.items()
162
+ if any(file == path or file.startswith(f"{path}/") for file in changed)}
163
+ return set(touched.values()) | {folder for folder, consumes in declared for entry in consumes
164
+ if (entry.get("contract"), entry.get("version")) in touched}
165
+
166
+
167
+ def listing(root: Path, base: str | None = None,
168
+ with_contract_sides: bool = False) -> list[dict[str, str]] | None:
169
+ """Every module — a folder holding a MANIFEST.yaml, contracts/ and platform/ included — or,
170
+ given a base, those with a file changed since it, and with `with_contract_sides` both sides
171
+ of a contract version the change touches (D53); None when the base is unknown."""
135
172
  found = [{"name": manifest.parent.name, "folder": manifest.parent.relative_to(root).as_posix()}
136
173
  for manifest in find_manifests(root)]
137
174
  if base is None:
@@ -140,7 +177,11 @@ def listing(root: Path, base: str | None = None) -> list[dict[str, str]] | None:
140
177
  capture_output=True).returncode:
141
178
  return None
142
179
  changed = changed_files(root, base)
143
- return [module for module in found if any(path.startswith(f"{module['folder']}/") for path in changed)]
180
+ folders = {module["folder"] for module in found
181
+ if any(path.startswith(f"{module['folder']}/") for path in changed)}
182
+ if with_contract_sides:
183
+ folders |= _contract_sides(root, changed)
184
+ return [module for module in found if module["folder"] in folders]
144
185
 
145
186
 
146
187
  def _modules(root: Path) -> dict[str, Path]:
@@ -55,8 +55,12 @@ def _explain(exc: Exception, command: str, where: Path, source: str, ref: str) -
55
55
  return (f"FAIL [{command}] Working tree modified in {where}: an update starts from a "
56
56
  f"committed state (PDR-0001).\n{action}commit or stash (git stash), then run again.")
57
57
  if match := DOWNGRADE.search(text):
58
+ # The action is a command, not a description of one: an install pinned to an exact
59
+ # version is not moved by `uv tool upgrade`, which is what a reader tries first.
58
60
  return (f"FAIL [{command}] Target version {match[2]} older than the project version ({match[1]}): "
59
- f"no going back (PDR-0001).\n{action}use nstack {match[1]} or newer.")
61
+ f"no going back (PDR-0001).\n{action}move this workstation forward first — "
62
+ 'uv tool install "napkinstack@latest" --with-executables-from pre-commit — '
63
+ f"or pin the version you want, {match[1]} or newer.")
60
64
  if text.startswith("Updating is only supported in git-tracked subprojects"):
61
65
  return (f"FAIL [{command}] {where} is not a git repository: the merge relies on "
62
66
  f"history.\n{action}git init, commit, then run again.")
@@ -64,9 +68,17 @@ def _explain(exc: Exception, command: str, where: Path, source: str, ref: str) -
64
68
  return (f"FAIL [{command}] The project does not come from a published version (_commit in {ANSWERS}): "
65
69
  f"no merge base.\n{action}create the project from a vX.Y.Z tag.")
66
70
  if isinstance(exc, OSError) or text == "Local template must be a directory.":
67
- detail = [line.split("|", 1)[-1].strip() for line in text.splitlines() if line.strip()][-1]
71
+ lines = [line.split("|", 1)[-1].strip() for line in text.splitlines() if line.strip()]
72
+ # git writes the cause on an `error:` line and the outcome on a `fatal:` one; keeping
73
+ # the last line alone drops the only one that says what happened (D54).
74
+ detail = " — ".join(line for line in lines if line.startswith(("error:", "fatal:"))) or lines[-1]
75
+ # A git `error:` line means git refused something local, so the action is local too:
76
+ # Copier copies the template's uncommitted state into its clone before reading it.
77
+ local = ("git refused a file of the template's working tree, which Copier copies as it "
78
+ "is: commit or stash it, or remove the file git names above.")
79
+ remote = "check --source and --ref (a vX.Y.Z tag), and network access."
68
80
  return (f"FAIL [{command}] Template {source} at version {ref} unreachable: {detail}\n"
69
- f"{action}check --source and --ref (a vX.Y.Z tag), and network access.")
81
+ + action + (local if detail.startswith("error:") else remote))
70
82
  return f"FAIL [{command}] Copier: {text}"
71
83
 
72
84
 
@@ -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:
@@ -71,12 +71,6 @@ commands:
71
71
  # run: # optional: local start, with doubles for the dependencies
72
72
  # e2e: # optional: end-to-end scenarios; evidence written to .evidence/
73
73
 
74
- # Review budget. Inherited from the project when absent (docs/os/05-workflow.md §4).
75
- review_budget:
76
- max_lines: 400
77
- max_files: 15
78
- max_modules: 1 # not adjustable
79
-
80
74
  docs:
81
75
  readme: README.md
82
76
  agents: AGENTS.md
File without changes