napkinstack 0.6.2__tar.gz → 0.7.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 (36) hide show
  1. {napkinstack-0.6.2 → napkinstack-0.7.0}/PKG-INFO +19 -8
  2. {napkinstack-0.6.2 → napkinstack-0.7.0}/README.md +18 -7
  3. {napkinstack-0.6.2 → napkinstack-0.7.0}/pyproject.toml +1 -1
  4. {napkinstack-0.6.2 → napkinstack-0.7.0}/pyproject.toml.orig +1 -1
  5. napkinstack-0.7.0/src/napkinstack/assurance.py +74 -0
  6. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/cli.py +24 -9
  7. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/compat.py +7 -7
  8. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/discovery.py +4 -4
  9. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/doctor.py +77 -25
  10. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/fitness/boundaries.py +7 -7
  11. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/fitness/manifests.py +34 -14
  12. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/fitness/plan.py +104 -9
  13. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/fitness/pr_scope.py +5 -5
  14. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/modules.py +123 -13
  15. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/project.py +204 -7
  16. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/provenance.py +1 -1
  17. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/pull_request.py +73 -27
  18. napkinstack-0.7.0/src/napkinstack/settings.py +247 -0
  19. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/skills.py +6 -6
  20. napkinstack-0.7.0/src/napkinstack/templates/contracts/AGENTS.md +26 -0
  21. napkinstack-0.7.0/src/napkinstack/templates/contracts/MANIFEST.yaml +29 -0
  22. napkinstack-0.7.0/src/napkinstack/templates/contracts/README.md +8 -0
  23. napkinstack-0.7.0/src/napkinstack/templates/contracts/docs/runbook.md +15 -0
  24. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/templates/module/AGENTS.md +1 -1
  25. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/templates/module/MANIFEST.yaml +9 -6
  26. napkinstack-0.7.0/src/napkinstack/templates/module/README.md +24 -0
  27. napkinstack-0.7.0/src/napkinstack/templates/module/tests/.gitkeep +0 -0
  28. napkinstack-0.6.2/src/napkinstack/templates/module/README.md +0 -27
  29. {napkinstack-0.6.2 → napkinstack-0.7.0}/LICENSE +0 -0
  30. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/__init__.py +0 -0
  31. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/fitness/__init__.py +0 -0
  32. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/fitness/hygiene.py +0 -0
  33. {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/landed.py +0 -0
  34. {napkinstack-0.6.2/src/napkinstack/templates/module/docs/adr → napkinstack-0.7.0/src/napkinstack/templates/contracts/tests}/.gitkeep +0 -0
  35. {napkinstack-0.6.2/src/napkinstack/templates/module/src → napkinstack-0.7.0/src/napkinstack/templates/module/docs/adr}/.gitkeep +0 -0
  36. {napkinstack-0.6.2/src/napkinstack/templates/module/tests → napkinstack-0.7.0/src/napkinstack/templates/module/src}/.gitkeep +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: napkinstack
3
- Version: 0.6.2
3
+ Version: 0.7.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,7 +20,7 @@ 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.6.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): what the
23
+ > **Status: v0.6.2, published** ([PyPI](https://pypi.org/project/napkinstack/)): what the
24
24
  > framework announces, it refuses — and what it cannot refuse, it says. The boundaries read the
25
25
  > contracts a module uses, a contract version someone relies on changes only with a proof that
26
26
  > runs in the base's tree, the module checks and stale approvals are enforced, a verifier is not
@@ -28,8 +28,10 @@ Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
28
28
  > holding its template's words is refused, a deprecated module's consumers are named with their
29
29
  > removal date, and a contract change runs the checks of both sides — the producer and every
30
30
  > declared consumer. The diagnosis says whether anything refuses at all; a record names what
31
- > reached the default branch outside a pull request, and never refuses it. A first pilot
32
- > project, private, starts from here.
31
+ > reached the default branch outside a pull request, and never refuses it. Since v0.6.2, each
32
+ > version's notes are published, engine and project apart, and an update names every version it
33
+ > crosses. A first real project, public, has run two cycles on it; **M11**, in progress, makes
34
+ > the framework charge what a failure costs, and nothing more ([PDR-0011](docs/pdr/0011-ceremony-follows-consequence.md)).
33
35
  > Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
34
36
  > [move to English](docs/governance/plans/2026-09-16-english-migration.md),
35
37
  > [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
@@ -37,6 +39,7 @@ Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
37
39
  > [a proof that cannot be rewritten](docs/governance/plans/2026-09-20-v0.5.0-a-proof-that-cannot-be-rewritten.md),
38
40
  > [the conformance suite](docs/governance/plans/2026-09-20-m12-conformance-suite.md),
39
41
  > [what is announced refuses](docs/governance/plans/2026-09-20-v0.6.0-what-is-announced-refuses.md),
42
+ > [ceremony follows consequence](docs/governance/plans/2026-09-23-m11-ceremony-follows-consequence.md),
40
43
  > [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
41
44
 
42
45
  ## A project's journey
@@ -62,6 +65,14 @@ flowchart LR
62
65
  The project owns its skeleton and adapts it freely. Every new version reaches it on
63
66
  demand, merged with its adaptations; the conflicts are left to the team.
64
67
 
68
+ Five files are never merged again, because their content is the product's: the front page
69
+ `README.md`, the security policy, the module map and the two decision indexes. `nstack init`
70
+ writes them as a form — headings and the place of a diagram — and each is asked for when its
71
+ moment comes, never before: the sentence at the discovery's go, the page at the charter's
72
+ acceptance, the map at a module's first code. The foundation's manual sits one click away,
73
+ in `.nstack/handbook/INSTALL.md`
74
+ ([PDR-0010](docs/pdr/0010-the-framework-imposes-the-form.md)).
75
+
65
76
  ```mermaid
66
77
  flowchart LR
67
78
  V1["Skeleton v0.1<br/>common base"]:::ref --> F{"Three-way<br/>merge"}
@@ -91,8 +102,8 @@ To try it without installing anything, or to install it from this repository ins
91
102
  registry — always pinned to a release tag:
92
103
 
93
104
  ```bash
94
- uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.5.0" nstack init my-project
95
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.5.0"
105
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.6.2" nstack init my-project
106
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.6.2"
96
107
  ```
97
108
 
98
109
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -155,8 +166,8 @@ generation; dotted: a versioned dependency.
155
166
 
156
167
  | Path | Role |
157
168
  |---|---|
158
- | `skeleton/` | What every project receives: kernel, playbooks, handbook, CI, hooks |
159
- | `copier.yml` | The questions asked at creation (a Copier template, ADR-0001) |
169
+ | `skeleton/` | What every project receives: kernel, playbooks, handbook and its manual, CI, hooks — and five forms the project writes |
170
+ | `copier.yml` | The questions asked at creation, and the files no update touches again (a Copier template, ADR-0001) |
160
171
  | `src/napkinstack/` | The engine, the `nstack` command |
161
172
  | `platform/` | The engine module's envelope: manifest, runbook, tests |
162
173
  | `PRODUCT.md`, `docs/governance/` | The working context for NapkinStack itself |
@@ -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.6.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): what the
8
+ > **Status: v0.6.2, published** ([PyPI](https://pypi.org/project/napkinstack/)): what the
9
9
  > framework announces, it refuses — and what it cannot refuse, it says. The boundaries read the
10
10
  > contracts a module uses, a contract version someone relies on changes only with a proof that
11
11
  > runs in the base's tree, the module checks and stale approvals are enforced, a verifier is not
@@ -13,8 +13,10 @@ Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
13
13
  > holding its template's words is refused, a deprecated module's consumers are named with their
14
14
  > removal date, and a contract change runs the checks of both sides — the producer and every
15
15
  > declared consumer. The diagnosis says whether anything refuses at all; a record names what
16
- > reached the default branch outside a pull request, and never refuses it. A first pilot
17
- > project, private, starts from here.
16
+ > reached the default branch outside a pull request, and never refuses it. Since v0.6.2, each
17
+ > version's notes are published, engine and project apart, and an update names every version it
18
+ > crosses. A first real project, public, has run two cycles on it; **M11**, in progress, makes
19
+ > the framework charge what a failure costs, and nothing more ([PDR-0011](docs/pdr/0011-ceremony-follows-consequence.md)).
18
20
  > Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
19
21
  > [move to English](docs/governance/plans/2026-09-16-english-migration.md),
20
22
  > [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
@@ -22,6 +24,7 @@ Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
22
24
  > [a proof that cannot be rewritten](docs/governance/plans/2026-09-20-v0.5.0-a-proof-that-cannot-be-rewritten.md),
23
25
  > [the conformance suite](docs/governance/plans/2026-09-20-m12-conformance-suite.md),
24
26
  > [what is announced refuses](docs/governance/plans/2026-09-20-v0.6.0-what-is-announced-refuses.md),
27
+ > [ceremony follows consequence](docs/governance/plans/2026-09-23-m11-ceremony-follows-consequence.md),
25
28
  > [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
26
29
 
27
30
  ## A project's journey
@@ -47,6 +50,14 @@ flowchart LR
47
50
  The project owns its skeleton and adapts it freely. Every new version reaches it on
48
51
  demand, merged with its adaptations; the conflicts are left to the team.
49
52
 
53
+ Five files are never merged again, because their content is the product's: the front page
54
+ `README.md`, the security policy, the module map and the two decision indexes. `nstack init`
55
+ writes them as a form — headings and the place of a diagram — and each is asked for when its
56
+ moment comes, never before: the sentence at the discovery's go, the page at the charter's
57
+ acceptance, the map at a module's first code. The foundation's manual sits one click away,
58
+ in `.nstack/handbook/INSTALL.md`
59
+ ([PDR-0010](docs/pdr/0010-the-framework-imposes-the-form.md)).
60
+
50
61
  ```mermaid
51
62
  flowchart LR
52
63
  V1["Skeleton v0.1<br/>common base"]:::ref --> F{"Three-way<br/>merge"}
@@ -76,8 +87,8 @@ To try it without installing anything, or to install it from this repository ins
76
87
  registry — always pinned to a release tag:
77
88
 
78
89
  ```bash
79
- uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.5.0" nstack init my-project
80
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.5.0"
90
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.6.2" nstack init my-project
91
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.6.2"
81
92
  ```
82
93
 
83
94
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -140,8 +151,8 @@ generation; dotted: a versioned dependency.
140
151
 
141
152
  | Path | Role |
142
153
  |---|---|
143
- | `skeleton/` | What every project receives: kernel, playbooks, handbook, CI, hooks |
144
- | `copier.yml` | The questions asked at creation (a Copier template, ADR-0001) |
154
+ | `skeleton/` | What every project receives: kernel, playbooks, handbook and its manual, CI, hooks — and five forms the project writes |
155
+ | `copier.yml` | The questions asked at creation, and the files no update touches again (a Copier template, ADR-0001) |
145
156
  | `src/napkinstack/` | The engine, the `nstack` command |
146
157
  | `platform/` | The engine module's envelope: manifest, runbook, tests |
147
158
  | `PRODUCT.md`, `docs/governance/` | The working context for NapkinStack itself |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.6.2"
3
+ version = "0.7.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.6.2"
3
+ version = "0.7.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"
@@ -0,0 +1,74 @@
1
+ """
2
+ The machine half of the assurance matrix (`.nstack/handbook/05-workflow.md` §7).
3
+
4
+ `assurance` here is the word of DO-178C's *Design Assurance Level*, of ISO 26262, of the
5
+ OpenSSF Baseline's "advanced high assurance", and of plain *quality assurance*: how much
6
+ verification the cost of a failure buys. It is not `insurance`, and it is not the French
7
+ word it is spelled like.
8
+
9
+ That table is the single source of what a criticality value requires (D67). Its rows are marked
10
+ *machine* or *owed*: the machine ones are here, the owed ones belong to a human and this module
11
+ does not pretend to know them.
12
+
13
+ Before it, one threshold lived in four places — `pull_request.SHEET_CRITICALITIES`,
14
+ `modules.NEEDS_RUNBOOK`, `modules.NEEDS_SHEET` and a literal inside M8 — which is how a
15
+ framework comes to describe one fact three times in its documents and spend it as one in its
16
+ code. A rule that reads the matrix here cannot drift from the table that announces it;
17
+ `platform/tests/test_assurance_contract.py` guards the other direction.
18
+
19
+ One row of the table is deliberately absent: **e2e**. A check can read that a module
20
+ declares an `e2e` verb; it cannot read that the verb runs end-to-end scenarios rather than
21
+ the unit suite under another name. This repository's own `platform` module declares its
22
+ whole oracle as `test`, so the rule would have asked it to alias one command as two — and a
23
+ rule a one-line alias satisfies teaches the alias (P6). The row is owed to a human.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ # Ascending, so that "the value below" is the previous one. M3 validates a manifest against it.
29
+ CRITICALITIES = ("prototype", "standard", "high", "critical")
30
+
31
+ # What each value requires, machine rows only:
32
+ # sheet a test sheet run by a verifier who is not an author (T1)
33
+ # runbook a runbook declared in the manifest and present (M8)
34
+ # rerun_at_head every scenario re-run at the head; no confirmation stands in for a
35
+ # re-run, which is what the top value costs (T4)
36
+ # `"user_facing"` means: required only when the module declares a user-visible surface (M10).
37
+ MATRIX: dict[str, dict[str, bool | str]] = {
38
+ "prototype": {"sheet": False, "runbook": False, "rerun_at_head": False},
39
+ "standard": {"sheet": "user_facing", "runbook": False, "rerun_at_head": False},
40
+ "high": {"sheet": True, "runbook": True, "rerun_at_head": False},
41
+ "critical": {"sheet": True, "runbook": True, "rerun_at_head": True},
42
+ }
43
+
44
+
45
+ def requires(criticality: str | None, row: str, user_facing: bool = False) -> bool:
46
+ """Does `criticality` require `row`? An unknown value reads as the strictest: M3 refuses it
47
+ where a manifest is judged, and until a reader fixes that the safe reading is the expensive
48
+ one — never the cheap one."""
49
+ rule = MATRIX.get(criticality or "", MATRIX["critical"])[row]
50
+ return bool(user_facing) if rule == "user_facing" else bool(rule)
51
+
52
+
53
+ def below(criticality: str | None) -> str | None:
54
+ """The value one step down, or None at the bottom and for a value the engine does not know."""
55
+ if criticality not in CRITICALITIES:
56
+ return None
57
+ index = CRITICALITIES.index(criticality)
58
+ return CRITICALITIES[index - 1] if index else None
59
+
60
+
61
+ def relief(criticality: str | None, row: str, user_facing: bool = False) -> str:
62
+ """What the value below would have required instead, as half a sentence, or "" when there is
63
+ nothing below or nothing would change.
64
+
65
+ P6 asks a refusal to name an action. On a threshold the action is not always "do the work":
66
+ it can be "this module is not what you declared it to be". Saying what the neighbouring
67
+ value costs lets a reader judge the declaration instead of only obeying it — which is the
68
+ decision an agent took alone when nothing told it where to stop (D70)."""
69
+ lower = below(criticality)
70
+ if lower is None or requires(lower, row, user_facing):
71
+ return ""
72
+ if MATRIX[lower][row] == "user_facing":
73
+ return f"at {lower} it would be required only for a module a user sees"
74
+ return f"at {lower} it would not be required"
@@ -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, landed, modules, provenance,
9
+ from napkinstack import assurance
10
+ from napkinstack import (__version__, compat, discovery, doctor, landed, modules, provenance, settings,
10
11
  pull_request, skills)
11
12
  from napkinstack.fitness import boundaries, hygiene, manifests, plan, pr_scope
12
13
 
@@ -65,7 +66,7 @@ def build_parser() -> argparse.ArgumentParser:
65
66
  lambda a: manifests.run(a.root))
66
67
  _add(sub, "boundaries", "declared graph against real graph (B1-B8)",
67
68
  lambda a: boundaries.run(a.root))
68
- _add(sub, "plan", "the discovery, the charter and the cycles (C1-C7)", lambda a: plan.run(a.root))
69
+ _add(sub, "plan", "the discovery, the charter and the cycles (C1-C8)", lambda a: plan.run(a.root))
69
70
  sk = _add(sub, "skills", "generates or checks the skills (S1-S4)",
70
71
  lambda a: skills.run(a.root, check_only=a.check))
71
72
  sk.add_argument("--check", action="store_true", help="check without writing")
@@ -73,16 +74,24 @@ def build_parser() -> argparse.ArgumentParser:
73
74
  lambda a: hygiene.run(a.root))
74
75
  _add(sub, "fitness", "manifests + boundaries + skills + plan + hygiene",
75
76
  lambda a: _fitness(a.root))
76
- _add(sub, "doctor", "diagnoses the workstation and the GitHub settings, read-only (PDR-0001)",
77
+ _add(sub, "doctor", "diagnoses the workstation and the GitHub settings, read-only",
77
78
  lambda a: doctor.run(a.root))
79
+ _add(sub, "settings", "prints the calls that would bring the GitHub settings to the checklist; changes nothing",
80
+ lambda a: settings.run(a.root))
78
81
  nm = _add(sub, "new-module", "creates a module and its guardrails, with no imposed stack",
79
82
  lambda a: modules.create(a.root, a.name, a.owner, a.criticality, a.user_facing))
80
83
  nm.add_argument("name", help="module name, kebab-case")
81
84
  nm.add_argument("owner", help="GitHub team, organisation/team, or a user when the project has "
82
85
  "no organisation")
83
- nm.add_argument("criticality", choices=["prototype", "standard", "high", "critical"])
86
+ nm.add_argument("criticality", choices=list(assurance.CRITICALITIES),
87
+ help="what a failure of this module costs — prototype: nothing, it is "
88
+ "thrown away and nothing depends on it · standard: real code, no "
89
+ "money and no personal data · high: a day of work · critical: a "
90
+ "key, an order, money, or someone's personal data. The decider "
91
+ "settles it (.nstack/handbook/05-workflow.md §7)")
84
92
  nm.add_argument("--user-facing", action="store_true",
85
- help="a user sees this module: its pull requests carry a test sheet")
93
+ help="a user sees this module: from standard up, its pull requests carry a "
94
+ "test sheet (.nstack/handbook/05-workflow.md §7)")
86
95
  for verb, help_text in (("bootstrap", "prepares one module, or all of them (commands.bootstrap)"),
87
96
  ("check", "format, lint, types of one module, or all (commands.check)"),
88
97
  ("test", "tests of one module, or of all of them (commands.test)"),
@@ -105,6 +114,10 @@ def build_parser() -> argparse.ArgumentParser:
105
114
  ps = _add(sub, "pr-scope", "one PR = one module, review budget (P1-P2)",
106
115
  lambda a: pr_scope.run(a.root, a.base))
107
116
  ps.add_argument("--base", default="origin/main")
117
+ nc = _add(sub, "new-contract", "creates a contract — and, the first time, the module that holds them all",
118
+ lambda a: modules.create_contract(a.root, a.name, a.owner))
119
+ nc.add_argument("name", help="the contract's name, kebab-case")
120
+ nc.add_argument("--owner", help="owner of the contracts module (default: the foundation's owner)")
108
121
  pc = _add(sub, "pr-check", "test sheet and cycle, read from the pull request description (T1-T5, K1-K4)",
109
122
  lambda a: pull_request.run(a.root, a.base, a.body_file))
110
123
  pc.add_argument("--base", default="origin/main")
@@ -113,10 +126,10 @@ def build_parser() -> argparse.ArgumentParser:
113
126
  lambda a: landed.run(a.root, a.span, landed.from_environment(a.root)))
114
127
  ld.add_argument("--span", default="HEAD~1..HEAD",
115
128
  help="the commits to read, BEFORE..AFTER (default: the last commit)")
116
- ds = _add(sub, "discover", "starts a discovery from an idea file, for the team's agent (PDR-0002)",
129
+ ds = _add(sub, "discover", "starts a discovery from an idea file, for the team's agent",
117
130
  lambda a: discovery.run(a.root, a.idea))
118
131
  ds.add_argument("idea", type=Path, help="the idea, a .md or .txt file")
119
- ini = sub.add_parser("init", help="creates a project from the skeleton (PDR-0001)")
132
+ ini = sub.add_parser("init", help="creates a project from the skeleton")
120
133
  ini.add_argument("destination", type=Path, help="project folder, missing or empty")
121
134
  ini.add_argument("--project-name", help="project name (asked when absent)")
122
135
  ini.add_argument("--github-repo", help="GitHub repository, organisation/name (asked when absent)")
@@ -125,13 +138,13 @@ def build_parser() -> argparse.ArgumentParser:
125
138
  ini.add_argument("--source", help="template: URL or path (default: the NapkinStack repository)")
126
139
  ini.add_argument("--ref", help="skeleton version, tag vX.Y.Z (default: the one of nstack)")
127
140
  ini.set_defaults(func=_init)
128
- up = _add(sub, "update", "merges a NapkinStack version onto a branch to review (PDR-0001)",
141
+ up = _add(sub, "update", "merges a NapkinStack version onto a branch to review",
129
142
  _update)
130
143
  up.add_argument("--ref", help="target version, tag vX.Y.Z (default: the one of nstack)")
131
144
  return parser
132
145
 
133
146
 
134
- JUDGES = {"manifests", "boundaries", "plan", "skills", "hygiene", "fitness", "doctor", "pr-scope",
147
+ JUDGES = {"manifests", "boundaries", "plan", "skills", "hygiene", "fitness", "doctor", "settings", "pr-scope",
135
148
  "pr-check", "compat", "landed"} # the commands whose verdict depends on the framework's rules
136
149
 
137
150
 
@@ -139,4 +152,6 @@ def main(argv: list[str] | None = None) -> int:
139
152
  args = build_parser().parse_args(argv)
140
153
  if args.command in JUDGES:
141
154
  print(provenance.judged_by(args.root), flush=True)
155
+ if said := plan.stage_line(args.root):
156
+ print(said, flush=True)
142
157
  return args.func(args)
@@ -1,6 +1,6 @@
1
1
  """
2
2
  Contract versions (D30): a merged version that someone relies on changes only with a proof
3
- that the change is additive (docs/os/03-contracts.md §3 and §4).
3
+ that the change is additive (.nstack/handbook/03-contracts.md §3 and §4).
4
4
 
5
5
  Rule:
6
6
  V1 a frozen contract version changes only when the compatibility command of the module
@@ -15,7 +15,7 @@ those under provides[].path; a new version beside it changes nothing merged. A v
15
15
  provided at the head is judged wherever it now lives: moving it is no removal (D36).
16
16
 
17
17
  The command is the project's (P1: no format assumed): an OpenAPI, protobuf or JSON Schema
18
- comparator, named in docs/tooling-profile.md. It runs from the holding module's folder with
18
+ comparator, named in .nstack/tooling-profile.md. It runs from the holding module's folder with
19
19
  NSTACK_CONTRACT the contract's name catalog-api
20
20
  NSTACK_VERSION the version v1
21
21
  NSTACK_BASE_PATH the version as merged, extracted to a temporary folder
@@ -133,7 +133,7 @@ def run(root: Path, module: str | None, base: str) -> int:
133
133
  if holder is None:
134
134
  failures.append(f"[V1] {label} changed at '{target}', which no module holds: nothing declares "
135
135
  "how its versions are compared.\n Action: keep contracts under contracts/ "
136
- "(docs/os/03-contracts.md §2).")
136
+ "(.nstack/handbook/03-contracts.md §2).")
137
137
  continue
138
138
  if module is not None and holder[0] != module:
139
139
  continue
@@ -151,8 +151,8 @@ def run(root: Path, module: str | None, base: str) -> int:
151
151
  if not command:
152
152
  failures.append(f"[V1] {label} changed, and no merged compatibility command proves the "
153
153
  f"change compatible.\n Action: declare commands.compat in {where} in a "
154
- "pull request of its own (docs/tooling-profile.md), or publish the change as "
155
- "a new version beside it (docs/os/03-contracts.md §4).")
154
+ "pull request of its own (.nstack/tooling-profile.md), or publish the change as "
155
+ "a new version beside it (.nstack/handbook/03-contracts.md §4).")
156
156
  continue
157
157
  with tempfile.TemporaryDirectory() as temporary:
158
158
  tree = _base_tree(root, fork, Path(temporary))
@@ -161,7 +161,7 @@ def run(root: Path, module: str | None, base: str) -> int:
161
161
  failures.append(f"[V1] {label}: module '{name}' has no folder at the base, so its "
162
162
  f"compatibility command cannot be run as merged.\n Action: "
163
163
  "declare the command where the module now lives, in a pull request "
164
- "of its own (docs/os/03-contracts.md §4).")
164
+ "of its own (.nstack/handbook/03-contracts.md §4).")
165
165
  continue
166
166
  env = {**os.environ, "NSTACK_CONTRACT": contract, "NSTACK_VERSION": version,
167
167
  "NSTACK_BASE_PATH": str(tree / path), "NSTACK_HEAD_PATH": str(root / target)}
@@ -172,7 +172,7 @@ def run(root: Path, module: str | None, base: str) -> int:
172
172
  f"did not prove the change compatible (exit {code}).\n"
173
173
  " Action: read the comparator's output above. If the change is "
174
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 "
175
+ "consumers (expand/contract, .nstack/handbook/03-contracts.md §4). If the command "
176
176
  "could not run — a missing tool, no network — fix the command: V1 cannot "
177
177
  "pass without a proof.")
