napkinstack 0.2.0__tar.gz → 0.3.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 (24) hide show
  1. {napkinstack-0.2.0 → napkinstack-0.3.0}/PKG-INFO +11 -6
  2. {napkinstack-0.2.0 → napkinstack-0.3.0}/README.md +10 -5
  3. {napkinstack-0.2.0 → napkinstack-0.3.0}/pyproject.toml +1 -1
  4. {napkinstack-0.2.0 → napkinstack-0.3.0}/pyproject.toml.orig +1 -1
  5. {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/cli.py +21 -9
  6. napkinstack-0.3.0/src/napkinstack/discovery.py +53 -0
  7. {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/doctor.py +41 -7
  8. {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/fitness/manifests.py +6 -0
  9. napkinstack-0.3.0/src/napkinstack/fitness/plan.py +221 -0
  10. {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/modules.py +15 -8
  11. napkinstack-0.3.0/src/napkinstack/pull_request.py +242 -0
  12. {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/templates/module/MANIFEST.yaml +6 -1
  13. {napkinstack-0.2.0 → napkinstack-0.3.0}/LICENSE +0 -0
  14. {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/__init__.py +0 -0
  15. {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/fitness/__init__.py +0 -0
  16. {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/fitness/boundaries.py +0 -0
  17. {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/fitness/pr_scope.sh +0 -0
  18. {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/project.py +0 -0
  19. {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/skills.py +0 -0
  20. {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
  21. {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/templates/module/README.md +0 -0
  22. {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/templates/module/docs/adr/.gitkeep +0 -0
  23. {napkinstack-0.2.0 → napkinstack-0.3.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  24. {napkinstack-0.2.0 → napkinstack-0.3.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.2.0
3
+ Version: 0.3.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,9 +20,10 @@ 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.1.0, the first published version** ([PyPI](https://pypi.org/project/napkinstack/)).
24
- > A first pilot project, private, puts it to the test before the rest. Tracking:
25
- > [roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
23
+ > **Status: v0.2.0, published** ([PyPI](https://pypi.org/project/napkinstack/)), English
24
+ > throughout. A first pilot project, private, starts from it and puts it to the test before
25
+ > the rest. Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
26
+ > [move to English](docs/governance/plans/2026-09-16-english-migration.md),
26
27
  > [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
27
28
 
28
29
  ## A project's journey
@@ -84,10 +85,14 @@ the commit of this repository
84
85
  |---|---|
85
86
  | `nstack init <folder>` | Creates the project: skeleton, git repository, initial commit, GitHub checklist |
86
87
  | `nstack doctor` | Checks the workstation and the GitHub settings, read-only |
87
- | `nstack new-module <name> <organisation>/<team> <criticality>` | Creates a module, with no imposed stack |
88
+ | `nstack new-module <name> <owner> <criticality>` | Creates a module, with no imposed stack |
88
89
  | `nstack check`, `test`, `bootstrap` `[module]`; `nstack run <module>` | Run the commands declared in the module's manifest |
89
- | `nstack fitness` | Manifests, boundaries between modules, skills |
90
+ | `nstack discover <idea-file>` | Starts a discovery for your agent: the idea kept, its document created |
91
+ | `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures |
92
+ | `nstack fitness` | Manifests, boundaries between modules, skills, plan |
90
93
  | `nstack pr-scope` | One PR = one module, review budget |
94
+ | `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
95
+ | `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
91
96
  | `nstack skills` | Exposes the playbooks as skills for the agent |
92
97
  | `nstack update` | Lays the new version on a branch to review |
93
98
 
@@ -5,9 +5,10 @@ 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.1.0, the first published version** ([PyPI](https://pypi.org/project/napkinstack/)).
9
- > A first pilot project, private, puts it to the test before the rest. Tracking:
10
- > [roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
8
+ > **Status: v0.2.0, published** ([PyPI](https://pypi.org/project/napkinstack/)), English
9
+ > throughout. A first pilot project, private, starts from it and puts it to the test before
10
+ > the rest. Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
11
+ > [move to English](docs/governance/plans/2026-09-16-english-migration.md),
11
12
  > [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
12
13
 
13
14
  ## A project's journey
@@ -69,10 +70,14 @@ the commit of this repository
69
70
  |---|---|
70
71
  | `nstack init <folder>` | Creates the project: skeleton, git repository, initial commit, GitHub checklist |
71
72
  | `nstack doctor` | Checks the workstation and the GitHub settings, read-only |
72
- | `nstack new-module <name> <organisation>/<team> <criticality>` | Creates a module, with no imposed stack |
73
+ | `nstack new-module <name> <owner> <criticality>` | Creates a module, with no imposed stack |
73
74
  | `nstack check`, `test`, `bootstrap` `[module]`; `nstack run <module>` | Run the commands declared in the module's manifest |
74
- | `nstack fitness` | Manifests, boundaries between modules, skills |
75
+ | `nstack discover <idea-file>` | Starts a discovery for your agent: the idea kept, its document created |
76
+ | `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures |
77
+ | `nstack fitness` | Manifests, boundaries between modules, skills, plan |
75
78
  | `nstack pr-scope` | One PR = one module, review budget |
79
+ | `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
80
+ | `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
76
81
  | `nstack skills` | Exposes the playbooks as skills for the agent |
77
82
  | `nstack update` | Lays the new version on a branch to review |
78
83
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.2.0"
3
+ version = "0.3.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.2.0"
3
+ version = "0.3.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"
@@ -7,8 +7,8 @@ import os
7
7
  import subprocess
8
8
  from pathlib import Path
9
9
 
10
- from napkinstack import __version__, doctor, modules, skills
11
- from napkinstack.fitness import boundaries, manifests
10
+ from napkinstack import __version__, discovery, doctor, modules, pull_request, skills
11
+ from napkinstack.fitness import boundaries, manifests, plan
12
12
 
13
13
  PACKAGE = Path(__file__).resolve().parent
14
14
 
@@ -26,7 +26,7 @@ def _script(relative: str, *args: str, root: Path) -> int:
26
26
 
27
27
 
28
28
  def _fitness(root: Path) -> int:
29
- results = [manifests.run(root), boundaries.run(root), skills.run(root, check_only=True)]
29
+ results = [manifests.run(root), boundaries.run(root), skills.run(root, check_only=True), plan.run(root)]
30
30
  return 1 if any(results) else 0
31
31
 
32
32
 
@@ -61,21 +61,26 @@ def build_parser() -> argparse.ArgumentParser:
61
61
  lambda a: manifests.run(a.root))
62
62
  _add(sub, "boundaries", "declared graph against real graph (B1-B5)",
63
63
  lambda a: boundaries.run(a.root))
64
+ _add(sub, "plan", "the discovery, the charter and the cycles (C1-C7)", lambda a: plan.run(a.root))
64
65
  sk = _add(sub, "skills", "generates or checks the skills (S1-S4)",
65
66
  lambda a: skills.run(a.root, check_only=a.check))
66
67
  sk.add_argument("--check", action="store_true", help="check without writing")
67
- _add(sub, "fitness", "manifests + boundaries + skills",
68
+ _add(sub, "fitness", "manifests + boundaries + skills + plan",
68
69
  lambda a: _fitness(a.root))
69
70
  _add(sub, "doctor", "diagnoses the workstation and the GitHub settings, read-only (PDR-0001)",
70
71
  lambda a: doctor.run(a.root))
71
72
  nm = _add(sub, "new-module", "creates a module and its guardrails, with no imposed stack",
72
- lambda a: modules.create(a.root, a.name, a.owner, a.criticality))
73
+ lambda a: modules.create(a.root, a.name, a.owner, a.criticality, a.user_facing))
73
74
  nm.add_argument("name", help="module name, kebab-case")
74
- nm.add_argument("owner", help="GitHub team, organisation/team")
75
+ nm.add_argument("owner", help="GitHub team, organisation/team, or a user when the project has "
76
+ "no organisation")
75
77
  nm.add_argument("criticality", choices=["prototype", "standard", "high", "critical"])
78
+ nm.add_argument("--user-facing", action="store_true",
79
+ help="a user sees this module: its pull requests carry a test sheet")
76
80
  for verb, help_text in (("bootstrap", "prepares one module, or all of them (commands.bootstrap)"),
77
81
  ("check", "format, lint, types of one module, or all (commands.check)"),
78
- ("test", "tests of one module, or of all of them (commands.test)")):
82
+ ("test", "tests of one module, or of all of them (commands.test)"),
83
+ ("e2e", "end-to-end scenarios of one module, or of all (commands.e2e)")):
79
84
  vb = _add(sub, verb, help_text, lambda a, v=verb: modules.run_verb(a.root, v, a.module))
80
85
  vb.add_argument("module", nargs="?", help="module name (default: all)")
81
86
  rn = _add(sub, "run", "starts a module locally (commands.run)",
@@ -84,12 +89,19 @@ def build_parser() -> argparse.ArgumentParser:
84
89
  ps = _add(sub, "pr-scope", "one PR = one module, review budget (P1-P2)",
85
90
  lambda a: _script("fitness/pr_scope.sh", a.base, root=a.root))
86
91
  ps.add_argument("--base", default="origin/main")
92
+ pc = _add(sub, "pr-check", "test sheet and cycle, read from the pull request description (T1-T5, K1-K4)",
93
+ lambda a: pull_request.run(a.root, a.base, a.body_file))
94
+ pc.add_argument("--base", default="origin/main")
95
+ pc.add_argument("--body-file", type=Path, help="the description, when PR_BODY is not set")
96
+ ds = _add(sub, "discover", "starts a discovery from an idea file, for the team's agent (PDR-0002)",
97
+ lambda a: discovery.run(a.root, a.idea))
98
+ ds.add_argument("idea", type=Path, help="the idea, a .md or .txt file")
87
99
  ini = sub.add_parser("init", help="creates a project from the skeleton (PDR-0001)")
88
100
  ini.add_argument("destination", type=Path, help="project folder, missing or empty")
89
101
  ini.add_argument("--project-name", help="project name (asked when absent)")
90
102
  ini.add_argument("--github-repo", help="GitHub repository, organisation/name (asked when absent)")
91
- ini.add_argument("--owner-team", help="GitHub team owning the foundation, organisation/team "
92
- "(asked when absent)")
103
+ ini.add_argument("--owner-team", help="owner of the foundation: organisation/team, or a user "
104
+ "when the project has no organisation (asked when absent)")
93
105
  ini.add_argument("--source", help="template: URL or path (default: the NapkinStack repository)")
94
106
  ini.add_argument("--ref", help="skeleton version, tag vX.Y.Z (default: the one of nstack)")
95
107
  ini.set_defaults(func=_init)
@@ -0,0 +1,53 @@
1
+ """
2
+ nstack discover — starts a project's discovery from an idea file (PDR-0002, extension).
3
+
4
+ Deterministic, no model called: the idea is kept in docs/project/inputs/, the discovery
5
+ document is created from its template, and the instruction for the team's agent is
6
+ printed. The conversation itself happens in the agent (playbooks/discovery.md).
7
+
8
+ Usage : nstack discover <idea-file> [--root ROOT]
9
+ Output: 0 when the discovery is started, 1 otherwise.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import shutil
15
+ from pathlib import Path
16
+
17
+ from napkinstack.fitness.plan import DISCOVERY, PROJECT
18
+
19
+ INPUTS = PROJECT / "inputs"
20
+ TEMPLATE = PROJECT / "_DISCOVERY_TEMPLATE.md"
21
+ SUFFIXES = {".md", ".txt"}
22
+
23
+
24
+ def run(root: Path, idea: Path) -> int:
25
+ if not (root / TEMPLATE).is_file():
26
+ print(f"FAIL [discover] {TEMPLATE} not found in {root}: not a project created by nstack init, "
27
+ "or one older than the discovery.\n Action: run it at the project root, or update "
28
+ "the project (nstack update).")
29
+ return 1
30
+ if not idea.is_file() or idea.suffix.lower() not in SUFFIXES:
31
+ print(f"FAIL [discover] {idea}: a Markdown or text file expected.\n Action: write the idea "
32
+ "in a .md or .txt file — a few lines are enough.")
33
+ return 1
34
+ if (root / DISCOVERY).exists():
35
+ print(f"FAIL [discover] {DISCOVERY} already exists: one discovery per project.\n Action: "
36
+ "continue it in your agent (playbooks/discovery.md), as a new round of the same document.")
37
+ return 1
38
+ kept = root / INPUTS / idea.name
39
+ if kept.exists() and kept.resolve() != idea.resolve():
40
+ print(f"FAIL [discover] {INPUTS / idea.name} already exists.\n Action: rename the idea file.")
41
+ return 1
42
+ kept.parent.mkdir(parents=True, exist_ok=True)
43
+ if kept.resolve() != idea.resolve():
44
+ shutil.copyfile(idea, kept)
45
+ source = (INPUTS / idea.name).as_posix()
46
+ template = (root / TEMPLATE).read_text(encoding="utf-8")
47
+ (root / DISCOVERY).write_text(template.replace("<idea file>", source), encoding="utf-8")
48
+ print(f"Discovery started: {source} kept, {DISCOVERY} created.")
49
+ print("\nNext, in your agent:")
50
+ print(f" Follow playbooks/discovery.md on {source}: interview me, one question at a time.")
51
+ print("Then, in another session, have the document challenged (stage 5); the decider decides: "
52
+ "go, clarify or kill.")
53
+ return 0
@@ -15,8 +15,9 @@ Rules:
15
15
  L3 pre-commit hooks installed
16
16
  L4 PRODUCT.md absent: that is NapkinStack's own development context (R6)
17
17
  L5 README personalised: the presentation sentence is written
18
- G1-G11 the GitHub settings of CHECKLIST; G6 is not applicable outside a public
19
- repository, and on a private one G1-G5 name the GitHub plan or option required
18
+ L6 CODEOWNERS starts with a default owner: the code owner review covers every path
19
+ G1-G12 the GitHub settings of CHECKLIST; G6 is not applicable outside a public
20
+ repository, and on a private one G1-G5 and G12 name the GitHub plan or option required
20
21
 
21
22
  Usage : nstack doctor [--root ROOT]
22
23
  Output: 0 when everything is verified and compliant, 1 otherwise.
@@ -43,14 +44,15 @@ from napkinstack.project import ANSWERS
43
44
  OK, GAP, UNKNOWN, NOT_APPLICABLE = "OK", "FAIL", "NOT VERIFIED", "NOT APPLICABLE"
44
45
  API_VERSION = "2026-03-10"
45
46
  PLACEHOLDER = "<One sentence: what this project does.>"
46
- JOBS = ("Fitness functions", "PR scope and review budget", "Hooks and secrets")
47
+ JOBS = ("Fitness functions", "PR scope and review budget", "Hooks and secrets", "Test sheet and cycle")
47
48
  THIRD_PARTY_ACTIONS = ("astral-sh/setup-uv",) # non-GitHub actions of the skeleton workflows
48
- LABELS = ("cross-module", "over-budget")
49
+ LABELS = ("cross-module", "over-budget", "out-of-cycle")
49
50
  PUBLISHED = re.compile(r"v\d+(\.\d+)*((a|b|rc)\d+)?(\.post\d+)?(\.dev\d+)?")
50
51
 
51
52
  RULESET = "Settings → Rules → Rulesets, main branch"
52
53
  SECURITY = "Settings → Advanced Security"
53
54
  ACTIONS = "Settings → Actions → General"
55
+ CODEOWNERS = Path(".github") / "CODEOWNERS"
54
56
 
55
57
  CHECKLIST = [ # (rule, setting, action)
56
58
  ("G1", "Pull request required: no direct push to main",
@@ -70,15 +72,17 @@ CHECKLIST = [ # (rule, setting, action)
70
72
  f"{ACTIONS}: require approval for all external contributors"),
71
73
  ("G10", "Workflow token read-only; Actions neither creates nor approves pull requests",
72
74
  f"{ACTIONS}: workflow permissions read-only, with no pull request creation or approval"),
73
- ("G11", "Labels " + " and ".join(f"`{label}`" for label in LABELS),
74
- "Issues → Labels: create " + " and ".join(LABELS)),
75
+ ("G11", "Labels " + ", ".join(f"`{label}`" for label in LABELS[:-1]) + f" and `{LABELS[-1]}`",
76
+ "Issues → Labels: create " + ", ".join(LABELS[:-1]) + f" and {LABELS[-1]}"),
77
+ ("G12", "Bypass list empty: nobody merges around the rules, administrators included",
78
+ f"{RULESET}: remove every bypass actor"),
75
79
  ]
76
80
 
77
81
  # Settings specific to public repositories, and settings a private one pays for
78
82
  # (GitHub documentation, 2026-09-15).
79
83
  PUBLIC_ONLY = {"G6": "private vulnerability reporting only exists for a public repository; "
80
84
  "state an internal channel in SECURITY.md"}
81
- PRIVATE_PLAN = dict.fromkeys(("G1", "G2", "G3", "G4"),
85
+ PRIVATE_PLAN = dict.fromkeys(("G1", "G2", "G3", "G4", "G12"),
82
86
  "Private repository: rulesets require the GitHub Team plan (organisation) "
83
87
  "or Pro (personal account); without it, nothing blocks the merge.")
84
88
  PRIVATE_PLAN["G5"] = ("Private repository: Secret Protection is a paid option; without it, only the "
@@ -155,6 +159,20 @@ def _workflows(client: GitHub) -> bool:
155
159
  and permissions.get("can_approve_pull_request_reviews") is False)
156
160
 
157
161
 
162
+ def _no_bypass(client: GitHub) -> bool:
163
+ """G12: every ruleset applying to main has an empty bypass list (ADR-0004)."""
164
+ rulesets = {rule["ruleset_id"] for rule in client.get("/rules/branches/main") if rule.get("ruleset_id")}
165
+ if not rulesets:
166
+ return False
167
+ for ruleset in sorted(rulesets):
168
+ actors = client.get(f"/rulesets/{ruleset}?includes_parents=true").get("bypass_actors")
169
+ if actors is None:
170
+ raise NotVerified("bypass list not visible: the token lacks the Administration: read permission")
171
+ if actors:
172
+ return False
173
+ return True
174
+
175
+
158
176
  CHECKS: dict[str, Callable[[GitHub], bool]] = {
159
177
  "G1": lambda c: _rule(c, "pull_request") is not None,
160
178
  "G2": lambda c: _parameters(c, "pull_request").get("required_approving_review_count", 0) >= 1,
@@ -169,9 +187,23 @@ CHECKS: dict[str, Callable[[GitHub], bool]] = {
169
187
  "approval_policy") == "all_external_contributors",
170
188
  "G10": _workflows,
171
189
  "G11": lambda c: all(c.get(f"/labels/{label}", missing=True) is not None for label in LABELS),
190
+ "G12": _no_bypass,
172
191
  }
173
192
 
174
193
 
194
+ def _default_owner(root: Path) -> tuple[str, str]:
195
+ """L6: `*` first, so that the code owner review covers every path (ADR-0004)."""
196
+ path = root / CODEOWNERS
197
+ if not path.is_file():
198
+ return GAP, f"{CODEOWNERS} missing.\nAction: create it, starting with `* @<owner>`."
199
+ rules = [line.split() for line in path.read_text(encoding="utf-8").splitlines()
200
+ if line.strip() and not line.lstrip().startswith("#")]
201
+ if rules and rules[0][0] == "*" and len(rules[0]) > 1:
202
+ return OK, ""
203
+ return GAP, (f"The first rule of {CODEOWNERS} is not a default owner.\nAction: make `* @<owner>` "
204
+ "its first rule: the code owner review then covers every path (ADR-0004).")
205
+
206
+
175
207
  def _workstation(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
176
208
  commit = str(answers.get("_commit") or "")
177
209
  project = commit.removeprefix("v")
@@ -212,6 +244,8 @@ def _workstation(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
212
244
  results.append(("L5", "README personalised", GAP if untouched else OK,
213
245
  f'README.md still contains "{PLACEHOLDER}".\n'
214
246
  "Action: write the sentence that presents the project." if untouched else ""))
247
+
248
+ results.append(("L6", "CODEOWNERS starts with a default owner", *_default_owner(root)))
215
249
  return results
216
250
 
217
251
 
@@ -15,6 +15,7 @@ Rules:
15
15
  M7 standard verbs declared (check / test at least)
16
16
  M8 runbook required when criticality >= high
17
17
  M9 complete file envelope (AGENTS.md, README.md, tests/)
18
+ M10 user_facing declared: true when a user sees the module (docs/os/05-workflow.md §7)
18
19
 
19
20
  Usage : nstack manifests [--root ROOT]
20
21
  Output: 0 if everything passes, 1 otherwise. Every failure explains the rule broken.
@@ -164,6 +165,11 @@ def check_manifest(path: Path, today: datetime.date) -> None:
164
165
  if not (path.parent / "tests").is_dir() and criticality != "prototype":
165
166
  fail(rel, "M9", "tests/ folder missing")
166
167
 
168
+ # M10 - a user-visible surface declared: it decides the test sheet (docs/os/05-workflow.md §7)
169
+ if not isinstance(mod.get("user_facing"), bool):
170
+ fail(rel, "M10", "module.user_facing must be true or false: does a user see this module? "
171
+ "true requires a test sheet on its pull requests (docs/os/05-workflow.md §7)")
172
+
167
173
 
168
174
  def run(root: Path) -> int:
169
175
  failures.clear()
@@ -0,0 +1,221 @@
1
+ """
2
+ Fitness function 4 — The project's charter and cycles (PDR-0002).
3
+
4
+ A project is framed by a charter and bounded by cycles, written in docs/project/. Their
5
+ front matter, a YAML block between two --- lines at the top of the file, is what the
6
+ checks read; the prose below it is for humans and agents.
7
+
8
+ Rules:
9
+ C1 front matter readable: a YAML mapping at the top of the file
10
+ C2 charter: status proposed or accepted, a decider, at least one success criterion
11
+ C3 cycle: a goal, a known status, appetite_weeks, start and end, end = start + appetite
12
+ C4 deliverables: ids D1, D2… unique, a title, a known state, acceptance criteria once ready
13
+ C5 at most one accepted cycle, and only under an accepted charter
14
+ C6 a closed or stopped cycle records its outcome and the date it ended
15
+ C7 discovery: a known decision, its decider and date once decided, a challenger for a go;
16
+ a charter follows a go
17
+
18
+ Templates, whose file name starts with "_", are not checked.
19
+
20
+ Usage : nstack plan [--root ROOT]
21
+ Output: 0 when the charter and the cycles are valid, or absent; 1 otherwise.
22
+ A root without docs/project/, such as the NapkinStack repository itself, is out of scope.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import datetime
28
+ import re
29
+ from pathlib import Path
30
+
31
+ import yaml
32
+
33
+ PROJECT = Path("docs") / "project"
34
+ CHARTER = PROJECT / "charter.md"
35
+ CYCLES = PROJECT / "cycles"
36
+ DISCOVERY = PROJECT / "discovery.md"
37
+ DECISIONS = {"proposed", "go", "clarify", "kill"}
38
+ CHARTER_STATUSES = {"proposed", "accepted"}
39
+ CYCLE_STATUSES = {"proposed", "accepted", "closed", "stopped"}
40
+ DELIVERABLE_STATES = {"proposed", "ready", "in-progress", "accepted", "deferred", "dropped"}
41
+ WITHOUT_CRITERIA = {"proposed", "deferred", "dropped"} # acceptance criteria not required yet
42
+ OUTCOMES = {"closed": {"completed", "shipped"}, "stopped": {"reframed", "stopped"}}
43
+ DELIVERABLE_ID = re.compile(r"D[1-9][0-9]*")
44
+ FRONT_MATTER = re.compile(r"\A---\n(.*?)\n---\n", re.S)
45
+
46
+
47
+ def read_front_matter(path: Path) -> tuple[dict | None, str]:
48
+ """The front matter as a mapping, or None and the reason it cannot be read (C1)."""
49
+ match = FRONT_MATTER.match(path.read_text(encoding="utf-8"))
50
+ if not match:
51
+ return None, "no front matter: the file must start with a YAML block between two --- lines"
52
+ try:
53
+ data = yaml.safe_load(match[1])
54
+ except yaml.YAMLError as exc:
55
+ return None, f"front matter unreadable: {exc}"
56
+ if not isinstance(data, dict):
57
+ return None, "front matter must be a YAML mapping"
58
+ return data, ""
59
+
60
+
61
+ def as_date(value) -> datetime.date | None:
62
+ if isinstance(value, datetime.datetime):
63
+ return value.date()
64
+ if isinstance(value, datetime.date):
65
+ return value
66
+ try:
67
+ return datetime.date.fromisoformat(str(value))
68
+ except ValueError:
69
+ return None
70
+
71
+
72
+ def cycle_files(root: Path) -> list[Path]:
73
+ folder = root / CYCLES
74
+ return sorted(p for p in folder.glob("*.md") if not p.name.startswith("_")) if folder.is_dir() else []
75
+
76
+
77
+ def charter_accepted(root: Path) -> bool:
78
+ path = root / CHARTER
79
+ data = read_front_matter(path)[0] if path.is_file() else None
80
+ return bool(data) and data.get("status") == "accepted"
81
+
82
+
83
+ def accepted_cycle(root: Path) -> tuple[Path, dict] | None:
84
+ """The cycle in progress: the one accepted cycle, when its front matter is readable."""
85
+ for path in cycle_files(root):
86
+ data = read_front_matter(path)[0]
87
+ if data and data.get("status") == "accepted":
88
+ return path, data
89
+ return None
90
+
91
+
92
+ def _check_charter(path: Path, fail) -> bool:
93
+ data, reason = read_front_matter(path)
94
+ if data is None:
95
+ fail("C1", path, reason)
96
+ return False
97
+ if data.get("status") not in CHARTER_STATUSES:
98
+ fail("C2", path, f"status '{data.get('status')}': expected one of {sorted(CHARTER_STATUSES)}")
99
+ if not str(data.get("decider") or "").strip():
100
+ fail("C2", path, "decider missing: the GitHub handle of the human who validates the charter "
101
+ "and the cycles, and decides at the circuit breaker")
102
+ criteria = data.get("success_criteria")
103
+ if not isinstance(criteria, list) or not [c for c in criteria if str(c or "").strip()]:
104
+ fail("C2", path, "success_criteria: at least one, they say when the project itself ends")
105
+ return data.get("status") == "accepted"
106
+
107
+
108
+ def _check_deliverables(path: Path, deliverables, fail) -> None:
109
+ if not isinstance(deliverables, list) or not deliverables:
110
+ fail("C4", path, "deliverables: a finite, non-empty list — what the cycle delivers")
111
+ return
112
+ seen: set[str] = set()
113
+ for index, item in enumerate(deliverables, start=1):
114
+ if not isinstance(item, dict):
115
+ fail("C4", path, f"deliverable no. {index}: expected a mapping (id, title, state, acceptance)")
116
+ continue
117
+ ident = str(item.get("id") or "")
118
+ where = ident or f"no. {index}"
119
+ if not DELIVERABLE_ID.fullmatch(ident):
120
+ fail("C4", path, f"deliverable {where}: id expected as D1, D2…")
121
+ elif ident in seen:
122
+ fail("C4", path, f"deliverable {ident}: id used twice")
123
+ seen.add(ident)
124
+ if not str(item.get("title") or "").strip():
125
+ fail("C4", path, f"deliverable {where}: title missing")
126
+ state = item.get("state")
127
+ if state not in DELIVERABLE_STATES:
128
+ fail("C4", path, f"deliverable {where}: state '{state}', expected one of {sorted(DELIVERABLE_STATES)}")
129
+ criteria = item.get("acceptance")
130
+ filled = isinstance(criteria, list) and [c for c in criteria if str(c or "").strip()]
131
+ if state in DELIVERABLE_STATES - WITHOUT_CRITERIA and not filled:
132
+ fail("C4", path, f"deliverable {where}: state '{state}' requires acceptance criteria — "
133
+ "the definition of ready, and the source of its test sheet (PDR-0003)")
134
+
135
+
136
+ def _check_cycle(path: Path, fail) -> str | None:
137
+ data, reason = read_front_matter(path)
138
+ if data is None:
139
+ fail("C1", path, reason)
140
+ return None
141
+ if not str(data.get("goal") or "").strip():
142
+ fail("C3", path, "goal missing: one sentence")
143
+ status = data.get("status")
144
+ if status not in CYCLE_STATUSES:
145
+ fail("C3", path, f"status '{status}': expected one of {sorted(CYCLE_STATUSES)}")
146
+ weeks = data.get("appetite_weeks")
147
+ if isinstance(weeks, bool) or not isinstance(weeks, int) or weeks < 1:
148
+ fail("C3", path, "appetite_weeks: a whole number of weeks, decided rather than estimated")
149
+ weeks = None
150
+ start, end = as_date(data.get("start")), as_date(data.get("end"))
151
+ if start is None or end is None:
152
+ fail("C3", path, "start and end: dates, YYYY-MM-DD")
153
+ elif weeks is not None and end != start + datetime.timedelta(weeks=weeks):
154
+ fail("C3", path, f"end {end}: the appetite sets it, start + {weeks} week(s) = "
155
+ f"{start + datetime.timedelta(weeks=weeks)}")
156
+ _check_deliverables(path, data.get("deliverables"), fail)
157
+ if status in OUTCOMES:
158
+ if data.get("outcome") not in OUTCOMES[status]:
159
+ fail("C6", path, f"a {status} cycle records its outcome: one of {sorted(OUTCOMES[status])}")
160
+ if as_date(data.get("ended_on")) is None:
161
+ fail("C6", path, f"a {status} cycle records ended_on, the date it ended")
162
+ return status if isinstance(status, str) else None
163
+
164
+
165
+ def _check_discovery(path: Path, fail) -> str | None:
166
+ data, reason = read_front_matter(path)
167
+ if data is None:
168
+ fail("C1", path, reason)
169
+ return None
170
+ decision = data.get("decision")
171
+ if decision not in DECISIONS:
172
+ fail("C7", path, f"decision '{decision}': expected one of {sorted(DECISIONS)}")
173
+ elif decision != "proposed":
174
+ if not str(data.get("decider") or "").strip():
175
+ fail("C7", path, f"a decision ({decision}) names its decider")
176
+ if as_date(data.get("decided_on")) is None:
177
+ fail("C7", path, f"a decision ({decision}) records decided_on, YYYY-MM-DD")
178
+ if decision == "go" and not str(data.get("challenger") or "").strip():
179
+ fail("C7", path, "a go names its challenger: another session, or a human, challenged the "
180
+ "document first (playbooks/discovery.md, stage 5)")
181
+ return decision if isinstance(decision, str) else None
182
+
183
+
184
+ def run(root: Path) -> int:
185
+ failures: list[str] = []
186
+
187
+ def fail(rule: str, path: Path, message: str) -> None:
188
+ failures.append(f"[{rule}] {path.relative_to(root)}\n {message}")
189
+
190
+ if not (root / PROJECT).is_dir():
191
+ print(f"Plan: not applicable, no {PROJECT}/ in {root}.")
192
+ return 0
193
+ charter = root / CHARTER
194
+ discovery = root / DISCOVERY
195
+ cycles = cycle_files(root)
196
+ if not charter.is_file() and not cycles and not discovery.is_file():
197
+ print(f"Plan: no charter and no cycle in {PROJECT}/ — the project is not framed yet "
198
+ "(playbooks/framing.md).")
199
+ return 0
200
+ decision = _check_discovery(discovery, fail) if discovery.is_file() else None
201
+ accepted_charter = _check_charter(charter, fail) if charter.is_file() else False
202
+ if accepted_charter and discovery.is_file() and decision != "go":
203
+ fail("C7", charter, f"the charter is accepted, but the discovery's decision is '{decision}': "
204
+ "a charter follows a go (docs/project/discovery.md)")
205
+ accepted = [path for path in cycles if _check_cycle(path, fail) == "accepted"]
206
+ if len(accepted) > 1:
207
+ names = ", ".join(path.name for path in accepted)
208
+ fail("C5", root / CYCLES, f"{len(accepted)} accepted cycles ({names}): one cycle at a time — "
209
+ "close or stop the others")
210
+ if accepted and not accepted_charter:
211
+ fail("C5", accepted[0], "an accepted cycle requires an accepted charter "
212
+ f"({CHARTER}, status: accepted)")
213
+
214
+ print(f"Plan: charter {'present' if charter.is_file() else 'absent'}, {len(cycles)} cycle(s).")
215
+ for failure in failures:
216
+ print(f" FAIL {failure}")
217
+ if failures:
218
+ print(f"\n{len(failures)} violation(s). See docs/project/README.md and playbooks/framing.md.")
219
+ return 1
220
+ print("Plan: compliant.")
221
+ return 0
@@ -21,8 +21,9 @@ from napkinstack.fitness.manifests import find_manifests
21
21
 
22
22
  TEMPLATE = Path(__file__).resolve().parent / "templates" / "module"
23
23
  NAME = re.compile(r"[a-z][a-z0-9-]*")
24
- TEAM = re.compile(r"[A-Za-z0-9-]+/[A-Za-z0-9._-]+") # same rule as copier.yml
25
- OPTIONAL = {"bootstrap"} # absent: nothing to prepare
24
+ OWNER = re.compile( # same rule as copier.yml: organisation/team, or a GitHub user
25
+ r"[A-Za-z0-9-]+/[A-Za-z0-9._-]+|(?=[A-Za-z0-9-]{1,39}$)[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*")
26
+ OPTIONAL = {"bootstrap": "nothing to prepare", "e2e": "no end-to-end scenario"} # undeclared: skipped
26
27
  NEEDS_RUNBOOK = {"high", "critical"} # M8, kept in step with CRITICALITIES
27
28
 
28
29
  RUNBOOK = """# Runbook - {name}
@@ -46,13 +47,14 @@ RUNBOOK = """# Runbook - {name}
46
47
  """
47
48
 
48
49
 
49
- def create(root: Path, name: str, owner: str, criticality: str) -> int:
50
+ def create(root: Path, name: str, owner: str, criticality: str, user_facing: bool = False) -> int:
50
51
  if not NAME.fullmatch(name):
51
52
  print(f"FAIL [new-module] invalid name '{name}': kebab-case expected, for example billing.")
52
53
  return 1
53
- if not TEAM.fullmatch(owner):
54
+ if not OWNER.fullmatch(owner):
54
55
  print(f"FAIL [new-module] invalid owner '{owner}': a GitHub team, organisation/team, "
55
- "for example acme/billing (CODEOWNERS, docs/os/07-governance.md §7).")
56
+ "or a user when the project has no organisation, for example acme/billing "
57
+ "(CODEOWNERS, docs/os/07-governance.md §7).")
56
58
  return 1
57
59
  folder = root / "modules" / name
58
60
  if folder.exists():
@@ -67,6 +69,11 @@ def create(root: Path, name: str, owner: str, criticality: str) -> int:
67
69
  text = text.replace(marker, value)
68
70
  file_.write_text(text, encoding="utf-8")
69
71
 
72
+ if user_facing:
73
+ manifest = folder / "MANIFEST.yaml"
74
+ manifest.write_text(re.sub(r"^( *)user_facing: false", r"\1user_facing: true",
75
+ manifest.read_text(encoding="utf-8"), flags=re.M), encoding="utf-8")
76
+
70
77
  runbook = criticality in NEEDS_RUNBOOK
71
78
  if runbook:
72
79
  (folder / "docs").mkdir(exist_ok=True)
@@ -86,10 +93,10 @@ def create(root: Path, name: str, owner: str, criticality: str) -> int:
86
93
  print(f"WARNING: .github/CODEOWNERS missing; add \"{line} @{owner}\" to it.")
87
94
 
88
95
  print(f"Module created: modules/{name} (owner {owner}, criticality {criticality})"
89
- + (", runbook to fill in" if runbook else "") + ".")
96
+ + (", user-facing" if user_facing else "") + (", runbook to fill in" if runbook else "") + ".")
90
97
  print("\nNext steps:")
91
98
  print(" 1. Creation ADR in docs/adr/: capability, boundary, alternatives")
92
- print(" 2. MANIFEST.yaml: responsibility in ONE sentence, then the stack's check and test commands")
99
+ print(" 2. MANIFEST.yaml: responsibility in ONE sentence, user_facing, then the stack's check and test commands")
93
100
  print(f" 3. modules/{name}/AGENTS.md: what is specific to the module, never the kernel")
94
101
  print(f" 4. nstack fitness, then nstack check {name} and nstack test {name}")
95
102
  return 0
@@ -120,7 +127,7 @@ def run_verb(root: Path, verb: str, name: str | None) -> int:
120
127
  command = commands.get(verb)
121
128
  if not command:
122
129
  if verb in OPTIONAL:
123
- print(f"-> {target}: {verb} not declared, nothing to prepare.")
130
+ print(f"-> {target}: {verb} not declared, {OPTIONAL[verb]}.")
124
131
  continue
125
132
  print(f"FAIL [{verb}] module '{target}': commands.{verb} not declared in {manifest}.\n"
126
133
  " Action: declare the module stack's command there (docs/os/09-platform.md §2).")
@@ -0,0 +1,242 @@
1
+ """
2
+ Pull request rules read from its description: the test sheet (PDR-0003) and the cycle
3
+ (PDR-0002).
4
+
5
+ Rules:
6
+ T1 a test sheet when the pull request touches a user-facing module, or one of
7
+ criticality high or critical
8
+ T2 a verifier named, and every scenario row filled in: id, given · when · then, a kind
9
+ (automated, explored, human only — reason), a result (passed, failed, not verified)
10
+ T3 no scenario passed without its evidence and the commit it was verified on
11
+ T4 evidence produced on the pull request's head commit: the others are to run again
12
+ T5 no scenario failed; none left not verified, unless it is human only
13
+ K1 delivery work needs an accepted charter and an accepted cycle
14
+ K2 delivery work stops once the cycle is past its end date: the circuit breaker
15
+ K3 delivery work names a ready or in-progress deliverable of the cycle
16
+ K4 the out-of-cycle label carries its justification
17
+
18
+ Delivery work: a pull request that changes a module — a folder holding a MANIFEST.yaml.
19
+ The out-of-cycle label lifts K1 to K3, visibly and countably (docs/os/10-measurement.md).
20
+
21
+ Usage : nstack pr-check [--root ROOT] [--base BASE] [--body-file FILE]
22
+ In CI : PR_BODY, PR_LABELS and PR_HEAD_SHA come from the pull_request event.
23
+ Output: 0 when every applicable rule passes, 1 otherwise.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import datetime
29
+ import os
30
+ import re
31
+ import subprocess
32
+ from collections.abc import Callable
33
+ from pathlib import Path
34
+
35
+ import yaml
36
+
37
+ from napkinstack.fitness import plan
38
+ from napkinstack.fitness.manifests import find_manifests
39
+
40
+ LABEL = "out-of-cycle"
41
+ DELIVERABLE = re.compile(r"^Deliverable:[ \t]*(D[1-9][0-9]*)\b", re.I | re.M)
42
+ JUSTIFICATION = re.compile(r"^Out of cycle:[ \t]*(\S.*)$", re.I | re.M)
43
+ SHEET_CRITICALITIES = {"high", "critical"}
44
+ COLUMNS = ("#", "given · when · then", "kind", "result", "evidence", "commit")
45
+ KINDS = {"automated", "explored"}
46
+ RESULTS = {"passed", "failed", "not verified"}
47
+ SECTION = re.compile(r"^##[ \t]+Test sheet[ \t]*$", re.I | re.M)
48
+ NEXT_SECTION = re.compile(r"^#{1,2}[ \t]", re.M)
49
+ VERIFIER = re.compile(r"^Verifier:[ \t]*(.*)$", re.I | re.M)
50
+ HUMAN_ONLY = re.compile(r"human only[ \t]*[—–-][ \t]*(\S.*)", re.I)
51
+ PLACEHOLDER = re.compile(r"<[^<>]*>")
52
+ EMPTY = {"", "—", "-"}
53
+ SHA = re.compile(r"[0-9a-f]{7,40}")
54
+
55
+ Fail = Callable[[str, str], None]
56
+
57
+
58
+ def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
59
+ return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
60
+
61
+
62
+ def touched_modules(root: Path, files: list[str]) -> dict[str, dict]:
63
+ """Folder, relative to the root, to manifest, for every module the files change."""
64
+ touched = {}
65
+ for manifest in find_manifests(root):
66
+ folder = manifest.parent.relative_to(root).as_posix()
67
+ if any(path.startswith(f"{folder}/") for path in files):
68
+ try:
69
+ data = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {}
70
+ except yaml.YAMLError:
71
+ data = {}
72
+ touched[folder] = data if isinstance(data, dict) else {}
73
+ return touched
74
+
75
+
76
+ def sheet_reason(folder: str, manifest: dict) -> str | None:
77
+ """Why a module requires a test sheet (T1), or None."""
78
+ module = manifest.get("module") if isinstance(manifest.get("module"), dict) else {}
79
+ if module.get("user_facing") is True:
80
+ return f"{folder} (user-facing)"
81
+ if module.get("criticality") in SHEET_CRITICALITIES:
82
+ return f"{folder} (criticality {module['criticality']})"
83
+ return None
84
+
85
+
86
+ def _cells(line: str) -> list[str]:
87
+ inner = line.strip().removeprefix("|").removesuffix("|")
88
+ return [cell.strip().replace("\\|", "|") for cell in re.split(r"(?<!\\)\|", inner)]
89
+
90
+
91
+ def read_sheet(body: str) -> tuple[str, list[str], list[dict[str, str]]]:
92
+ """(verifier, header, filled rows) of the "Test sheet" section; empty when absent."""
93
+ match = SECTION.search(body)
94
+ if not match:
95
+ return "", [], []
96
+ section = body[match.end():]
97
+ if following := NEXT_SECTION.search(section):
98
+ section = section[:following.start()]
99
+ found = VERIFIER.search(section)
100
+ verifier = found[1].strip() if found else ""
101
+ verifier = "" if PLACEHOLDER.fullmatch(verifier) else verifier
102
+ lines = [line for line in section.splitlines() if line.strip().startswith("|")]
103
+ if len(lines) < 2:
104
+ return verifier, [], []
105
+ header = [" ".join(cell.lower().split()) for cell in _cells(lines[0])]
106
+ rows = []
107
+ for line in lines[2:]:
108
+ values = _cells(line)
109
+ if all(value in EMPTY or PLACEHOLDER.fullmatch(value) for value in values[1:]):
110
+ continue # the template's example row
111
+ rows.append(dict(zip(header, values)))
112
+ return verifier, header, rows
113
+
114
+
115
+ def _result(cell: str) -> str:
116
+ return re.split(r"[ \t]+[—–-][ \t]+", cell.replace("*", "").strip(), maxsplit=1)[0].lower()
117
+
118
+
119
+ def check_sheet(body: str, head: str, reasons: list[str], fail: Fail) -> list[str]:
120
+ """T1 to T5; returns the human-only scenarios, listed apart for the approver."""
121
+ verifier, header, rows = read_sheet(body)
122
+ if reasons and not rows:
123
+ fail("T1", f"Test sheet missing: this pull request touches {', '.join(reasons)}.\n"
124
+ " Action: fill in the \"Test sheet\" section of the description — scenarios "
125
+ "from the acceptance criteria, run by a verifier who is not the author "
126
+ "(docs/os/05-workflow.md §7).")
127
+ return []
128
+ if not rows:
129
+ return []
130
+ missing = [column for column in COLUMNS if column not in header]
131
+ if missing:
132
+ fail("T2", f"Test sheet: columns missing: {', '.join(missing)}.\n"
133
+ f" Expected: | {' | '.join(COLUMNS)} |")
134
+ return []
135
+ if not verifier:
136
+ fail("T2", "Test sheet: no verifier named.\n Action: \"Verifier: <agent session "
137
+ "or @human>\", someone other than the author of the change.")
138
+ human_only, rerun = [], []
139
+ for row in rows:
140
+ ident = row["#"] or "?"
141
+ kind = row["kind"]
142
+ reason = HUMAN_ONLY.fullmatch(kind)
143
+ result = _result(row["result"])
144
+ if row["given · when · then"] in EMPTY or PLACEHOLDER.fullmatch(row["given · when · then"]):
145
+ fail("T2", f"scenario {ident}: given · when · then missing")
146
+ if kind.lower() not in KINDS and not reason:
147
+ fail("T2", f"scenario {ident}: kind '{kind}', expected automated, explored, "
148
+ "or human only — <reason>")
149
+ if result not in RESULTS:
150
+ fail("T2", f"scenario {ident}: result '{row['result']}', expected passed, failed or "
151
+ "not verified")
152
+ continue
153
+ commit = row["commit"].strip().strip("`").lower()
154
+ if result == "passed" and (row["evidence"] in EMPTY or not SHA.fullmatch(commit)):
155
+ fail("T3", f"scenario {ident}: passed without evidence and the commit verified.\n"
156
+ " Action: link the screenshot, video, trace or log, and give the commit.")
157
+ elif result in {"passed", "failed"} and SHA.fullmatch(commit) and not head.startswith(commit):
158
+ rerun.append(ident)
159
+ if result == "failed":
160
+ fail("T5", f"scenario {ident}: failed.\n Action: fix the change, or have the decider "
161
+ "change the expected result, visibly in the sheet's history.")
162
+ elif result == "not verified" and reason:
163
+ human_only.append(f"{ident} — {reason[1]}")
164
+ elif result == "not verified":
165
+ fail("T5", f"scenario {ident}: not verified.\n Action: run it, or mark it "
166
+ "human only — <reason>.")
167
+ if rerun:
168
+ fail("T4", f"scenarios verified on another commit than the head {head[:7]}: "
169
+ f"{', '.join(rerun)}.\n Action: run them again on the head commit.")
170
+ return human_only
171
+
172
+
173
+ def check_cycle(root: Path, body: str, labels: set[str], today: datetime.date, fail: Fail) -> None:
174
+ """K1 to K4, for delivery work."""
175
+ if LABEL in labels:
176
+ if not JUSTIFICATION.search(body):
177
+ fail("K4", f"label {LABEL} without its justification.\n Action: add "
178
+ "\"Out of cycle: <reason>\" to the description — an incident, a production defect.")
179
+ return
180
+ cycle = plan.accepted_cycle(root)
181
+ if not plan.charter_accepted(root) or cycle is None:
182
+ fail("K1", "the project is not framed: no accepted charter and cycle in docs/project/.\n"
183
+ " Action: frame it with your agent (playbooks/framing.md), or add the "
184
+ f"{LABEL} label with a justification.")
185
+ return
186
+ path, data = cycle
187
+ end = plan.as_date(data.get("end"))
188
+ if end is not None and today > end:
189
+ fail("K2", f"circuit breaker: {path.name} ended on {end}, with no automatic extension.\n"
190
+ " Action: the decider chooses — ship what is accepted (status: closed), "
191
+ "frame a new cycle with a new appetite, or stop the project (status: stopped).")
192
+ return
193
+ match = DELIVERABLE.search(body)
194
+ states = {str(item.get("id")): item.get("state") for item in data.get("deliverables") or []
195
+ if isinstance(item, dict)}
196
+ if not match:
197
+ fail("K3", f"no deliverable named.\n Action: \"Deliverable: D<n>\" in the description, "
198
+ f"a deliverable of {path.name}.")
199
+ elif match[1] not in states:
200
+ fail("K3", f"deliverable {match[1]} is not in {path.name}.\n Action: name one of "
201
+ f"{', '.join(states) or 'its deliverables'}, or re-frame the cycle with the decider.")
202
+ elif states[match[1]] not in {"ready", "in-progress"}:
203
+ fail("K3", f"deliverable {match[1]} is '{states[match[1]]}': work starts on a ready "
204
+ "deliverable (definition of ready).")
205
+
206
+
207
+ def run(root: Path, base: str, body_file: Path | None = None) -> int:
208
+ if body_file is not None:
209
+ body = body_file.read_text(encoding="utf-8")
210
+ elif "PR_BODY" in os.environ:
211
+ body = os.environ["PR_BODY"]
212
+ else:
213
+ print("Pull request description not provided (PR_BODY or --body-file): not checked.")
214
+ return 0
215
+ if _git(root, "rev-parse", "--verify", "--quiet", base).returncode:
216
+ print(f"Base '{base}' not found — check skipped.")
217
+ return 0
218
+ files = _git(root, "diff", "--name-only", f"{base}...HEAD").stdout.split()
219
+ head = (os.environ.get("PR_HEAD_SHA") or _git(root, "rev-parse", "HEAD").stdout).strip().lower()
220
+ modules = touched_modules(root, files)
221
+ labels = {label.strip() for label in os.environ.get("PR_LABELS", "").split(",") if label.strip()}
222
+
223
+ failures: list[str] = []
224
+
225
+ def fail(rule: str, message: str) -> None:
226
+ failures.append(f"[{rule}] {message}")
227
+
228
+ reasons = [reason for folder, data in modules.items() if (reason := sheet_reason(folder, data))]
229
+ human_only = check_sheet(body, head, reasons, fail)
230
+ if modules:
231
+ check_cycle(root, body, labels, datetime.date.today(), fail)
232
+
233
+ print(f"Modules touched : {len(modules)}" + "".join(f"\n - {folder}" for folder in modules))
234
+ print(f"Test sheet : {'required' if reasons else 'not required'}")
235
+ for scenario in human_only:
236
+ print(f" For the approver, human only: {scenario}")
237
+ for failure in failures:
238
+ print(f"FAIL {failure}")
239
+ if failures:
240
+ return 1
241
+ print("Pull request rules: compliant.")
242
+ return 0
@@ -9,7 +9,7 @@ module:
9
9
  responsibility: >
10
10
  TODO - describe this module's business capability in one sentence.
11
11
 
12
- owner: "{{OWNER}}" # a GitHub TEAM, organisation/team, never an individual
12
+ owner: "{{OWNER}}" # a GitHub team, organisation/team; a user only without an organisation
13
13
  contact: "@{{OWNER}}"
14
14
 
15
15
  # proposed | active | maintenance | deprecated | retired (docs/os/02-modules.md §6)
@@ -18,6 +18,10 @@ module:
18
18
  # prototype | standard | high | critical (docs/os/07-governance.md §6)
19
19
  criticality: "{{CRITICALITY}}"
20
20
 
21
+ # Does a user see this module? true requires a test sheet on its pull requests
22
+ # (docs/os/05-workflow.md §7).
23
+ user_facing: false
24
+
21
25
  # The module's name in the code, when it differs from the folder (package, namespace).
22
26
  # Used by boundary detection.
23
27
  # code_name: {{MODULE_NAME}}
@@ -63,6 +67,7 @@ commands:
63
67
  test: "echo 'commands.test to be declared in MANIFEST.yaml' >&2; exit 1" # NEVER starts another module
64
68
  # bootstrap: # optional: make the module usable from a fresh clone
65
69
  # run: # optional: local start, with doubles for the dependencies
70
+ # e2e: # optional: end-to-end scenarios; evidence written to .evidence/
66
71
 
67
72
  # Review budget. Inherited from the project when absent (docs/os/05-workflow.md §4).
68
73
  review_budget:
File without changes