napkinstack 0.18.0__tar.gz → 0.19.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 (39) hide show
  1. {napkinstack-0.18.0 → napkinstack-0.19.0}/PKG-INFO +8 -7
  2. {napkinstack-0.18.0 → napkinstack-0.19.0}/README.md +6 -6
  3. {napkinstack-0.18.0 → napkinstack-0.19.0}/pyproject.toml +2 -1
  4. {napkinstack-0.18.0 → napkinstack-0.19.0}/pyproject.toml.orig +2 -2
  5. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/attest.py +25 -5
  6. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/cli.py +4 -4
  7. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/doctor.py +59 -10
  8. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/fitness/manifests.py +30 -1
  9. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/modules.py +6 -2
  10. napkinstack-0.19.0/src/napkinstack/owners.py +78 -0
  11. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/project.py +5 -3
  12. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/settings.py +5 -5
  13. {napkinstack-0.18.0 → napkinstack-0.19.0}/LICENSE +0 -0
  14. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/__init__.py +0 -0
  15. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/assurance.py +0 -0
  16. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/board.py +0 -0
  17. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/compat.py +0 -0
  18. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/discovery.py +0 -0
  19. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/evidence.py +0 -0
  20. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/fitness/__init__.py +0 -0
  21. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/fitness/boundaries.py +0 -0
  22. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/fitness/hygiene.py +0 -0
  23. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/fitness/plan.py +0 -0
  24. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/fitness/pr_scope.py +0 -0
  25. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/hold.py +0 -0
  26. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/landed.py +0 -0
  27. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/provenance.py +0 -0
  28. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/pull_request.py +0 -0
  29. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/skills.py +0 -0
  30. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
  31. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
  32. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/contracts/README.md +0 -0
  33. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
  34. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
  35. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
  36. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -0
  37. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/module/README.md +0 -0
  38. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  39. {napkinstack-0.18.0 → napkinstack-0.19.0}/src/napkinstack/templates/module/tests/.gitkeep +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: napkinstack
3
- Version: 0.18.0
3
+ Version: 0.19.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
7
7
  Requires-Dist: pyyaml>=6.0
8
8
  Requires-Dist: copier==9.18.2
9
+ Requires-Dist: pathspec>=0.12
9
10
  Requires-Dist: pre-commit==4.6.2
10
11
  Requires-Python: >=3.12
11
12
  Project-URL: Homepage, https://github.com/NapkinStack/engineering-os
@@ -20,7 +21,7 @@ modules, contracts, guardrails in CI. On the Django or Rails model, one command
20
21
  the project, which then receives new versions on demand; no application stack is imposed.
21
22
  Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
22
23
 
23
- > **Status: v0.18.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
24
+ > **Status: v0.19.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
24
25
  > What each version changes, engine and project apart: [`CHANGELOG.md`](CHANGELOG.md).