178
178
  else:
@@ -3,7 +3,7 @@ nstack discover — starts a project's discovery from an idea file (PDR-0002, ex
3
3
 
4
4
  Deterministic, no model called: the idea is kept in docs/project/inputs/, the discovery
5
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).
6
+ printed. The conversation itself happens in the agent (.nstack/playbooks/discovery.md).
7
7
 
8
8
  Usage : nstack discover <idea-file> [--root ROOT]
9
9
  Output: 0 when the discovery is started, 1 otherwise.
@@ -17,7 +17,7 @@ from pathlib import Path
17
17
  from napkinstack.fitness.plan import DISCOVERY, PROJECT
18
18
 
19
19
  INPUTS = PROJECT / "inputs"
20
- TEMPLATE = PROJECT / "_DISCOVERY_TEMPLATE.md"
20
+ TEMPLATE = Path(".nstack") / "forms" / "discovery.md" # the framework's forms live in its one folder (PDR-0012)
21
21
  SUFFIXES = {".md", ".txt"}
22
22
 
23
23
 
@@ -33,7 +33,7 @@ def run(root: Path, idea: Path) -> int:
33
33
  return 1
34
34
  if (root / DISCOVERY).exists():
35
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.")
36
+ "continue it in your agent (.nstack/playbooks/discovery.md), as a new round of the same document.")
37
37
  return 1
