napkinstack 0.9.6__tar.gz → 0.10.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 (35) hide show
  1. {napkinstack-0.9.6 → napkinstack-0.10.0}/PKG-INFO +5 -4
  2. {napkinstack-0.9.6 → napkinstack-0.10.0}/README.md +4 -3
  3. {napkinstack-0.9.6 → napkinstack-0.10.0}/pyproject.toml +1 -1
  4. {napkinstack-0.9.6 → napkinstack-0.10.0}/pyproject.toml.orig +1 -1
  5. napkinstack-0.10.0/src/napkinstack/attest.py +207 -0
  6. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/cli.py +19 -4
  7. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/doctor.py +5 -1
  8. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/project.py +1 -0
  9. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/pull_request.py +43 -0
  10. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/settings.py +10 -2
  11. {napkinstack-0.9.6 → napkinstack-0.10.0}/LICENSE +0 -0
  12. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/__init__.py +0 -0
  13. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/assurance.py +0 -0
  14. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/compat.py +0 -0
  15. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/discovery.py +0 -0
  16. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/fitness/__init__.py +0 -0
  17. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/fitness/boundaries.py +0 -0
  18. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/fitness/hygiene.py +0 -0
  19. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/fitness/manifests.py +0 -0
  20. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/fitness/plan.py +0 -0
  21. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/fitness/pr_scope.py +0 -0
  22. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/landed.py +0 -0
  23. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/modules.py +0 -0
  24. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/provenance.py +0 -0
  25. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/skills.py +0 -0
  26. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
  27. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
  28. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/contracts/README.md +0 -0
  29. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
  30. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
  31. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
  32. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -0
  33. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/module/README.md +0 -0
  34. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  35. {napkinstack-0.9.6 → napkinstack-0.10.0}/src/napkinstack/templates/module/tests/.gitkeep +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: napkinstack