25
26
  > What is open: [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
26
27
 
@@ -84,8 +85,8 @@ To try it without installing anything, or to install it from this repository ins
84
85
  registry — always pinned to a release tag:
85
86
 
86
87
  ```bash
87
- uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.18.0" nstack init my-project
88
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.18.0"
88
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.19.0" nstack init my-project
89
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.19.0"
89
90
  ```
90
91
 
91
92
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -111,16 +112,16 @@ Each project then pins its version and changes it through `nstack update`.
111
112
  | `nstack new-contract <name>` | Creates a contract and, the first time, the module that holds them all |
112
113
  | `nstack check`, `test`, `bootstrap` `[module]`; `nstack run <module>` | Run the commands declared in the module's manifest |
113
114
  | `nstack discover <idea-file>` | Starts a discovery for your agent: the idea kept, its document created |
114
- | `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures; then the project across its cycles, and who holds what |
115
+ | `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures, and the decisions due at a closure; then the project across its cycles, and who holds what |
115
116
  | `nstack fitness` | Manifests, boundaries between modules, skills, plan, hygiene — each also runs alone: `manifests`, `boundaries`, `skills`, `plan`, `hygiene` |
116
117
  | `nstack pr-scope` | One PR = one module, review budget |
117
118
  | `nstack modules [--changed-since <base>]` | The project's modules, or those with a file changed since a base |
118
119
  | `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
119
- | `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
120
+ | `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared — one run at a time on a machine |
120
121
  | `nstack release [module] [args]` | Makes each module's artefacts into `dist/<module>/`, each after what it assembles; what follows a named module goes to its command |
121
122
  | `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
122
123
  | `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
123
- | `nstack attest --base <base> --body-file <description>` | The CI's checks run here, on a clean head; the run recorded on the commit, in git (`refs/notes/nstack/checks`), and shown on GitHub as `nstack / local checks` |
124
+ | `nstack attest --base <base> --body-file <description>` | The CI's checks run here, on a clean head; the run recorded on the commit, in git (`refs/notes/nstack/checks`), and shown on GitHub as `nstack / local checks`; attesting the same commit again keeps the module checks it passed (`--again` runs them) |
124
125
  | `nstack skills` | Exposes the playbooks as skills for the agent |
125
126
  | `nstack update [--session <id>] [--forge <name>]` | Lays the new version on a branch to review; an agent's session as the commit's `Agent-Session` trailer; `--forge` moves the project to another forge, its own lines kept |
126
127
 
@@ -5,7 +5,7 @@ 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.18.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
8
+ > **Status: v0.19.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
9
9
  > What each version changes, engine and project apart: [`CHANGELOG.md`](CHANGELOG.md).
10
10
  > What is open: [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
11
11
 
@@ -69,8 +69,8 @@ To try it without installing anything, or to install it from this repository ins
69
69
  registry — always pinned to a release tag:
70
70
 
71
71
  ```bash
72
- uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.18.0" nstack init my-project
73
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.18.0"
72
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.19.0" nstack init my-project
73
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.19.0"
74
74
  ```
75
75
 
76
76
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -96,16 +96,16 @@ Each project then pins its version and changes it through `nstack update`.
96
96
  | `nstack new-contract <name>` | Creates a contract and, the first time, the module that holds them all |
97
97
  | `nstack check`, `test`, `bootstrap` `[module]`; `nstack run <module>` | Run the commands declared in the module's manifest |
98
98
  | `nstack discover <idea-file>` | Starts a discovery for your agent: the idea kept, its document created |
99
- | `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures; then the project across its cycles, and who holds what |
99
+ | `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures, and the decisions due at a closure; then the project across its cycles, and who holds what |
100
100
  | `nstack fitness` | Manifests, boundaries between modules, skills, plan, hygiene — each also runs alone: `manifests`, `boundaries`, `skills`, `plan`, `hygiene` |
101
101
  | `nstack pr-scope` | One PR = one module, review budget |
102
102
  | `nstack modules [--changed-since <base>]` | The project's modules, or those with a file changed since a base |
103
103
  | `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
104
- | `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
104
+ | `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared — one run at a time on a machine |
105
105
  | `nstack release [module] [args]` | Makes each module's artefacts into `dist/<module>/`, each after what it assembles; what follows a named module goes to its command |
106
106
  | `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
107
107
  | `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
108
- | `nstack attest --base <base> --body-file <description>` | The CI's checks run here, on a clean head; the run recorded on the commit, in git (`refs/notes/nstack/checks`), and shown on GitHub as `nstack / local checks` |
108
+ | `nstack attest --base <base> --body-file <description>` | The CI's checks run here, on a clean head; the run recorded on the commit, in git (`refs/notes/nstack/checks`), and shown on GitHub as `nstack / local checks`; attesting the same commit again keeps the module checks it passed (`--again` runs them) |
109
109
  | `nstack skills` | Exposes the playbooks as skills for the agent |
110
110
  | `nstack update [--session <id>] [--forge <name>]` | Lays the new version on a branch to review; an agent's session as the commit's `Agent-Session` trailer; `--forge` moves the project to another forge, its own lines kept |
111
111
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.18.0"
3
+ version = "0.19.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"
@@ -9,6 +9,7 @@ readme = "README.md"
9
9
  dependencies = [
10
10
  "PyYAML>=6.0",
11
11
  "copier==9.18.2",
12
+ "pathspec>=0.12",
12
13
  "pre-commit==4.6.2",
13
14
  ]
14
15
 
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.18.0"
3
+ version = "0.19.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
7
  license-files = ["LICENSE"]
8
8
  readme = "README.md"
9
- dependencies = ["PyYAML>=6.0", "copier==9.18.2", "pre-commit==4.6.2"]
9
+ dependencies = ["PyYAML>=6.0", "copier==9.18.2", "pathspec>=0.12", "pre-commit==4.6.2"]
10
10
 
11
11
  [project.urls]
12
12
  Homepage = "https://github.com/NapkinStack/engineering-os"
@@ -78,15 +78,35 @@ def _git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProc
78
78
  return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=check)
79
79
 
80
80
 