38
38
  kept = root / INPUTS / idea.name
39
39
  if kept.exists() and kept.resolve() != idea.resolve():
@@ -47,7 +47,7 @@ def run(root: Path, idea: Path) -> int:
47
47
  (root / DISCOVERY).write_text(template.replace("<idea file>", source), encoding="utf-8")
48
48
  print(f"Discovery started: {source} kept, {DISCOVERY} created.")
49
49
  print("\nNext, in your agent:")
50
- print(f" Follow playbooks/discovery.md on {source}: interview me, one question at a time.")
50
+ print(f" Follow .nstack/playbooks/discovery.md on {source}: interview me, one question at a time.")
51
51
  print("Then, in another session, have the document challenged (stage 5); the decider decides: "
52
52
  "go, clarify or kill.")
53
53
  return 0
@@ -3,7 +3,7 @@ Read-only diagnosis of a project: the workstation and the GitHub settings (PDR-0
3
3
 
4
4
  Workflows inform; it is the GitHub settings that block, and they are not copied with the
5
5
  project. CHECKLIST is printed by nstack init, checked here, and repeated word for word in
6
- the skeleton README (a test verifies it).
6
+ the foundation manual, skeleton/.nstack/handbook/INSTALL.md.jinja (a test verifies it).
7
7
 
8
8
  GitHub is read with a token supplied by a human (GH_TOKEN, otherwise GITHUB_TOKEN):
9
9
  fine-grained, limited to the repository, Administration: read permission. Without a token,
@@ -14,15 +14,21 @@ Rules:
14
14
  published: an unpublished framework is a gap, never compliance (PDR-0005)
15
15
  L2 git and pre-commit available
16
16
  L3 pre-commit hooks installed
17
- L4 PRODUCT.md absent: that is NapkinStack's own development context (R6)
18
- L5 README personalised: the presentation sentence is written
17
+ L4 retired (D79): it refused a file named PRODUCT.md, which a product may write. The
18
+ number is not reused.
19
+ L5 retired (PDR-0010): it reported the front page's unwritten sentence from a project's
20
+ first minute. The plan check asks for it at the discovery's go (C8). Not reused.
19
21
  L6 CODEOWNERS starts with a default owner: the code owner review covers every path
20
22
  L7 the template source reachable by anyone: a repository, not a path on one machine
21
- G1-G13 the GitHub settings of CHECKLIST; G6 is not applicable outside a public
22
- repository, and on a private one G1-G5, G12 and G13 name the GitHub plan or option required
23
+ G1-G18 the GitHub settings of CHECKLIST; G6 is not applicable outside a public
24
+ repository, and on a private one G1-G5, G12, G13, G17 and G18 name the GitHub plan
25
+ or option required
23
26
 
24
27
  Usage : nstack doctor [--root ROOT]
25
- Output: 0 when everything is verified and compliant, 1 otherwise.
28
+ Output: 0 when everything is verified and compliant; 1 when a setting is missing — a gap;
29
+ 2 when nothing read is missing and something could not be read — not verified
30
+ (D77). The two are different things to whoever reads the code: one asks for a
31
+ change, the other for a token.
26
32
  """
27
33
 
28
34
  from __future__ import annotations
@@ -41,6 +47,7 @@ from pathlib import Path
41
47
  import yaml
42
48
 
43
49
  from napkinstack import __version__, provenance
50
+ from napkinstack.fitness import plan
44
51
  from napkinstack.project import ANSWERS
45
52
 
46
53
  OK, GAP, UNKNOWN, NOT_APPLICABLE = "OK", "FAIL", "NOT VERIFIED", "NOT APPLICABLE"
@@ -49,7 +56,6 @@ OUT_OF_REACH = "OUT OF REACH" # the plan forbids it: a state, not a fault (
49
56
  # all in force; the others matter and none of them stops a merge.
50
57
  BLOCKING = ("G1", "G2", "G3", "G4", "G12", "G13")
51
58
  API_VERSION = "2026-03-10"
52
- PLACEHOLDER = "<One sentence: what this project does.>"
53
59
  JOBS = ("Fitness functions", "PR scope and review budget", "Hooks and secrets", "Test sheet and cycle",
54
60
  "Module checks")
55
61
  THIRD_PARTY_ACTIONS = ("astral-sh/setup-uv",) # non-GitHub actions of the skeleton workflows
@@ -60,6 +66,7 @@ REMOTE = re.compile(r"https://|ssh://|git@[^:/]+:|gh:|gl:") # as .nstack/instal
60
66
  RULESET = "Settings → Rules → Rulesets, main branch"
61
67
  SECURITY = "Settings → Advanced Security"
62
68
  ACTIONS = "Settings → Actions → General"
69
+ MERGES = "Settings → General → Pull Requests"
63
70
  CODEOWNERS = Path(".github") / "CODEOWNERS"
64
71
 
65
72
  CHECKLIST = [ # (rule, setting, action)
@@ -87,13 +94,25 @@ CHECKLIST = [ # (rule, setting, action)
87
94
  f"{RULESET}: remove every bypass actor"),
88
95
  ("G13", "Stale approvals dismissed when new commits are pushed",
89
96
  f"{RULESET}: dismiss stale pull request approvals when new commits are pushed"),
97
+ # G14 to G16 shape the merge; they never refuse one, so they are not in BLOCKING. Like G11,
98
+ # they need neither the Administration permission nor a paid plan (D61).
99
+ ("G14", "Auto-merge available: a pull request armed for it merges itself once approved and green",
100
+ f"{MERGES}: allow auto-merge"),
101
+ ("G15", "Squash the only merge method: one pull request, one commit on main",
102
+ f"{MERGES}: allow squash merging, and turn off merge commits and rebase merging"),
103
+ ("G16", "Head branch deleted on merge: a merged branch is history, not a place to work",
104
+ f"{MERGES}: automatically delete head branches"),
105
+ # G17 and G18 protect the branch itself (D78): the rules above say how a change reaches
106
+ # main, and said nothing of main being deleted or rewritten under them.
107
+ ("G17", "Main branch cannot be deleted", f"{RULESET}: restrict deletions"),
108
+ ("G18", "Main branch history cannot be rewritten: no force push", f"{RULESET}: block force pushes"),
90
109
  ]
91
110
 
92
111
  # Settings specific to public repositories, and settings a private one pays for
93
112
  # (GitHub documentation, 2026-09-15).
94
113
  PUBLIC_ONLY = {"G6": "private vulnerability reporting only exists for a public repository; "
95
114
  "state an internal channel in SECURITY.md"}
96
- PRIVATE_PLAN = dict.fromkeys(("G1", "G2", "G3", "G4", "G12", "G13"),
115
+ PRIVATE_PLAN = dict.fromkeys(("G1", "G2", "G3", "G4", "G12", "G13", "G17", "G18"),
97
116
  "Private repository: rulesets require the GitHub Team plan (organisation) "
98
117
  "or Pro (personal account); without it, nothing blocks the merge.")
99
118
  PRIVATE_PLAN["G5"] = ("Private repository: Secret Protection is a paid option; without it, only the "
@@ -189,6 +208,33 @@ def _no_bypass(client: GitHub) -> bool:
189
208
  return True
190
209
 
191
210
 
211
+ MERGE_SETTINGS = ("allow_auto_merge", "allow_squash_merge", "allow_merge_commit",
212
+ "allow_rebase_merge", "delete_branch_on_merge")
213
+
214
+
215
+ def _merges(client: GitHub, *keys: str) -> dict:
216
+ """G14 to G16 read the repository object, which carries these fields in every answer and
217
+ asks for no Administration permission (D61). Absent means a degraded read, never a setting
218
+ that is off: the difference between "not verified" and "missing" is the whole point of this
219
+ command (PDR-0005). Each rule names the fields it reads, and one of them absent is enough."""
220
+ repo = client.get("")
221
+ absent = [key for key in keys or MERGE_SETTINGS if key not in repo]
222
+ if absent:
223
+ raise NotVerified(f"{', '.join(absent)} not visible in the repository's own object")
224
+ return repo
225
+
226
+
227
+ def _squash_only(client: GitHub) -> bool:
228
+ """G15: squash alone, so that a workstream reaches main as one commit. A merge commit puts
229
+ the branch's commits on main beside a merge of its own; a rebase puts them there without
230
+ one. Measured on two repositories before the rule existed: 22 branches for 23 merged pull
231
+ requests, and four commits on main for two workstreams (D61)."""
232
+ repo = _merges(client, "allow_squash_merge", "allow_merge_commit", "allow_rebase_merge")
233
+ return (repo.get("allow_squash_merge") is True
234
+ and repo.get("allow_merge_commit") is False
235
+ and repo.get("allow_rebase_merge") is False)
236
+
237
+
192
238
  CHECKS: dict[str, Callable[[GitHub], bool]] = {
193
239
  "G1": lambda c: _rule(c, "pull_request") is not None,
194
240
  "G2": lambda c: _parameters(c, "pull_request").get("required_approving_review_count", 0) >= 1,
@@ -205,6 +251,11 @@ CHECKS: dict[str, Callable[[GitHub], bool]] = {
205
251
  "G11": lambda c: all(c.get(f"/labels/{label}", missing=True) is not None for label in LABELS),
206
252
  "G12": _no_bypass,
207
253
  "G13": lambda c: _parameters(c, "pull_request").get("dismiss_stale_reviews_on_push") is True,
254
+ "G14": lambda c: _merges(c, "allow_auto_merge").get("allow_auto_merge") is True,
255
+ "G15": _squash_only,
256
+ "G16": lambda c: _merges(c, "delete_branch_on_merge").get("delete_branch_on_merge") is True,
257
+ "G17": lambda c: _rule(c, "deletion") is not None,
258
+ "G18": lambda c: _rule(c, "non_fast_forward") is not None,
208
259
  }
209
260
 
210
261
 
@@ -218,7 +269,7 @@ def _default_owner(root: Path) -> tuple[str, str]:
218
269
  if rules and rules[0][0] == "*" and len(rules[0]) > 1:
219
270
  return OK, ""
220
271
  return GAP, (f"The first rule of {CODEOWNERS} is not a default owner.\nAction: make `* @<owner>` "
221
- "its first rule: the code owner review then covers every path (ADR-0004).")
272
+ "its first rule: the code owner review then covers every path (.nstack/handbook/07-governance.md §7).")
222
273
 
223
274
 
224
275
  def _workstation(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
@@ -230,12 +281,13 @@ def _workstation(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
230
281
  _, origin = provenance.engine()
231
282
  if not PUBLISHED.fullmatch(commit):
232
283
  l1 = (GAP, f"The project is pinned to an unpublished framework ({commit or 'unknown'}): its "
233
- "verdicts come from rules nobody has published (PDR-0005).\nAction: once the change it "
284
+ "verdicts come from rules nobody has published.\nAction: once the change it "
234
285
  "waits for is released, nstack update --ref vX.Y.Z.")
235
286
  elif origin is not None:
236
- l1 = (GAP, f"The nstack running is unpublished ({origin}) (PDR-0005).\nAction: {install}")
287
+ l1 = (GAP, f"The nstack running is unpublished ({origin}).\nAction: {install}")
237
288
  elif project != __version__:
238
- l1 = (GAP, f"nstack {__version__} installed, project on {project} (PDR-0001 R3).\nAction: {install}")
289
+ l1 = (GAP, f"nstack {__version__} installed, project on {project}: the engine and the project "
290
+ f"move together.\nAction: {install}")
239
291
  else:
240
292
  l1 = (OK, "")
241
293
  results.append(("L1", "nstack at the project version", *l1))
@@ -256,24 +308,13 @@ def _workstation(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
256
308
  l3 = (OK, "") if installed else (GAP, "Action: pre-commit install")
257
309
  results.append(("L3", "pre-commit hooks installed", *l3))
258
310
 
259
- product = (root / "PRODUCT.md").exists()
260
- results.append(("L4", "PRODUCT.md absent", GAP if product else OK,
261
- "PRODUCT.md describes the development of NapkinStack itself (PDR-0001 R6).\n"
262
- "Action: delete it." if product else ""))
263
-
264
- readme = root / "README.md"
265
- untouched = readme.is_file() and PLACEHOLDER in readme.read_text(encoding="utf-8", errors="replace")
266
- results.append(("L5", "README personalised", GAP if untouched else OK,
267
- f'README.md still contains "{PLACEHOLDER}".\n'
268
- "Action: write the sentence that presents the project." if untouched else ""))
269
-
270
311
  results.append(("L6", "CODEOWNERS starts with a default owner", *_default_owner(root)))
271
312
 
272
313
  source = str(answers.get("_src_path") or "")
273
314
  local = not REMOTE.match(source)
274
315
  results.append(("L7", "Template source reachable by anyone", GAP if local else OK,
275
316
  f"The project was generated from '{source}', a path on one machine: nobody else can "
276
- "update it (PDR-0005).\nAction: nstack update, from the published source." if local else ""))
317
+ "update it.\nAction: nstack update, from the published source." if local else ""))
277
318
  return results
278
319
 
279
320
 
@@ -333,6 +374,15 @@ def run(root: Path) -> int:
333
374
  for result in results:
334
375
  _display(*result)
335
376
 
377
+ if plan.stage(root) == plan.UNFRAMED:
378
+ # Said here and nowhere else, once: every other run names the stage in one line and
379
+ # leaves it there. A stage is not a gap, and nothing below counts it as one.
380
+ print("\nProject\n UNFRAMED no accepted charter — a stage, not a gap\n"
381
+ " Framing buys an appetite, an end date, a circuit breaker and a decider.\n"
382
+ " It is worth its price when there is something to bound: several people,\n"
383
+ " a deadline, a decider who is not the one typing.\n"
384
+ " To start: follow .nstack/playbooks/framing.md with your agent.")
385
+
336
386
  token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
337
387
  print(f"\nGitHub: {answers.get('github_repo')}")
338
388
  if token:
@@ -373,4 +423,6 @@ def run(root: Path) -> int:
373
423
  return 0
374
424
  print(f"\nnstack doctor: {gaps} gap(s), {unknown} not verified{suffix}.\nWorkflows inform; "
375
425
  "it is the GitHub settings that block, and they are not copied with the project.")
376
- return 1
426
+ if gaps:
427
+ print("What would close the gaps, as calls to run yourself: nstack settings")
428
+ return 1 if gaps else 2