napkinstack 0.9.6__tar.gz → 0.11.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.11.0}/PKG-INFO +6 -5
  2. {napkinstack-0.9.6 → napkinstack-0.11.0}/README.md +5 -4
  3. {napkinstack-0.9.6 → napkinstack-0.11.0}/pyproject.toml +1 -1
  4. {napkinstack-0.9.6 → napkinstack-0.11.0}/pyproject.toml.orig +1 -1
  5. napkinstack-0.11.0/src/napkinstack/attest.py +207 -0
  6. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/cli.py +27 -6
  7. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/doctor.py +5 -1
  8. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/fitness/manifests.py +3 -1
  9. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/project.py +5 -2
  10. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/pull_request.py +70 -1
  11. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/settings.py +10 -2
  12. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/contracts/AGENTS.md +3 -0
  13. {napkinstack-0.9.6 → napkinstack-0.11.0}/LICENSE +0 -0
  14. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/__init__.py +0 -0
  15. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/assurance.py +0 -0
  16. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/compat.py +0 -0
  17. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/discovery.py +0 -0
  18. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/fitness/__init__.py +0 -0
  19. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/fitness/boundaries.py +0 -0
  20. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/fitness/hygiene.py +0 -0
  21. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/fitness/plan.py +0 -0
  22. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/fitness/pr_scope.py +0 -0
  23. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/landed.py +0 -0
  24. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/modules.py +0 -0
  25. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/provenance.py +0 -0
  26. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/skills.py +0 -0
  27. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
  28. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/contracts/README.md +0 -0
  29. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
  30. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
  31. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
  32. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -0
  33. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/module/README.md +0 -0
  34. {napkinstack-0.9.6 → napkinstack-0.11.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  35. {napkinstack-0.9.6 → napkinstack-0.11.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.11.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.11.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.11.0" nstack init my-project
88
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.11.0"
89
89
  ```
90
90
 
91
91
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -120,8 +120,9 @@ 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
- | `nstack update` | Lays the new version on a branch to review |
125
+ | `nstack update [--session <id>]` | Lays the new version on a branch to review; an agent's session as the commit's `Agent-Session` trailer |
125
126
 
126
127
  **Prerequisites**: uv and git. The guardrails really block on a public GitHub repository,
127
128
  or on a private one under the Team or Pro plan; on a private repository on the Free plan
@@ -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.11.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.11.0" nstack init my-project
73
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.11.0"
74
74
  ```
75
75
 
76
76
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -105,8 +105,9 @@ 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
- | `nstack update` | Lays the new version on a branch to review |
110
+ | `nstack update [--session <id>]` | Lays the new version on a branch to review; an agent's session as the commit's `Agent-Session` trailer |
110
111
 
111
112
  **Prerequisites**: uv and git. The guardrails really block on a public GitHub repository,
112
113
  or on a private one under the Team or Pro plan; on a private repository on the Free plan
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.9.6"
3
+ version = "0.11.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.11.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
 
@@ -57,7 +67,7 @@ def _init(args: argparse.Namespace) -> int:
57
67
  def _update(args: argparse.Namespace) -> int:
58
68
  from napkinstack import project
59
69
 
60
- return project.update(args.root, args.ref or project.default_ref())
70
+ return project.update(args.root, args.ref or project.default_ref(), args.session)
61
71
 
62
72
 
63
73
  class _Version(argparse.Action):
@@ -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")
@@ -161,6 +176,7 @@ def build_parser() -> argparse.ArgumentParser:
161
176
  up = _add(sub, "update", "merges a NapkinStack version onto a branch to review",
162
177
  _update)
163
178
  up.add_argument("--ref", help="target version: a tag vX.Y.Z, or a commit (default: the one of nstack)")
179
+ up.add_argument("--session", help="the agent's session, written as the commit's Agent-Session trailer")
164
180
  return parser
165
181
 
166
182
 
@@ -176,7 +192,7 @@ def _named_root(argv: list[str]) -> Path:
176
192
  return Path(argv[index + 1])
177
193
  if word.startswith("--root="):
178
194
  return Path(word.removeprefix("--root="))
179
- return Path(".")
195
+ return _here()
180
196
 
181
197
 
182
198
  def _release(a) -> int:
@@ -215,4 +231,9 @@ def main(argv: list[str] | None = None) -> int:
215
231
  print(provenance.judged_by(args.root), flush=True)
216
232
  if said := plan.stage_line(args.root):
217
233
  print(said, flush=True)
218
- return args.func(args)
234
+ code = args.func(args)
235
+ if code and args.command in JUDGES | {"attest"}:
236
+ # Where a person reads what the codes and words above mean (D131).
237
+ print("A rule's code is explained in .nstack/handbook/RULES.md; a word of the framework in "
238
+ ".nstack/handbook/glossary.md.", flush=True)
239
+ return code
@@ -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)?/?$")
@@ -128,7 +128,9 @@ def is_description(path: str) -> bool:
128
128
  (D24); a module holding nothing else has nothing to check yet (D33)."""
129
129
  # A page at any depth describes; it runs nothing. A contract's own page —
130
130
  # contracts/<name>/v1/README.md — is the case that asked: the module that holds it must
131
- # stay green until the contract gains a schema or a test (PDR-0012).
131
+ # stay green until the contract gains a schema or a test (PDR-0012). Once a module consumes
132
+ # the version, or it holds a schema, its page is the contract, and a pull request changing it
133
+ # changes its module (pull_request.relied_on_pages, D133).
132
134
  return (path in DESCRIPTION or path.startswith("docs/")
133
135
  or path.rsplit("/", 1)[-1] in {".gitkeep", "README.md"})
134
136
 
@@ -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:
@@ -325,7 +326,7 @@ def _readdress(root: Path) -> tuple[list[str], list[str]]:
325
326
  return rewritten, left
326
327
 
327
328
 
328
- def update(root: Path, ref: str) -> int:
329
+ def update(root: Path, ref: str, session: str | None = None) -> int:
329
330
  import copier
330
331
  from copier.errors import CopierError
331
332
 
@@ -388,7 +389,9 @@ def update(root: Path, ref: str) -> int:
388
389
  "then git add --all && git commit.\n The check-merge-conflict hook and CI "
389
390
  "reject any remaining marker.")
390
391
  return 1
391
- for args in (("add", "--all"), ("commit", "--quiet", "--message", f"NapkinStack {previous} -> {current}")):
392
+ # An agent's update carries the kernel's trailer, as every commit it makes (D135).
393
+ message = f"NapkinStack {previous} -> {current}" + (f"\n\nAgent-Session: {session}" if session else "")
394
+ for args in (("add", "--all"), ("commit", "--quiet", "--message", message)):
392
395
  result = _git(root, *args)
393
396
  if result.returncode:
394
397
  print(f"FAIL [update] Update laid on {branch}, but `git {args[0]}` failed:\n"
@@ -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
 
@@ -103,14 +104,40 @@ def touched_modules(root: Path, base: str, files: list[str]) -> dict[str, list[d
103
104
  parts = path.split("/")
104
105
  if parts[-1] == "MANIFEST.yaml" and parts[0] in MODULE_DIRS and len(parts) in (2, 3):
105
106
  manifests.setdefault(path, []).append(_manifest(_git(root, "show", f"{base}:{path}").stdout))
107
+ relied = relied_on_pages(root, manifests)
106
108
  touched = {}
107
109
  for path, found in sorted(manifests.items()):
108
110
  folder = path.removesuffix("/MANIFEST.yaml")
109
- if any(file.startswith(f"{folder}/") and not is_description(file[len(folder) + 1:]) for file in files):
111
+ if any(file.startswith(f"{folder}/") and (not is_description(file[len(folder) + 1:]) or file in relied)
112
+ for file in files):
110
113
  touched[folder] = found
111
114
  return touched
112
115
 
113
116
 
117
+ CONTRACT_PAGE = re.compile(r"(?P<path>.+/(?P<contract>[^/]+)/(?P<version>v\d+))/README\.md")
118
+
119
+
120
+ def relied_on_pages(root: Path, manifests: dict[str, list[dict]]) -> set[str]:
121
+ """The pages of the contract versions someone relies on — a module consumes the version, or
122
+ it holds a schema — relative to the root (D133). Such a page is the contract: its prose is
123
+ held by review and by nothing else (03-contracts.md), so changing it changes its module. A
124
+ version nobody relies on yet keeps PDR-0012's relief: its page is a description."""
125
+ consumed = {(str(entry.get("contract")), str(entry.get("version")))
126
+ for found in manifests.values() for data in found[:1]
127
+ for entry in (data.get("consumes") or []) if isinstance(entry, dict)}
128
+ pages = set()
129
+ for data in (found[0] for found in manifests.values() if found):
130
+ for entry in data.get("provides") or []:
131
+ if not isinstance(entry, dict) or not entry.get("path"):
132
+ continue
133
+ page = f"{str(entry['path']).rstrip('/')}/README.md"
134
+ match = CONTRACT_PAGE.fullmatch(page)
135
+ if match and ((match["contract"], match["version"]) in consumed
136
+ or any((root / match["path"]).glob("*.schema.json"))):
137
+ pages.add(page)
138
+ return pages
139
+
140
+
114
141
  def sheet_reason(folder: str, manifests: list[dict]) -> str | None:
115
142
  """Why a module requires a test sheet (T1), or None: the stricter of its manifests, so
116
143
  that a pull request cannot lower its own requirement. What each criticality requires is read
@@ -388,6 +415,46 @@ def charter_without_pdr(root: Path, base: str, files: list[str]) -> list[str]:
388
415
  "correction that decides nothing needs none (.nstack/playbooks/framing.md, Decisions)."]
389
416
 
390
417
 
418
+ def check_attested(root: Path, head: str, fail) -> str:
419
+ """Where the checks are judged, as the decider's answer `checks` says (A1, A2): on the forge's
420
+ CI, or attested on a workstation by `nstack attest` — then a complete, passed record of the
421
+ head is required. Returns the line the summary prints."""
422
+ from napkinstack import attest # the record's reader, and the one list of checks
423
+
424
+ try:
425
+ answers = yaml.safe_load((root / ANSWERS).read_text(encoding="utf-8")) or {}
426
+ except (OSError, yaml.YAMLError):
427
+ answers = {}
428
+ attest.fetch(root)
429
+ record = attest.latest(root, head)
430
+ if answers.get("checks", "forge") != "attested":
431
+ shown = ""
432
+ if record:
433
+ passed = sum(1 for check in record["checks"] if check["passed"])
434
+ shown = f"; a record of the head: attested {passed} of {len(record['checks'])} (information)"
435
+ return "the forge's CI" + shown
436
+ if os.environ.get("NSTACK_ATTESTING") == head:
437
+ return "attested — this run is the record being made"
438
+ if os.environ.get("GITHUB_ACTIONS") == "true":
439
+ # CI's checkout keeps no credential to fetch the notes: where the checks are attested, the
440
+ # record is read where the merge is decided, never failed here for want of a fetch.
441
+ return "attested — judged where nstack attest runs, not in CI"
442
+ if record is None:
443
+ fail("A1", f"no record of the head {head[:7]}: this project's checks are attested, and none ran on "
444
+ "this commit.\n Action: run nstack attest on the head, then push.")
445
+ return "attested — no record of the head"
446
+ names = [check["name"] for check in record["checks"]]
447
+ missing = [check.name for check in attest.CHECKS if check.name not in names]
448
+ failed = [check["name"] for check in record["checks"] if not check["passed"]]
449
+ if missing or failed:
450
+ said = "; ".join(part for part in (f"missing: {', '.join(missing)}" if missing else "",
451
+ f"failed: {', '.join(failed)}" if failed else "") if part)
452
+ fail("A2", f"the record of the head {head[:7]} is not complete and passed ({said}).\n"
453
+ " Action: fix what failed, commit, and run nstack attest again.")
454
+ return "attested — the record of the head does not pass"
455
+ return f"attested on a workstation, by {record['by']}, at {record['at']}"
456
+
457
+
391
458
  def run(root: Path, base: str, body_file: Path | None = None) -> int:
392
459
  if body_file is not None:
393
460
  body = body_file.read_text(encoding="utf-8")
@@ -423,9 +490,11 @@ def run(root: Path, base: str, body_file: Path | None = None) -> int:
423
490
  lambda commit: bool(tree_of(commit)) and tree_of(commit) == tree_of(head))
424
491
  if modules:
425
492
  check_cycle(root, body, labels, datetime.date.today(), fail, warnings.append)
493
+ checks = check_attested(root, head, fail)
426
494
 
427
495
  print(f"Modules touched : {len(modules)}" + "".join(f"\n - {folder}" for folder in modules))
428
496
  print(f"Test sheet : {'required' if reasons else 'not required'}")
497
+ print(f"Checks : {checks}")
429
498
  for line in summary(body, authorship) if reasons else []:
430
499
  print(line)
431
500
  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
 
@@ -13,6 +13,9 @@ Defines and versions the interface contracts between modules.
13
13
  - A version a module consumes, or marked stable, changes only with the merged `compat`
14
14
  command's approval (V1).
15
15
  - Contract tests run on **both sides**: producer and consumer.
16
+ - A version's page (`<name>/v<n>/README.md`) is the contract once a module consumes the
17
+ version or it holds a schema: a pull request changing it changes this module, and this
18
+ module's test sheet is due — its prose is held by review and by nothing else.
16
19
 
17
20
  ## The main trap
18
21
 
File without changes