3
- Version: 0.9.6
3
+ Version: 0.10.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.9.6, published** on [PyPI](https://pypi.org/project/napkinstack/).
23
+ > **Status: v0.10.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
24
24
  > What each version changes, engine and project apart: [`CHANGELOG.md`](CHANGELOG.md).
25
25
  > What is open: [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
26
26
 
@@ -84,8 +84,8 @@ To try it without installing anything, or to install it from this repository ins
84
84
  registry — always pinned to a release tag:
85
85
 
86
86
  ```bash
87
- uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.9.6" nstack init my-project
88
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.9.6"
87
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.10.0" nstack init my-project
88
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.10.0"
89
89
  ```
90
90
 
91
91
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -120,6 +120,7 @@ Each project then pins its version and changes it through `nstack update`.
120
120
  | `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
121
  | `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
122
122
  | `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` |
123
124
  | `nstack skills` | Exposes the playbooks as skills for the agent |
124
125
  | `nstack update` | Lays the new version on a branch to review |
125
126
 
@@ -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.9.6, published** on [PyPI](https://pypi.org/project/napkinstack/).
8
+ > **Status: v0.10.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.9.6" nstack init my-project
73
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.9.6"
72
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.10.0" nstack init my-project
73
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.10.0"
74
74
  ```
75
75
 
76
76
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -105,6 +105,7 @@ Each project then pins its version and changes it through `nstack update`.
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
109
  | `nstack skills` | Exposes the playbooks as skills for the agent |
109
110
  | `nstack update` | Lays the new version on a branch to review |
110
111
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.9.6"
3
+ version = "0.10.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.9.6"
3
+ version = "0.10.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,207 @@
1
+ """
2
+ Checks run where the team works, recorded in git (PDR-0013, ADR-0006).
3
+
4
+ When the forge cannot run a pull request's checks, the team runs them on a workstation: this
5
+ command runs every check the project's CI runs — from one list, bound to the workflows by a
6
+ test — on a clean tree at a commit, and appends the run to that commit's note under
7
+ `refs/notes/nstack/checks`, one JSON line per run. The note travels with the branch's remote
8
+ and is merged line by line; the latest line for a commit is its record. On GitHub, with a token
9
+ that may write statuses, the run is also shown as `nstack / local checks` — a name no CI job
10
+ has. A record is *attested*, never *checked*: it makes a claim traceable, not true.
11
+
12
+ Usage : nstack attest [--root ROOT] [--base BASE] [--body-file FILE] [--labels a,b]
13
+ Output: 0 when every check passed, 1 otherwise — the record is written either way.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import datetime
19
+ import hashlib
20
+ import json
21
+ import os
22
+ import subprocess
23
+ import sys
24
+ import tempfile
25
+ import urllib.request
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ from napkinstack import provenance
30
+
31
+ NOTES = "nstack/checks"
32
+ RECORD = "attested-checks/v1"
33
+ CONTEXT = "nstack / local checks"
34
+ KEPT = 64 * 1024 # bytes of a step's output kept in the record; the whole is hashed
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Step:
39
+ signature: str # what the job's own `run` holds: the test binding the list to the workflows reads it
40
+ label: str
41
+ command: str # bash, at the root; NSTACK_BASE, NSTACK_BODY, NSTACK_TMP and PR_LABELS are set
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Check:
46
+ name: str # the CI job's name
47
+ steps: tuple[Step, ...]
48
+
49
+
50
+ # The project's CI, job by job, as a workstation runs it (skeleton/.github/workflows/). A job or a
51
+ # step in one place and not the other fails platform/tests/test_attest.py.
52
+ CHECKS: tuple[Check, ...] = (
53
+ Check("Fitness functions", (
54
+ Step("nstack fitness", "Manifests, boundaries, skills, plan, hygiene", "nstack fitness --root ."),)),
55
+ Check("PR scope and review budget", (
56
+ Step("nstack pr-scope", "One PR = one module", 'nstack pr-scope --root . --base "$NSTACK_BASE"'),)),
57
+ Check("Hooks and secrets", (
58
+ Step("pre-commit run --all-files", "Hooks on every file",
59
+ "SKIP=gitleaks pre-commit run --all-files --show-diff-on-failure"),
60
+ Step("pre-commit run gitleaks-history", "Secrets in the whole git history",
61
+ "pre-commit run gitleaks-history --hook-stage manual --all-files"),
62
+ Step("pre-commit run no-session-link", "No private session link in a message",
63
+ '{ cat "$NSTACK_BODY"; git log --format=%B "$NSTACK_BASE..HEAD"; } > "$NSTACK_TMP/said.txt" '
64
+ '&& pre-commit run no-session-link --hook-stage commit-msg --commit-msg-filename "$NSTACK_TMP/said.txt"'),
65
+ )),
66
+ Check("Test sheet and cycle", (
67
+ Step("nstack pr-check", "Test sheet and cycle",
68
+ 'nstack pr-check --root . --base "$NSTACK_BASE" --body-file "$NSTACK_BODY"'),)),
69
+ Check("Module checks", (
70
+ Step("bash .nstack/check-modules.sh", "Every changed module",
71
+ 'BASE="$NSTACK_BASE" bash .nstack/check-modules.sh'),)),
72
+ )
73
+
74
+
75
+ def _git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
76
+ return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=check)
77
+
78
+
79
+ def capped(text: str) -> str:
80
+ """A step's output as the record keeps it: whole up to KEPT bytes, else its head and its tail."""
81
+ data = text.encode("utf-8", "replace")
82
+ if len(data) <= KEPT:
83
+ return text
84
+ half = KEPT // 2
85
+ cut = len(data) - KEPT
86
+ return (data[:half].decode("utf-8", "ignore") + f"\n[… {cut} bytes cut …]\n"
87
+ + data[-half:].decode("utf-8", "ignore"))
88
+
89
+
90
+ def latest(root: Path, commit: str) -> dict | None:
91
+ """The record of a commit: the latest run its note holds, or None."""
92
+ shown = _git(root, "notes", f"--ref={NOTES}", "show", commit, check=False)
93
+ if shown.returncode != 0:
94
+ return None
95
+ runs = []
96
+ for line in shown.stdout.splitlines():
97
+ try:
98
+ run = json.loads(line)
99
+ except json.JSONDecodeError:
100
+ continue
101
+ if isinstance(run, dict) and run.get("record") == RECORD and run.get("commit") == commit:
102
+ runs.append(run)
103
+ return max(runs, key=lambda run: str(run.get("at", "")), default=None)
104
+
105
+
106
+ def _remote(root: Path) -> str | None:
107
+ """The branch's remote, else `origin`, else none."""
108
+ branch = _git(root, "symbolic-ref", "--quiet", "--short", "HEAD", check=False).stdout.strip()
109
+ if branch:
110
+ named = _git(root, "config", f"branch.{branch}.remote", check=False).stdout.strip()
111
+ if named:
112
+ return named
113
+ remotes = _git(root, "remote", check=False).stdout.split()
114
+ return "origin" if "origin" in remotes else None
115
+
116
+
117
+ def fetch(root: Path) -> str | None:
118
+ """Brings the remote's notes in, merged line by line with ours; returns the remote's name."""
119
+ remote = _remote(root)
120
+ if remote is None:
121
+ return None
122
+ theirs = f"refs/notes/nstack-remote/{NOTES.split('/')[-1]}"
123
+ got = _git(root, "fetch", "--quiet", remote, f"+refs/notes/{NOTES}:{theirs}", check=False)
124
+ if got.returncode == 0:
125
+ _git(root, "notes", f"--ref={NOTES}", "merge", "--quiet", "--strategy=cat_sort_uniq", theirs, check=False)
126
+ return remote
127
+
128
+
129
+ def _session(root: Path, commit: str) -> str | None:
130
+ trailers = _git(root, "log", "-1", "--format=%(trailers:key=Agent-Session,valueonly)", commit).stdout
131
+ return trailers.strip().splitlines()[0] if trailers.strip() else None
132
+
133
+
134
+ def _show(root: Path, commit: str, passed: int, total: int) -> str:
135
+ """The forge's display, when there is one and a token: never a judgement, a view of the record."""
136
+ token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
137
+ url = _git(root, "remote", "get-url", _remote(root) or "origin", check=False).stdout.strip()
138
+ from napkinstack.doctor import ON_GITHUB # the doctor's reading of a GitHub remote
139
+
140
+ found = ON_GITHUB.search(url)
141
+ if not token or not found:
142
+ return "not shown on a forge (no GitHub remote, or no token)"
143
+ api = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/")
144
+ body = json.dumps({"state": "success" if passed == total else "failure", "context": CONTEXT,
145
+ "description": f"attested: {passed} of {total}"}).encode()
146
+ request = urllib.request.Request(f"{api}/repos/{found.group(1)}/statuses/{commit}", data=body, method="POST",
147
+ headers={"Accept": "application/vnd.github+json",
148
+ "Authorization": f"Bearer {token}"})
149
+ try:
150
+ with urllib.request.urlopen(request, timeout=10):
151
+ return f"shown on GitHub as '{CONTEXT}'"
152
+ except (OSError, ValueError) as error:
153
+ return f"not shown on GitHub ({error})"
154
+
155
+
156
+ def run(root: Path, base: str, body_file: Path | None, labels: str) -> int:
157
+ print(provenance.judged_by(root), flush=True)
158
+ if _git(root, "status", "--porcelain", "--untracked-files=normal").stdout.strip():
159
+ print("FAIL [attest] the tree holds changes: a record names one commit, and this tree is none.\n"
160
+ " Action: commit or stash them, then run nstack attest again.")
161
+ return 1
162
+ commit = _git(root, "rev-parse", "HEAD").stdout.strip()
163
+ engine, origin = provenance.engine()
164
+ env = dict(os.environ, PR_LABELS=labels)
165
+ # The engine that runs this command runs the checks: its folder first on the path.
166
+ env["PATH"] = os.pathsep.join([str(Path(sys.argv[0]).resolve().parent), env.get("PATH", "")])
167
+ results = []
168
+ with tempfile.TemporaryDirectory() as tmp:
169
+ body = body_file or Path(tmp, "body.md")
170
+ if body_file is None:
171
+ body.write_text("", encoding="utf-8")
172
+ # NSTACK_ATTESTING: pr-check, run as one of the checks, judges the pull request's rules
173
+ # without asking for the record this run is making (A1, A2) — found rehearsing v0.10.0.
174
+ env.update(NSTACK_BASE=base, NSTACK_BODY=str(Path(body).resolve()), NSTACK_TMP=tmp,
175
+ NSTACK_ATTESTING=commit)
176
+ for check in CHECKS:
177
+ steps = []
178
+ for step in check.steps:
179
+ done = subprocess.run(["bash", "-c", step.command], cwd=root, env=env,
180
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
181
+ steps.append({"label": step.label, "command": step.command, "exit": done.returncode,
182
+ "output": capped(done.stdout),
183
+ "sha256": hashlib.sha256(done.stdout.encode("utf-8", "replace")).hexdigest()})
184
+ passed = all(step["exit"] == 0 for step in steps)
185
+ results.append({"name": check.name, "passed": passed, "steps": steps})
186
+ failed = "; ".join(f"{s['label']} exited {s['exit']}" for s in steps if s["exit"])
187
+ print(f"FAIL [attest] {check.name}: {failed}" if failed else f"ok [attest] {check.name}", flush=True)
188
+ # Who ran it, as git would sign a commit made here — its environment and its configuration.
189
+ ident = _git(root, "var", "GIT_COMMITTER_IDENT", check=False).stdout.strip()
190
+ who = ident.rsplit(" ", 2)[0] if ident.count(" ") >= 2 else ident
191
+ record = {"record": RECORD, "commit": commit,
192
+ "at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="microseconds"),
193
+ "engine": engine, "published": origin is None, "by": who, "session": _session(root, commit),
194
+ "base": base, "checks": results}
195
+ remote = fetch(root)
196
+ before = _git(root, "notes", f"--ref={NOTES}", "show", commit, check=False)
197
+ lines = (before.stdout.rstrip("\n") + "\n" if before.returncode == 0 and before.stdout.strip() else "")
198
+ lines += json.dumps(record, separators=(",", ":"), ensure_ascii=False) + "\n"
199
+ subprocess.run(["git", "notes", f"--ref={NOTES}", "add", "--force", "--file=-", commit], cwd=root,
200
+ input=lines, text=True, capture_output=True, check=True)
201
+ passed = sum(1 for result in results if result["passed"])
202
+ where = "local only: no remote"
203
+ if remote is not None:
204
+ pushed = _git(root, "push", "--quiet", remote, f"refs/notes/{NOTES}", check=False)
205
+ where = f"pushed to {remote}" if pushed.returncode == 0 else f"NOT pushed to {remote}: {pushed.stderr.strip()}"
206
+ print(f"Record of {commit[:12]}: attested: {passed} of {len(results)}; {where}; {_show(root, commit, passed, len(results))}.")
207
+ return 0 if passed == len(results) else 1
@@ -8,10 +8,19 @@ import os
8
8
  import sys
9
9
  from pathlib import Path
10
10
 
11
- from napkinstack import assurance
11
+ from napkinstack import assurance, attest
12
12
  from napkinstack import (compat, discovery, doctor, landed, modules, provenance, settings,
13
13
  pull_request, skills)
14
14
  from napkinstack.fitness import boundaries, hygiene, manifests, plan, pr_scope
15
+ from napkinstack.project import ANSWERS
16
+
17
+
18
+ def _here() -> Path:
19
+ """The project the current folder belongs to: the nearest folder, this one or above, holding
20
+ the project's answers — as git and cargo find their root (D130). Outside any project, the
21
+ current folder, as before."""
22
+ here = Path.cwd()
23
+ return next((folder for folder in (here, *here.parents) if (folder / ANSWERS).is_file()), here)
15
24
 
16
25
 
17
26
  def _root(value: str) -> Path:
@@ -39,8 +48,9 @@ def _fitness(root: Path) -> int:
39
48
 
40
49
  def _add(sub, name: str, help_: str, func) -> argparse.ArgumentParser:
41
50
  parser = sub.add_parser(name, help=help_)
42
- parser.add_argument("--root", type=_root, default=Path.cwd(),
43
- help="project root (default: current folder)")
51
+ parser.add_argument("--root", type=_root, default=_here(),
52
+ help="project root (default: the nearest folder holding .copier-answers.yml, "
53
+ "this one or above; else the current folder)")
44
54
  parser.set_defaults(func=func)
45
55
  return parser
46
56
 
@@ -146,6 +156,11 @@ def build_parser() -> argparse.ArgumentParser:
146
156
  lambda a: landed.run(a.root, a.span, landed.from_environment(a.root)))
147
157
  ld.add_argument("--span", default="HEAD~1..HEAD",
148
158
  help="the commits to read, BEFORE..AFTER (default: the last commit)")
159
+ at = _add(sub, "attest", "runs the CI's checks here and records the run on the commit, in git",
160
+ lambda a: attest.run(a.root, a.base, a.body_file, a.labels))
161
+ at.add_argument("--base", default="origin/main")
162
+ at.add_argument("--body-file", type=Path, help="the pull request's description, for its checks")
163
+ at.add_argument("--labels", default="", help="the pull request's labels, comma-separated")
149
164
  ds = _add(sub, "discover", "starts a discovery from an idea file, for the team's agent",
150
165
  lambda a: discovery.run(a.root, a.idea))
151
166
  ds.add_argument("idea", type=Path, help="the idea, a .md or .txt file")
@@ -176,7 +191,7 @@ def _named_root(argv: list[str]) -> Path:
176
191
  return Path(argv[index + 1])
177
192
  if word.startswith("--root="):
178
193
  return Path(word.removeprefix("--root="))
179
- return Path(".")
194
+ return _here()
180
195
 
181
196
 
182
197
  def _release(a) -> int:
@@ -69,7 +69,11 @@ API_VERSION = "2026-03-10"
69
69
  JOBS = ("Fitness functions", "PR scope and review budget", "Hooks and secrets", "Test sheet and cycle",
70
70
  "Module checks")
71
71
  THIRD_PARTY_ACTIONS = ("astral-sh/setup-uv",) # non-GitHub actions of the skeleton workflows
72
- LABELS = ("cross-module", "over-budget", "out-of-cycle")
72
+ # The pull requests' three exceptions, then what the shipped issue templates put on an issue: a
73
+ # label that does not exist is dropped by GitHub without a word (D128).
74
+ EXCEPTIONS = ("cross-module", "over-budget", "out-of-cycle")
75
+ ISSUES = ("request", "bug", "architecture", "debt", "acceptance")
76
+ LABELS = EXCEPTIONS + ISSUES
73
77
  PUBLISHED = provenance.PUBLISHED # a published version is a vX.Y.Z tag (ADR-0002), as CI installs it
74
78
  REMOTE = re.compile(r"https://|ssh://|git@[^:/]+:|gh:|gl:") # as .nstack/install-engine.sh reads them
75
79
  ON_GITHUB = re.compile(r"(?:https://github\.com/|ssh://git@github\.com/|git@github\.com:|gh:)([^/]+/[^/]+?)(?:\.git)?/?$")
@@ -140,6 +140,7 @@ def init(destination: Path, answers: dict[str, str | None], source: str, ref: st
140
140
  f"folder inside it — nstack init {destination}/<project>.")
141
141
  return 1
142
142
  data = {question: value for question, value in answers.items() if value is not None}
143
+ data.setdefault("checks", "forge") # a project starts judged on the forge; the decider may change it
143
144
  try:
144
145
  copier.run_copy(source, destination, data=data, vcs_ref=ref, quiet=True, unsafe=False)
145
146
  except (CopierError, ValueError, OSError) as exc:
@@ -51,6 +51,7 @@ from napkinstack.fitness import plan
51
51
  from napkinstack import assurance
52
52
  from napkinstack.fitness.manifests import MODULE_DIRS, changed_files, find_manifests, is_description
53
53
  from napkinstack.modules import LOGIN
54
+ from napkinstack.project import ANSWERS
54
55
 
55
56
  CHARTER = plan.CHARTER.as_posix() # docs/project/charter.md, as the diff names it
56
57
 
@@ -388,6 +389,46 @@ def charter_without_pdr(root: Path, base: str, files: list[str]) -> list[str]:
388
389
  "correction that decides nothing needs none (.nstack/playbooks/framing.md, Decisions)."]
389
390
 
390
391
 
392
+ def check_attested(root: Path, head: str, fail) -> str:
393
+ """Where the checks are judged, as the decider's answer `checks` says (A1, A2): on the forge's
394
+ CI, or attested on a workstation by `nstack attest` — then a complete, passed record of the
395
+ head is required. Returns the line the summary prints."""
396
+ from napkinstack import attest # the record's reader, and the one list of checks
397
+
398
+ try:
399
+ answers = yaml.safe_load((root / ANSWERS).read_text(encoding="utf-8")) or {}
400
+ except (OSError, yaml.YAMLError):
401
+ answers = {}
402
+ attest.fetch(root)
403
+ record = attest.latest(root, head)
404
+ if answers.get("checks", "forge") != "attested":
405
+ shown = ""
406
+ if record:
407
+ passed = sum(1 for check in record["checks"] if check["passed"])
408
+ shown = f"; a record of the head: attested {passed} of {len(record['checks'])} (information)"
409
+ return "the forge's CI" + shown
410
+ if os.environ.get("NSTACK_ATTESTING") == head:
411
+ return "attested — this run is the record being made"
412
+ if os.environ.get("GITHUB_ACTIONS") == "true":
413
+ # CI's checkout keeps no credential to fetch the notes: where the checks are attested, the
414
+ # record is read where the merge is decided, never failed here for want of a fetch.
415
+ return "attested — judged where nstack attest runs, not in CI"
416
+ if record is None:
417
+ fail("A1", f"no record of the head {head[:7]}: this project's checks are attested, and none ran on "
418
+ "this commit.\n Action: run nstack attest on the head, then push.")
419
+ return "attested — no record of the head"
420
+ names = [check["name"] for check in record["checks"]]
421
+ missing = [check.name for check in attest.CHECKS if check.name not in names]
422
+ failed = [check["name"] for check in record["checks"] if not check["passed"]]
423
+ if missing or failed:
424
+ said = "; ".join(part for part in (f"missing: {', '.join(missing)}" if missing else "",
425
+ f"failed: {', '.join(failed)}" if failed else "") if part)
426
+ fail("A2", f"the record of the head {head[:7]} is not complete and passed ({said}).\n"
427
+ " Action: fix what failed, commit, and run nstack attest again.")
428
+ return "attested — the record of the head does not pass"
429
+ return f"attested on a workstation, by {record['by']}, at {record['at']}"
430
+
431
+
391
432
  def run(root: Path, base: str, body_file: Path | None = None) -> int:
392
433
  if body_file is not None:
393
434
  body = body_file.read_text(encoding="utf-8")
@@ -423,9 +464,11 @@ def run(root: Path, base: str, body_file: Path | None = None) -> int:
423
464
  lambda commit: bool(tree_of(commit)) and tree_of(commit) == tree_of(head))
424
465
  if modules:
425
466
  check_cycle(root, body, labels, datetime.date.today(), fail, warnings.append)
467
+ checks = check_attested(root, head, fail)
426
468
 
427
469
  print(f"Modules touched : {len(modules)}" + "".join(f"\n - {folder}" for folder in modules))
428
470
  print(f"Test sheet : {'required' if reasons else 'not required'}")
471
+ print(f"Checks : {checks}")
429
472
  for line in summary(body, authorship) if reasons else []:
430
473
  print(line)
431
474
  for scenario in human_only:
@@ -39,10 +39,18 @@ from napkinstack.project import ANSWERS
39
39
  RULESET_NAME = "nstack-checklist"
40
40
  IN_RULESET = ("G1", "G2", "G3", "G4", "G13", "G17", "G18")
41
41
  PULL_REQUEST = ("G1", "G2", "G3", "G13")
42
- LABEL_COLOURS = {"cross-module": "d93f0b", "over-budget": "fbca04", "out-of-cycle": "5319e7"}
42
+ LABEL_COLOURS = {"cross-module": "d93f0b", "over-budget": "fbca04", "out-of-cycle": "5319e7",
43
+ "request": "0e8a16", "bug": "d73a4a", "architecture": "1d76db", "debt": "c5def5",
44
+ "acceptance": "bfdadc"}
45
+ # An issue label means what its template says: the same words, the template being the source.
43
46
  LABEL_MEANING = {"cross-module": "A pull request touching several modules, with its justification",
44
47
  "over-budget": "A pull request beyond the review budget, with its justification",
45
- "out-of-cycle": "Work outside the current cycle, with its justification"}
48
+ "out-of-cycle": "Work outside the current cycle, with its justification",
49
+ "request": "Something asked for that no cycle plans yet",
50
+ "bug": "Incorrect behaviour in production or in test",
51
+ "architecture": "Boundary, contract, coupling, structuring decision",
52
+ "debt": "Identified debt, with its cost and its trigger",
53
+ "acceptance": "A deliverable tried by a person — what to run, what was seen"}
46
54
  Call = tuple[str, str, str, dict | None] # (rules closed, method, path under the repository, body)
47
55
 
48
56
 
File without changes