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.
- {napkinstack-0.6.2 → napkinstack-0.7.0}/PKG-INFO +19 -8
- {napkinstack-0.6.2 → napkinstack-0.7.0}/README.md +18 -7
- {napkinstack-0.6.2 → napkinstack-0.7.0}/pyproject.toml +1 -1
- {napkinstack-0.6.2 → napkinstack-0.7.0}/pyproject.toml.orig +1 -1
- napkinstack-0.7.0/src/napkinstack/assurance.py +74 -0
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/cli.py +24 -9
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/compat.py +7 -7
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/discovery.py +4 -4
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/doctor.py +77 -25
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/fitness/boundaries.py +7 -7
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/fitness/manifests.py +34 -14
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/fitness/plan.py +104 -9
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/fitness/pr_scope.py +5 -5
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/modules.py +123 -13
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/project.py +204 -7
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/provenance.py +1 -1
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/pull_request.py +73 -27
- napkinstack-0.7.0/src/napkinstack/settings.py +247 -0
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/skills.py +6 -6
- napkinstack-0.7.0/src/napkinstack/templates/contracts/AGENTS.md +26 -0
- napkinstack-0.7.0/src/napkinstack/templates/contracts/MANIFEST.yaml +29 -0
- napkinstack-0.7.0/src/napkinstack/templates/contracts/README.md +8 -0
- napkinstack-0.7.0/src/napkinstack/templates/contracts/docs/runbook.md +15 -0
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/templates/module/AGENTS.md +1 -1
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/templates/module/MANIFEST.yaml +9 -6
- napkinstack-0.7.0/src/napkinstack/templates/module/README.md +24 -0
- napkinstack-0.7.0/src/napkinstack/templates/module/tests/.gitkeep +0 -0
- napkinstack-0.6.2/src/napkinstack/templates/module/README.md +0 -27
- {napkinstack-0.6.2 → napkinstack-0.7.0}/LICENSE +0 -0
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/__init__.py +0 -0
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/fitness/__init__.py +0 -0
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/fitness/hygiene.py +0 -0
- {napkinstack-0.6.2 → napkinstack-0.7.0}/src/napkinstack/landed.py +0 -0
- {napkinstack-0.6.2/src/napkinstack/templates/module/docs/adr → napkinstack-0.7.0/src/napkinstack/templates/contracts/tests}/.gitkeep +0 -0
- {napkinstack-0.6.2/src/napkinstack/templates/module/src → napkinstack-0.7.0/src/napkinstack/templates/module/docs/adr}/.gitkeep +0 -0
- {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.
|
|
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.
|
|
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.
|
|
32
|
-
>
|
|
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.
|
|
95
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
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.
|
|
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.
|
|
17
|
-
>
|
|
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.
|
|
80
|
-
uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.
|
|
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 |
|
|
@@ -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
|
|
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-
|
|
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
|
|
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=
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
"(
|
|
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 (
|
|
155
|
-
"a new version beside it (
|
|
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 (
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|
18
|
-
|
|
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-
|
|
22
|
-
repository, and on a private one G1-G5, G12 and
|
|
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
|
|
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 (
|
|
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
|
|
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})
|
|
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}
|
|
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
|
|
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
|
-
|
|
426
|
+
if gaps:
|
|
427
|
+
print("What would close the gaps, as calls to run yourself: nstack settings")
|
|
428
|
+
return 1 if gaps else 2
|