napkinstack 0.7.7__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {napkinstack-0.7.7 → napkinstack-0.9.0}/PKG-INFO +4 -4
  2. {napkinstack-0.7.7 → napkinstack-0.9.0}/README.md +3 -3
  3. {napkinstack-0.7.7 → napkinstack-0.9.0}/pyproject.toml +1 -1
  4. {napkinstack-0.7.7 → napkinstack-0.9.0}/pyproject.toml.orig +1 -1
  5. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/doctor.py +6 -1
  6. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/landed.py +30 -1
  7. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/modules.py +2 -1
  8. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/pull_request.py +29 -2
  9. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/settings.py +8 -0
  10. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/skills.py +14 -1
  11. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/templates/contracts/AGENTS.md +2 -1
  12. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/templates/module/AGENTS.md +3 -3
  13. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -1
  14. napkinstack-0.9.0/src/napkinstack/templates/module/README.md +21 -0
  15. napkinstack-0.7.7/src/napkinstack/templates/module/README.md +0 -24
  16. napkinstack-0.7.7/src/napkinstack/templates/module/tests/.gitkeep +0 -0
  17. {napkinstack-0.7.7 → napkinstack-0.9.0}/LICENSE +0 -0
  18. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/__init__.py +0 -0
  19. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/assurance.py +0 -0
  20. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/cli.py +0 -0
  21. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/compat.py +0 -0
  22. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/discovery.py +0 -0
  23. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/fitness/__init__.py +0 -0
  24. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/fitness/boundaries.py +0 -0
  25. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/fitness/hygiene.py +0 -0
  26. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/fitness/manifests.py +0 -0
  27. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/fitness/plan.py +0 -0
  28. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/fitness/pr_scope.py +0 -0
  29. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/project.py +0 -0
  30. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/provenance.py +0 -0
  31. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
  32. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/templates/contracts/README.md +0 -0
  33. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
  34. {napkinstack-0.7.7 → napkinstack-0.9.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
  35. {napkinstack-0.7.7/src/napkinstack/templates/module/docs/adr → napkinstack-0.9.0/src/napkinstack/templates/module/src}/.gitkeep +0 -0
  36. {napkinstack-0.7.7/src/napkinstack/templates/module/src → napkinstack-0.9.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.7.7
3
+ Version: 0.9.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.7.7, published** on [PyPI](https://pypi.org/project/napkinstack/).
23
+ > **Status: v0.9.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.7.7" nstack init my-project
88
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.7.7"
87
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.9.0" nstack init my-project
88
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.9.0"
89
89
  ```
90
90
 
91
91
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -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.7.7, published** on [PyPI](https://pypi.org/project/napkinstack/).
8
+ > **Status: v0.9.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.7.7" nstack init my-project
73
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.7.7"
72
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.9.0" nstack init my-project
73
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.9.0"
74
74
  ```
75
75
 
76
76
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.7.7"
3
+ version = "0.9.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.7.7"
3
+ version = "0.9.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"
@@ -30,7 +30,7 @@ Rules:
30
30
  L7 the template source reachable by anyone: a repository, not a path on one machine
31
31
  N1 information, never a gap (D91): a published version newer than the project's, read from
32
32
  the registry — the one place a project learns of it. No other command opens a connection
33
- G1-G18 the GitHub settings of CHECKLIST; G6 is not applicable outside a public
33
+ G1-G19 the GitHub settings of CHECKLIST; G6 is not applicable outside a public
34
34
  repository, and on a private one G1-G5, G12, G13, G17 and G18 name the GitHub plan
35
35
  or option required
36
36
 
@@ -117,6 +117,10 @@ CHECKLIST = [ # (rule, setting, action)
117
117
  # main, and said nothing of main being deleted or rewritten under them.
118
118
  ("G17", "Main branch cannot be deleted", f"{RULESET}: restrict deletions"),
119
119
  ("G18", "Main branch history cannot be rewritten: no force push", f"{RULESET}: block force pushes"),
120
+ # G19: every workflow and ruleset here watches `main`. A repository whose default branch is
121
+ # another one triggers none of them, and its record of commits on main never runs.
122
+ ("G19", "Default branch `main`: the branch every workflow and ruleset here watches",
123
+ "Settings → General → Default branch: rename it main"),
120
124
  ]
121
125
 
122
126
  # Settings specific to public repositories, and settings a private one pays for
@@ -267,6 +271,7 @@ CHECKS: dict[str, Callable[[GitHub], bool]] = {
267
271
  "G16": lambda c: _merges(c, "delete_branch_on_merge").get("delete_branch_on_merge") is True,
268
272
  "G17": lambda c: _rule(c, "deletion") is not None,
269
273
  "G18": lambda c: _rule(c, "non_fast_forward") is not None,
274
+ "G19": lambda c: _merges(c, "default_branch").get("default_branch") == "main",
270
275
  }
271
276
 
272
277
 
@@ -14,6 +14,10 @@ It says nothing about what predates it: the commit that created the repository,
14
14
  everything the repository already held when the record arrived, are not reported. A record
15
15
  that greets a project by failing on its own arrival teaches people to ignore it.
16
16
 
17
+ It reads from the last commit it recorded, not from the push alone: a push whose run never
18
+ started — CI out of minutes, a runner down — is read by the next run that does. Its last
19
+ success is read from the forge (actions: read); a record that never succeeded reads the push.
20
+
17
21
  The forge is read with a token supplied by the run (GH_TOKEN, otherwise GITHUB_TOKEN), which
18
22
  needs no write access of any kind: the project's automation token stays read-only (G10).
19
23
  Without a token, or when the span cannot be read, nothing is claimed.
@@ -73,8 +77,28 @@ def _described(root: Path, commit: str) -> str:
73
77
  return _git(root, "show", "-s", "--format=%h (%an, %ad)", "--date=short", commit).stdout.strip()
74
78
 
75
79
 
80
+ def _since_last_record(root: Path, span: str, forge: GitHub) -> tuple[str, str | None]:
81
+ """The span widened to start at the last commit the record recorded, and that commit; the
82
+ span as given, and None, when the push already starts there or the forge cannot say."""
83
+ try:
84
+ runs = forge.get(f"/actions/workflows/{Path(RECORD).name}/runs?branch=main&status=success&per_page=1")
85
+ except NotVerified:
86
+ return span, None
87
+ found = (runs or {}).get("workflow_runs") or []
88
+ last = str(found[0].get("head_sha") or "") if found else ""
89
+ before, _, after = span.partition("..")
90
+ if not last or not after or last == before or _git(root, "cat-file", "-e", f"{last}^{{commit}}").returncode:
91
+ return span, None
92
+ if _git(root, "merge-base", "--is-ancestor", last, after).returncode:
93
+ return span, None # history rewritten: the push is all that is known
94
+ if before not in ("", EMPTY) and _git(root, "merge-base", "--is-ancestor", before, last).returncode == 0:
95
+ return span, None # the push starts after the last record already
96
+ return f"{last}..{after}", last
97
+
98
+
76
99
  def run(root: Path, span: str, forge: GitHub | None) -> int:
77
- commits = _commits(root, span)
100
+ pushed = _commits(root, span)
101
+ commits = pushed
78
102
  if commits is None:
79
103
  print(f"[W1] the commits of '{span}' cannot be read: not verified.\n"
80
104
  " Action: fetch the history (fetch-depth: 0), or pass an existing range.")
@@ -83,6 +107,11 @@ def run(root: Path, span: str, forge: GitHub | None) -> int:
83
107
  print("[W1] no token (GH_TOKEN or GITHUB_TOKEN): how each commit arrived is not verified.\n"
84
108
  " Action: supply a token with read access to the repository's pull requests.")
85
109
  return 0
110
+ span, last = _since_last_record(root, span, forge)
111
+ if last:
112
+ commits = _commits(root, span) or pushed
113
+ print(f"Read since the last commit it recorded, {last[:12]}: {len(commits) - len(pushed)} commit(s) "
114
+ "no run had read.")
86
115
  beginning = _beginning(root)
87
116
  outside, checked = [], 0
88
117
  for commit in commits:
@@ -218,7 +218,8 @@ def next_steps(root: Path, name: str, criticality: str, user_facing: bool) -> li
218
218
  """What the module's first pull requests will be asked, from facts nstack holds (D33)."""
219
219
  from napkinstack.fitness import plan # here: plan reads HANDLE from this module
220
220
 
221
- steps = ["Creation ADR in docs/adr/: capability, boundary, alternatives — and the criticality "
221
+ steps = [f"Creation ADR in the project's docs/adr/, Scope: module {name}: capability, boundary, "
222
+ "alternatives — and the criticality "
222
223
  f"with one sentence saying why {criticality} and not the value below or above; it is "
223
224
  "the decider who settles it",
224
225
  "MANIFEST.yaml: the responsibility in ONE sentence",
@@ -52,6 +52,8 @@ 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
54
 
55
+ CHARTER = plan.CHARTER.as_posix() # docs/project/charter.md, as the diff names it
56
+
55
57
  LABEL = "out-of-cycle"
56
58
  DELIVERABLE = re.compile(r"^Deliverable:[ \t]*(D[1-9][0-9]*)\b", re.I | re.M)
57
59
  JUSTIFICATION = re.compile(r"^Out of cycle:[ \t]*(\S.*)$", re.I | re.M)
@@ -320,7 +322,8 @@ def check_sheet(body: str, head: str, reasons: list[str], fail: Fail,
320
322
  return human_only
321
323
 
322
324
 
323
- def check_cycle(root: Path, body: str, labels: set[str], today: datetime.date, fail: Fail) -> None:
325
+ def check_cycle(root: Path, body: str, labels: set[str], today: datetime.date, fail: Fail,
326
+ warn: Callable[[str], None] = lambda message: None) -> None:
324
327
  """K1 to K4, for delivery work."""
325
328
  if LABEL in labels:
326
329
  if not JUSTIFICATION.search(body):
@@ -362,6 +365,27 @@ def check_cycle(root: Path, body: str, labels: set[str], today: datetime.date, f
362
365
  elif states[match[1]] not in {"ready", "in-progress"}:
363
366
  fail("K3", f"deliverable {match[1]} is '{states[match[1]]}': work starts on a ready "
364
367
  "deliverable (definition of ready).")
368
+ elif states[match[1]] == "ready":
369
+ # Said, not refused: the state is the decider's record, and this pull request knows (PDR-0002).
370
+ warn(f"[K3] deliverable {match[1]} is still 'ready' while this pull request delivers it.\n"
371
+ f" Action: set its state: in-progress in {path.name}, in this pull request.")
372
+
373
+
374
+ def charter_without_pdr(root: Path, base: str, files: list[str]) -> list[str]:
375
+ """C9: an accepted charter changes only through a PDR (it is the project's founding
376
+ decision). Said as a warning where the change is — a typo in it is not a decision — and
377
+ never while the charter is still proposed, which is when the framing writes it."""
378
+ if CHARTER not in files:
379
+ return []
380
+ before = _git(root, "show", f"{base}:{CHARTER}")
381
+ status = re.search(r"^status:\s*['\"]?(\w+)", before.stdout.split("\n---", 1)[0], re.M) if before.returncode == 0 else None
382
+ if not status or status.group(1) != "accepted":
383
+ return []
384
+ if any(path.startswith("docs/pdr/") and not path.endswith("README.md") for path in files):
385
+ return []
386
+ return [f"[C9] {CHARTER}: the accepted charter changes, and no decision record comes with it.\n"
387
+ " Action: write the PDR that decides the change, and cite it in the charter; a "
388
+ "correction that decides nothing needs none (.nstack/playbooks/framing.md, Decisions)."]
365
389
 
366
390
 
367
391
  def run(root: Path, base: str, body_file: Path | None = None) -> int:
@@ -383,6 +407,7 @@ def run(root: Path, base: str, body_file: Path | None = None) -> int:
383
407
  labels = {label.strip() for label in os.environ.get("PR_LABELS", "").split(",") if label.strip()}
384
408
 
385
409
  failures: list[str] = []
410
+ warnings = charter_without_pdr(root, base, files)
386
411
 
387
412
  def fail(rule: str, message: str) -> None:
388
413
  failures.append(f"[{rule}] {message}")
@@ -397,7 +422,7 @@ def run(root: Path, base: str, body_file: Path | None = None) -> int:
397
422
  human_only = check_sheet(body, head, reasons, fail, authorship, rerun_at_head(modules),
398
423
  lambda commit: bool(tree_of(commit)) and tree_of(commit) == tree_of(head))
399
424
  if modules:
400
- check_cycle(root, body, labels, datetime.date.today(), fail)
425
+ check_cycle(root, body, labels, datetime.date.today(), fail, warnings.append)
401
426
 
402
427
  print(f"Modules touched : {len(modules)}" + "".join(f"\n - {folder}" for folder in modules))
403
428
  print(f"Test sheet : {'required' if reasons else 'not required'}")
@@ -405,6 +430,8 @@ def run(root: Path, base: str, body_file: Path | None = None) -> int:
405
430
  print(line)
406
431
  for scenario in human_only:
407
432
  print(f" For the approver, human only: {scenario}")
433
+ for warning in warnings:
434
+ print(f"WARNING {warning}")
408
435
  for failure in failures:
409
436
  print(f"FAIL {failure}")
410
437
  if failures:
@@ -142,6 +142,14 @@ def plan(client: doctor.GitHub | None, missing: set[str]) -> tuple[list[Call], l
142
142
  merges["delete_branch_on_merge"] = True
143
143
  if merges:
144
144
  calls.append((", ".join(rule for rule in ("G14", "G15", "G16") if rule in missing), "PATCH", "", merges))
145
+ if "G19" in missing:
146
+ current = "<the default branch>"
147
+ if client is not None:
148
+ try:
149
+ current = str(client.get("").get("default_branch") or current)
150
+ except doctor.NotVerified:
151
+ pass
152
+ calls.append(("G19", "POST", f"/branches/{current}/rename", {"new_name": "main"}))
145
153
  return calls, notes
146
154
 
147
155
 
@@ -48,11 +48,24 @@ def normalise(text: str) -> str:
48
48
  return " ".join(text.split())
49
49
 
50
50
 
51
+ def trigger(playbook: str) -> str:
52
+ """A playbook's trigger, as its header says it — the kernel's words (§4), on one line."""
53
+ match = re.search(r"^> \*\*Trigger\.\*\* (.+?)(?=\n>\s*\n|\n\n|\Z)", playbook, re.S | re.M)
54
+ return normalise(match.group(1).replace("\n> ", " ")) if match else ""
55
+
56
+
57
+ def described(entry: dict, playbook: str) -> str:
58
+ """What the skill holds, from the mapping, and when it loads, from its playbook: one list of
59
+ triggers, the kernel's, which the playbook's header says again word for word."""
60
+ when = trigger(playbook)
61
+ return normalise(entry["description"]) + (f" {when}" if when else "")
62
+
63
+
51
64
  def build(root: Path, name: str, entry: dict) -> tuple[Path, str]:
52
65
  body = (root / entry["source"]).read_text(encoding="utf-8")
53
66
  # Serialised, never concatenated: a ":" or "#" in a description would break the YAML.
54
67
  frontmatter = yaml.safe_dump(
55
- {"name": name, "description": normalise(entry["description"])},
68
+ {"name": name, "description": described(entry, body)},
56
69
  allow_unicode=True, sort_keys=False, width=float("inf"),
57
70
  )
58
71
  content = "---\n" + frontmatter + "---\n\n" + BANNER.format(source=entry["source"]) + "\n\n" + body
@@ -22,5 +22,6 @@ contract tests do.
22
22
 
23
23
  ## Before changing anything
24
24
 
25
- Check the consumer matrix, generated from the manifests. A version is only removed once
25
+ Check who consumes it, read from the manifests: `nstack modules --with-contract-sides`, and
26
+ B6 for a version consumed and provided by nobody. A version is only removed once
26
27
  `consumers = 0`.
@@ -8,7 +8,7 @@
8
8
 
9
9
  ## Responsibility
10
10
 
11
- <One sentence. The same as the one in the MANIFEST.>
11
+ The one sentence of `MANIFEST.yaml` (`module.responsibility`); not repeated here.
12
12
 
13
13
  ## What this module does not do
14
14
 
@@ -24,8 +24,8 @@
24
24
 
25
25
  ## Known traps
26
26
 
27
- <What has already broken here. Every entry should become a test - see
28
- .nstack/handbook/10-measurement.md §4.>
27
+ <One line per trap — the situation, then what to do. Every entry becomes a test
28
+ (.nstack/handbook/10-measurement.md §4); a story of what happened goes to the log issue.>
29
29
 
30
30
  ## Areas not to change without approval
31
31
 
@@ -79,5 +79,4 @@ commands:
79
79
  docs:
80
80
  readme: README.md
81
81
  agents: AGENTS.md
82
- adr: docs/adr/
83
82
  # runbook: docs/runbook.md # required when criticality >= high
@@ -0,0 +1,21 @@
1
+ # {{MODULE_NAME}}
2
+
3
+ What this module is for, who owns it and how critical it is: its
4
+ [MANIFEST.yaml](./MANIFEST.yaml), the one place they are written.
5
+
6
+ <A diagram, with its legend: what this module provides, what it consumes, who uses it.>
7
+
8
+ ## Getting started
9
+
10
+ The standard verbs are declared in the `commands` section of the [manifest](./MANIFEST.yaml),
11
+ for this module's stack. Which they are, and what each one must do:
12
+ `.nstack/handbook/09-platform.md` §2.
13
+
14
+ ## Contracts
15
+
16
+ - Provided: see `provides` in the MANIFEST
17
+ - Consumed: see `consumes` in the MANIFEST
18
+
19
+ ## Decisions
20
+
21
+ In the project's `docs/adr/` and `docs/pdr/`, marked `Scope: module {{MODULE_NAME}}`.
@@ -1,24 +0,0 @@
1
- # {{MODULE_NAME}}
2
-
3
- <One sentence: what this module is for.>
4
-
5
- - **Owner**: {{OWNER}}
6
- - **Criticality**: {{CRITICALITY}}
7
- - **Manifest**: [MANIFEST.yaml](./MANIFEST.yaml)
8
-
9
- <A diagram, with its legend: what this module provides, what it consumes, who uses it.>
10
-
11
- ## Getting started
12
-
13
- The standard verbs — `bootstrap`, `check`, `test`, `run` — are declared in the `commands`
14
- section of the [manifest](./MANIFEST.yaml), for this module's stack. What each one must do:
15
- `.nstack/handbook/09-platform.md` §2.
16
-
17
- ## Contracts
18
-
19
- - Provided: see `provides` in the MANIFEST
20
- - Consumed: see `consumes` in the MANIFEST
21
-
22
- ## Decisions
23
-
24
- See `docs/adr/`.
File without changes