81
+ MARK = 48 # bytes a cut's mark may take: `[… N bytes cut …]` and its newlines
82
+
83
+
84
+ def _cut(data: bytes, room: int) -> str:
85
+ """Whole up to room bytes, else a quarter of it from the head, the rest from the tail."""
86
+ if len(data) <= room:
87
+ return data.decode("utf-8", "ignore")
88
+ head = max(room - MARK, 0) // 4
89
+ tail = max(room - MARK, 0) - head
90
+ return (data[:head].decode("utf-8", "ignore") + f"\n[… {len(data) - head - tail} bytes cut …]\n"
91
+ + (data[-tail:].decode("utf-8", "ignore") if tail else ""))
92
+
93
+
81
94
  def capped(text: str) -> str:
82
- """A step's output as the record keeps it: whole up to KEPT bytes, else its head and its tail."""
95
+ """A step's output as the record keeps it: whole up to KEPT bytes, else cut log group by log
96
+ group — one per module in Module checks — so that each keeps its commands and its results,
97
+ the room a small one leaves going to the others (#295). No group: one head and one tail."""
83
98
  data = text.encode("utf-8", "replace")
84
99
  if len(data) <= KEPT:
85
100
  return text
86
- half = KEPT // 2
87
- cut = len(data) - KEPT
88
- return (data[:half].decode("utf-8", "ignore") + f"\n[… {cut} bytes cut …]\n"
89
- + data[-half:].decode("utf-8", "ignore"))
101
+ starts = [found.start() for found in re.finditer(rb"^::group::", data, re.M)]
102
+ bounds = [0, *(start for start in starts if start), len(data)]
103
+ parts = [data[start:end] for start, end in zip(bounds, bounds[1:])]
104
+ room, given = KEPT, {}
105
+ for index in sorted(range(len(parts)), key=lambda index: len(parts[index])):
106
+ share = room // (len(parts) - len(given))
107
+ given[index] = min(len(parts[index]), share)
108
+ room -= given[index]
109
+ return "".join(_cut(part, given[index]) for index, part in enumerate(parts))
90
110
 
91
111
 
92
112
  def unplaced(value, root: Path):
@@ -96,7 +96,7 @@ def build_parser() -> argparse.ArgumentParser:
96
96
  parser = argparse.ArgumentParser(prog="nstack", description="NapkinStack engine.")
97
97
  parser.add_argument("--version", action=_Version, nargs=0, help="the engine's version, and where it came from")
98
98
  sub = parser.add_subparsers(dest="command", required=True, metavar="command")
99
- _add(sub, "manifests", "manifests, lifecycles, deprecations, what a release assembles (M1-M11)",
99
+ _add(sub, "manifests", "manifests, lifecycles, deprecations, what a release assembles, evidence, who approves (M1-M13)",
100
100
  lambda a: manifests.run(a.root))
101
101
  _add(sub, "boundaries", "declared graph against real graph, release artefacts included (B1-B9)",
102
102
  lambda a: boundaries.run(a.root))
@@ -143,7 +143,7 @@ def build_parser() -> argparse.ArgumentParser:
143
143
  lambda a: compat.run(a.root, a.module, a.base))
144
144
  cp.add_argument("module", nargs="?", help="module holding the contracts (default: all)")
145
145
  cp.add_argument("--base", default="origin/main")
146
- rl = _add(sub, "release", "makes one module's artefacts, or all of them, each into dist/<module> "
146
+ rl = _add(sub, "release", "makes one module's artefacts, or all of them, each into dist/<module>, emptied first "
147
147
  "(commands.release), with what follows a named module passed to it",
148
148
  lambda a: _release(a))
149
149
  rl.add_argument("module", nargs="?", help="module name (default: all, each after what it assembles)")
@@ -165,7 +165,7 @@ def build_parser() -> argparse.ArgumentParser:
165
165
  lambda a: modules.create_contract(a.root, a.name, a.owner))
166
166
  nc.add_argument("name", help="the contract's name, kebab-case")
167
167
  nc.add_argument("--owner", help="owner of the contracts module (default: the foundation's owner)")
168
- pc = _add(sub, "pr-check", "test sheet and cycle, read from the pull request description (T1-T5, K1-K5)",
168
+ pc = _add(sub, "pr-check", "test sheet, cycle and attested checks, read from the pull request description (T1-T5, K1-K5, A1-A2, C9)",
169
169
  lambda a: pull_request.run(a.root, a.base, a.body_file, a.labels))
170
170
  pc.add_argument("--base", default="origin/main")
171
171
  pc.add_argument("--body-file", type=Path, help="the description, when PR_BODY is not set")
@@ -191,7 +191,7 @@ def build_parser() -> argparse.ArgumentParser:
191
191
  cl.add_argument("--person", help="the person accountable, such as @alice (default: git's user)")
192
192
  cl.add_argument("--take-over", action="store_true",
193
193
  help="take a hold nobody has worked on for hold_days — recorded, with its previous holder")
194
- rs = _add(sub, "unclaim", "gives a held deliverable back: by its holder, the decider, or anyone once stale",
194
+ rs = _add(sub, "unclaim", "gives a held deliverable back: by its holder, the decider, or anyone once stale; a closed cycle's hold has ended",
195
195
  lambda a: hold.release(a.root, a.deliverable, a.session, a.person))
196
196
  rs.add_argument("deliverable", help="the deliverable's identifier, D<n>")
197
197
  rs.add_argument("--session", help="the agent's session, written on the release")
@@ -37,9 +37,9 @@ Rules:
37
37
  known public forge is said, never judged: a mirror is a choice
38
38
  N1 information, never a gap (D91): a published version newer than the project's, read from
39
39
  the registry — the one place a project learns of it. No other command opens a connection
40
- G1-G20 the GitHub settings of CHECKLIST; G6 is not applicable outside a public
41
- repository, and on a private one G1-G5, G12, G13, G17 and G18 name the GitHub plan
42
- or option required
40
+ G1-G21 the GitHub settings of CHECKLIST; G6 is not applicable outside a public
41
+ repository, G21 outside `approval: agent`, and on a private one G1-G5, G12, G13, G17,
42
+ G18, G20 and G21 name the GitHub plan or option required
43
43
 
44
44
  Off GitHub (the project's `forge` answer, ADR-0010) no G rule is read: the forge-neutral
45
45
  PROTECTIONS are said as information — the same list as INSTALL.md's, bound by a test — and the
@@ -77,7 +77,7 @@ SOURCES = "SOURCES" # a contributor's checkout holding the project's version: s
77
77
  OUT_OF_REACH = "OUT OF REACH" # the plan forbids it: a state, not a fault (PDR-0006)
78
78
  # The settings that make the forge refuse a merge. A repository is guarded when they are
79
79
  # all in force; the others matter and none of them stops a merge.
80
- BLOCKING = ("G1", "G2", "G3", "G4", "G12", "G13")
80
+ BLOCKING = ("G1", "G2", "G3", "G4", "G12", "G13", "G21")
81
81
  API_VERSION = "2026-03-10"
82
82
  JOBS = ("Fitness functions", "PR scope and review budget", "Hooks and secrets", "Test sheet and cycle",
83
83
  "Module checks")
@@ -154,13 +154,29 @@ CHECKLIST = [ # (rule, setting, action)
154
154
  ("G20", "Hold branches `nstack/hold/**/*` cannot be force-pushed nor deleted",
155
155
  "Settings → Rules → Rulesets, a ruleset on the branches nstack/hold/**/* — not nstack/hold/**, "
156
156
  "which stops at a slash — with nobody on its bypass list: block force pushes and restrict deletions"),
157
+ # G21: where another contributor's agent approves (PDR-0021), the one who pushed last never
158
+ # approves what they pushed — the author's own fix included.
159
+ ("G21", "The most recent push approved by someone other than its pusher (approval: agent)",
160
+ f"{RULESET}: require approval of the most recent reviewable push"),
157
161
  ]
158
162
 
163
+ # Asked of a project only on one of its answers: elsewhere not applicable, and said why.
164
+ ANSWERED = {"G21": ("approval", "agent", "the project's approval is human: a code owner approves every change")}
165
+
166
+
167
+ def unasked(rule: str, answers: dict) -> str | None:
168
+ """Why a rule is not asked of this project, or None when it is."""
169
+ if rule not in ANSWERED:
170
+ return None
171
+ key, value, reason = ANSWERED[rule]
172
+ return None if answers.get(key) == value else reason
173
+
174
+
159
175
  # Settings specific to public repositories, and settings a private one pays for
160
176
  # (GitHub documentation, 2026-09-15).
161
177
  PUBLIC_ONLY = {"G6": "private vulnerability reporting only exists for a public repository; "
162
178
  "state an internal channel in SECURITY.md"}
163
- PRIVATE_PLAN = dict.fromkeys(("G1", "G2", "G3", "G4", "G12", "G13", "G17", "G18", "G20"),
179
+ PRIVATE_PLAN = dict.fromkeys(("G1", "G2", "G3", "G4", "G12", "G13", "G17", "G18", "G20", "G21"),
164
180
  "Private repository: rulesets require the GitHub Team plan (organisation) "
165
181
  "or Pro (personal account); without it, nothing blocks the merge.")
166
182
  PRIVATE_PLAN["G5"] = ("Private repository: Secret Protection is a paid option; without it, only the "
@@ -339,19 +355,25 @@ CHECKS: dict[str, Callable[[GitHub], bool]] = {
339
355
  "G18": lambda c: _rule(c, "non_fast_forward") is not None,
340
356
  "G19": lambda c: _merges(c, "default_branch").get("default_branch") == "main",
341
357
  "G20": _holds_kept,
358
+ "G21": lambda c: _parameters(c, "pull_request").get("require_last_push_approval") is True,
342
359
  }
343
360
 
344
361
 
345
362
  def _default_owner(root: Path) -> tuple[str, str]:
346
- """L6: `*` first, so that the code owner review covers every path (ADR-0004)."""
347
- from napkinstack.project import forge, ownership
363
+ """L6: `*` first, so that the code owner review covers every path (ADR-0004) — or, where
364
+ another contributor's agent approves (PDR-0021), a human named for what one must approve and
365
+ no default owner, so that the rest is the agents'."""
366
+ from napkinstack.project import answer, forge, ownership
348
367
 
349
368
  path = ownership(root) # where the project's forge reads it (ADR-0010)
350
369
  shown = path.relative_to(root).as_posix()
370
+ agent = answer(root, "approval") == "agent"
351
371
  if not path.is_file():
352
- return GAP, f"{shown} missing.\nAction: create it, starting with `* @<owner>`."
372
+ return GAP, f"{shown} missing.\nAction: create it" + ("." if agent else ", starting with `* @<owner>`.")
353
373
  rules = [line.split() for line in path.read_text(encoding="utf-8").splitlines()
354
374
  if line.strip() and not line.lstrip().startswith("#")]
375
+ if agent:
376
+ return _kept_for_a_human(root, shown, path.read_text(encoding="utf-8"))
355
377
  if rules and rules[0][0] == "*" and len(rules[0]) > 1:
356
378
  name = forge(root)
357
379
  if name in UNREAD_OWNERSHIP:
@@ -364,6 +386,28 @@ def _default_owner(root: Path) -> tuple[str, str]:
364
386
  "its first rule: the code owner review then covers every path (.nstack/handbook/07-governance.md §7).")
365
387
 
366
388
 
389
+ def _kept_for_a_human(root: Path, shown: str, text: str) -> tuple[str, str]:
390
+ """L6 on `approval: agent`: no default owner, and every path a human must approve ends on a
391
+ rule with an owner (`owners.py`; in CI, rule M13)."""
392
+ from napkinstack import owners
393
+ from napkinstack.project import forge
394
+
395
+ default, missing = owners.unowned(root, text)
396
+ if default:
397
+ return GAP, (f"{shown} names a default owner: on approval agent, it leaves no change another "
398
+ "contributor's agent may approve.\nAction: remove the `*` rule; name a human for what "
399
+ "one must approve (.nstack/handbook/07-governance.md §7).")
400
+ if missing:
401
+ return GAP, (f"{shown} names no owner for " + ", ".join(f"`{path}`" for path in missing)
402
+ + ": on approval agent, a human approves them.\nAction: end each on a line "
403
+ "`/<path> @<owner>`, the owner a team of people (.nstack/handbook/07-governance.md §7).")
404
+ name = forge(root)
405
+ if name in UNREAD_OWNERSHIP or name == "none":
406
+ return OK, ("No forge reads it here: who approves what is for people to read, and nothing refuses "
407
+ "an agent's approval of the rest.")
408
+ return OK, ""
409
+
410
+
367
411
  def _forge(path: str) -> dict | None:
368
412
  """JSON of a public repository's page on the forge, read without a token; None when it does
369
413
  not answer."""
@@ -448,7 +492,9 @@ def _workstation(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
448
492
  l3 = (OK, "") if installed else (GAP, "Action: pre-commit install")
449
493
  results.append(("L3", "pre-commit hooks installed", *l3))
450
494
 
451
- results.append(("L6", "CODEOWNERS starts with a default owner", *_default_owner(root)))
495
+ results.append(("L6", "CODEOWNERS names a human where one approves (approval: agent)"
496
+ if answers.get("approval") == "agent" else "CODEOWNERS starts with a default owner",
497
+ *_default_owner(root)))
452
498
 
453
499
  source = str(answers.get("_src_path") or "")
454
500
  local = not REMOTE.match(source)
@@ -540,6 +586,7 @@ def _state(results: list[tuple[str, str, str, str]]) -> None:
540
586
  be read (PDR-0006)."""
541
587
  status_of = {rule: status for rule, _, status, _ in results}
542
588
  blocking = [status_of.get(rule, UNKNOWN) for rule in BLOCKING]
589
+ blocking = [status for status in blocking if status != NOT_APPLICABLE] # G21 off `approval: agent`
543
590
  if any(status == UNKNOWN for status in blocking):
544
591
  print("\nThis repository: not verified — what cannot be read is never reported as guarded.")
545
592
  return
@@ -629,7 +676,9 @@ def run(root: Path) -> int:
629
676
  "Administration: read permission, then run again.")
630
677
  private = client is not None and _private(client)
631
678
  for rule, setting, action in CHECKLIST:
632
- if client is None:
679
+ if unasked(rule, answers):
680
+ status, detail = NOT_APPLICABLE, f"Reason: {unasked(rule, answers)}."
681
+ elif client is None:
633
682
  status, detail = UNKNOWN, ""
634
683
  elif private and rule in PUBLIC_ONLY:
635
684
  status, detail = NOT_APPLICABLE, f"Reason: {PUBLIC_ONLY[rule]}."
@@ -21,6 +21,9 @@ Rules:
21
21
  (.nstack/handbook/09-platform.md §2, D127)
22
22
  M12 evidence declares what the module's end-to-end scenarios provoke: patterns that compile and
23
23
  check something, a level, a scenario (ADR-0009)
24
+ M13 on `approval: agent`, the code owners file names a human for every module neither
25
+ `prototype` nor `standard`, and for the project's rules, with no default owner (PDR-0021) —
26
+ in the same pull request that raises a module's criticality
24
27
 
25
28
  Usage : nstack manifests [--root ROOT]
26
29
  Output: 0 if everything passes, 1 otherwise. Every failure explains the rule broken.
@@ -467,13 +470,39 @@ def assembled(manifests: list[Path], root: Path) -> None:
467
470
  " Action: one of them stops assembling the other.", places[name])
468
471
 
469
472
 
473
+ def kept_for_a_human(root: Path) -> None:
474
+ """M13: where another contributor's agent approves, what a human must approve is named in the
475
+ code owners file. The forge reads that file from the base branch, so a pull request raising a
476
+ module's criticality without its line would be approved by an agent: refused here, in CI."""
477
+ from napkinstack import owners
478
+ from napkinstack.project import answer, ownership
479
+
480
+ if answer(root, "approval") != "agent":
481
+ return
482
+ path = ownership(root)
483
+ shown = path.relative_to(root).as_posix()
484
+ action = ("Action: end each on a line `/<path> @<owner>`, the owner a team of people, in this pull request "
485
+ "(.nstack/handbook/07-governance.md §7).")
486
+ if not path.is_file():
487
+ fail("code owners", "M13", f"{shown} missing: on approval agent, nothing keeps a change for a human.\n {action}")
488
+ return
489
+ default, missing = owners.unowned(root, path.read_text(encoding="utf-8"))
490
+ if default:
491
+ fail("code owners", "M13", f"{shown} names a default owner: on approval agent, no change is left "
492
+ "for another contributor's agent.\n Action: remove the `*` rule.")
493
+ for kept in missing:
494
+ fail("code owners", "M13", f"{shown} ends `{kept}` on no owner: on approval agent, a human "
495
+ f"approves it.\n {action}")
496
+
497
+
470
498
  def run(root: Path) -> int:
471
499
  failures.clear()
472
500
  warnings.clear()
473
501
  today = datetime.date.today()
474
502
  manifests = find_manifests(root)
475
503
  orphans = find_orphans(root)
476
- if not manifests and not orphans:
504
+ kept_for_a_human(root)
505
+ if not manifests and not orphans and not failures:
477
506
  print("No MANIFEST.yaml found. Nothing to validate.")
478
507
  return 0
479
508
  for orphan in orphans:
@@ -20,7 +20,7 @@ from pathlib import Path
20
20
  import yaml
21
21
 
22
22
  from napkinstack import assurance
23
- from napkinstack.project import forge, ownership
23
+ from napkinstack.project import answer, forge, ownership
24
24
  from napkinstack.fitness.manifests import (changed_files, contract_entries, find_manifests,
25
25
  module_content)
26
26
 
@@ -102,7 +102,11 @@ def create(root: Path, name: str, owner: str, criticality: str, user_facing: boo
102
102
 
103
103
  codeowners = ownership(root)
104
104
  line = f"/modules/{name}/"
105
- if codeowners.is_file():
105
+ if answer(root, "approval") == "agent" and criticality in ("prototype", "standard"):
106
+ # Another contributor's agent approves it (PDR-0021): no code owner, whose review a human's alone satisfies.
107
+ print(f"No code owner line for {line}: on approval agent, another contributor's agent approves a "
108
+ f"{criticality} module.")
109
+ elif codeowners.is_file():
106
110
  content = codeowners.read_text(encoding="utf-8")
107
111
  if not any(existing.split()[:1] == [line] for existing in content.splitlines()):
108
112
  codeowners.write_text(content.rstrip("\n") + f"\n{line:<31}@{owner}\n", encoding="utf-8")
@@ -0,0 +1,78 @@
1
+ """
2
+ Who must approve a change, read from the code owners file as the forge reads it (PDR-0021).
3
+
4
+ On `approval: agent`, another contributor's agent approves what the code owners file names no
5
+ owner for; a human, what it names. So every path a human must approve — the project's rules,
6
+ its records, its answers, the code owners file itself, every module neither `prototype` nor
7
+ `standard` — must end on a rule with an owner. A pattern is read as a gitignore pattern, the
8
+ syntax the forge documents for the file, and the last rule that matches a path decides it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ import pathspec
16
+ import yaml
17
+
18
+ # What a human approves whatever the project answers: its rules, its barriers, its records, its answers.
19
+ KEPT_FOR_A_HUMAN = ("AGENTS.md", "CONTRIBUTING.md", ".nstack/", ".copier-answers.yml", "docs/",
20
+ ".pre-commit-config.yaml", ".yamllint.yaml", "SECURITY.md")
21
+ FORGE_FOLDER = {"github": ".github/", "gitlab": ".gitlab/", "forgejo": ".gitea/", "gitea": ".gitea/"}
22
+ NOBODY = "nstack-no-file-is-named-so" # a path no rule names but a default owner
23
+
24
+
25
+ def rules(text: str) -> list[tuple[pathspec.PathSpec | None, list[str]]]:
26
+ """Each rule of the file, in order: its pattern and its owners — none after a `#`."""
27
+ found = []
28
+ for line in text.splitlines():
29
+ words = line.split("#", 1)[0].split()
30
+ if not words:
31
+ continue
32
+ try:
33
+ spec = pathspec.PathSpec.from_lines("gitignore", [words[0]])
34
+ except (ValueError, TypeError):
35
+ spec = None # a pattern the forge would refuse covers nothing
36
+ found.append((spec, words[1:]))
37
+ return found
38
+
39
+
40
+ def owned(read: list[tuple[pathspec.PathSpec | None, list[str]]], path: str) -> bool:
41
+ """Whether the last rule matching the path names an owner."""
42
+ last = [owners for spec, owners in read if spec is not None and spec.match_file(path)]
43
+ return bool(last and last[-1])
44
+
45
+
46
+ def wanted(root: Path) -> list[str]:
47
+ """The paths a human must approve on `approval: agent`, folders ending with `/`."""
48
+ from napkinstack.fitness.manifests import find_manifests
49
+ from napkinstack.project import forge, ownership
50
+
51
+ paths = [*KEPT_FOR_A_HUMAN, ownership(root).relative_to(root).as_posix()]
52
+ if forge(root) in FORGE_FOLDER:
53
+ paths.append(FORGE_FOLDER[forge(root)])
54
+ for manifest in find_manifests(root):
55
+ try:
56
+ data = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {}
57
+ except (OSError, yaml.YAMLError):
58
+ data = {}
59
+ module = data.get("module") if isinstance(data, dict) else None
60
+ # A value the engine does not know, or a manifest it cannot read, is judged as critical.
61
+ if not isinstance(module, dict) or module.get("criticality") not in ("prototype", "standard"):
62
+ paths.append(manifest.parent.relative_to(root).as_posix() + "/")
63
+ return list(dict.fromkeys(paths))
64
+
65
+
66
+ def unowned(root: Path, text: str) -> tuple[bool, list[str]]:
67
+ """Whether the file names a default owner, and the paths a human must approve that end on a
68
+ rule with none — a folder by its own name and by every file it holds."""
69
+ read = rules(text)
70
+ missing = []
71
+ for path in wanted(root):
72
+ probes = [path + NOBODY] if path.endswith("/") else [path]
73
+ folder = root / path
74
+ if path.endswith("/") and folder.is_dir():
75
+ probes += [file.relative_to(root).as_posix() for file in folder.rglob("*") if file.is_file()]
76
+ if not all(owned(read, probe) for probe in probes):
77
+ missing.append(path)
78
+ return owned(read, NOBODY), missing
@@ -201,7 +201,7 @@ def init(destination: Path, answers: dict[str, str | None], source: str, ref: st
201
201
  import copier
202
202
  from copier.errors import CopierError
203
203
 
204
- from napkinstack.doctor import CHECKLIST
204
+ from napkinstack.doctor import CHECKLIST, unasked
205
205
 
206
206
  destination = destination.resolve()
207
207
  if destination.exists() and not destination.is_dir():
@@ -235,6 +235,7 @@ def init(destination: Path, answers: dict[str, str | None], source: str, ref: st
235
235
  # was asked by Copier itself.
236
236
  data.setdefault("checks", "forge" if named == "github" else "attested")
237
237
  data.setdefault("hold_days", 7) # a hold goes stale after a week without work (ADR-0007)
238
+ data.setdefault("approval", "human") # a code owner approves, until the decider says otherwise (PDR-0021)
238
239
  try:
239
240
  copier.run_copy(source, destination, data=data, vcs_ref=ref, quiet=True, unsafe=False)
240
241
  except (CopierError, ValueError, OSError) as exc:
@@ -261,8 +262,9 @@ def init(destination: Path, answers: dict[str, str | None], source: str, ref: st
261
262
  if named == "github":
262
263
  print(f" 2. Create the GitHub repository {created.get('github_repo')}, push main to it, then apply "
263
264
  "these settings:")
264
- for _, setting, _ in CHECKLIST:
265
- print(f" - [ ] {setting}")
265
+ for rule, setting, _ in CHECKLIST:
266
+ if not unasked(rule, created): # G21 on `approval: agent` alone
267
+ print(f" - [ ] {setting}")
266
268
  print(" 3. Check the workstation and GitHub, read-only: nstack doctor (token: see .nstack/handbook/INSTALL.md)")
267
269
  return 0
268
270
  where = (f"Create the repository {created.get('repository')} at {named}, push main to it"
@@ -46,8 +46,8 @@ from napkinstack.project import ANSWERS
46
46
 
47
47
  RULESET_NAME = "nstack-checklist"
48
48
  HOLDS_RULESET = "nstack-holds" # the holds of deliverables: never forced, never deleted (G20)
49
- IN_RULESET = ("G1", "G2", "G3", "G4", "G13", "G17", "G18")
50
- PULL_REQUEST = ("G1", "G2", "G3", "G13")
49
+ IN_RULESET = ("G1", "G2", "G3", "G4", "G13", "G17", "G18", "G21")
50
+ PULL_REQUEST = ("G1", "G2", "G3", "G13", "G21")
51
51
  LABEL_COLOURS = {"cross-module": "d93f0b", "over-budget": "fbca04", "out-of-cycle": "5319e7",
52
52
  "request": "0e8a16", "bug": "d73a4a", "architecture": "1d76db", "debt": "c5def5",
53
53
  "acceptance": "bfdadc"}
@@ -74,7 +74,7 @@ def ruleset(missing: set[str]) -> dict:
74
74
  if missing & set(PULL_REQUEST):
75
75
  rules.append({"type": "pull_request", "parameters": {
76
76
  "required_approving_review_count": 1, "require_code_owner_review": True,
77
- "dismiss_stale_reviews_on_push": True, "require_last_push_approval": False,
77
+ "dismiss_stale_reviews_on_push": True, "require_last_push_approval": "G21" in missing,
78
78
  "required_review_thread_resolution": False}})
79
79
  if "G4" in missing:
80
80
  rules.append({"type": "required_status_checks", "parameters": {
@@ -329,7 +329,7 @@ def run(root: Path, apply: bool = False) -> int:
329
329
  if client is None:
330
330
  # G12 is not planned blind: the ruleset planned below has nobody on its bypass list, and
331
331
  # an older one that has is a step by hand, which only a read can find.
332
- missing = {rule for rule, _, _ in doctor.CHECKLIST} - {"G12"}
332
+ missing = {rule for rule, _, _ in doctor.CHECKLIST if not doctor.unasked(rule, answers)} - {"G12"}
333
333
  print(f"Settings of {repo}: not read — no token (GH_TOKEN or GITHUB_TOKEN). The whole checklist is "
334
334
  "planned below; a call for a setting already in place changes nothing, except a ruleset's: "
335
335
  f"skip it if a ruleset named {RULESET_NAME} exists, and the G20 one if {HOLDS_RULESET} does.\n"
@@ -346,7 +346,7 @@ def run(root: Path, apply: bool = False) -> int:
346
346
  "a token to print the whole checklist.")
347
347
  return 1
348
348
  for rule, _, _ in doctor.CHECKLIST:
349
- if private and rule in doctor.PUBLIC_ONLY:
349
+ if private and rule in doctor.PUBLIC_ONLY or doctor.unasked(rule, answers):
350
350
  continue
351
351
  try:
352
352
  compliant = doctor.CHECKS[rule](client)
File without changes