napkinstack 0.5.0__tar.gz → 0.6.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 (28) hide show
  1. {napkinstack-0.5.0 → napkinstack-0.6.0}/PKG-INFO +11 -7
  2. {napkinstack-0.5.0 → napkinstack-0.6.0}/README.md +10 -6
  3. {napkinstack-0.5.0 → napkinstack-0.6.0}/pyproject.toml +1 -1
  4. {napkinstack-0.5.0 → napkinstack-0.6.0}/pyproject.toml.orig +1 -1
  5. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/cli.py +5 -2
  6. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/fitness/boundaries.py +14 -0
  7. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/fitness/manifests.py +29 -1
  8. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/fitness/plan.py +11 -8
  9. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/modules.py +41 -5
  10. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/project.py +15 -3
  11. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -6
  12. {napkinstack-0.5.0 → napkinstack-0.6.0}/LICENSE +0 -0
  13. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/__init__.py +0 -0
  14. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/compat.py +0 -0
  15. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/discovery.py +0 -0
  16. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/doctor.py +0 -0
  17. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/fitness/__init__.py +0 -0
  18. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/fitness/hygiene.py +0 -0
  19. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/fitness/pr_scope.py +0 -0
  20. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/landed.py +0 -0
  21. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/provenance.py +0 -0
  22. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/pull_request.py +0 -0
  23. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/skills.py +0 -0
  24. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
  25. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/README.md +0 -0
  26. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/docs/adr/.gitkeep +0 -0
  27. {napkinstack-0.5.0 → napkinstack-0.6.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  28. {napkinstack-0.5.0 → napkinstack-0.6.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.5.0
3
+ Version: 0.6.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,18 +20,21 @@ 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.4.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): every barrier
23
+ > **Status: v0.5.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): every barrier
24
24
  > the framework announces refuses what it claims to — the boundaries read the contracts a
25
25
  > module uses, a contract version someone relies on changes only with a proof, the module
26
26
  > checks and stale approvals are enforced, a verifier is not an author, and every verdict names
27
27
  > the framework that gave it. Proved on the project that exposed the defects: eight probes
28
- > replayed, each refused or accepted as announced. A first pilot project, private, starts from
29
- > v0.5.0, which closes a way around the contract check found while proving it (D44) and says
30
- > what holds on a repository no plan lets the forge guard (PDR-0006).
28
+ > replayed, each refused or accepted as announced. The contract proof now runs in the base's
29
+ > tree, so a change cannot rewrite what judges it; the diagnosis says whether anything refuses
30
+ > at all; and a record names what reached the default branch outside a pull request — it
31
+ > records, it never refuses. A first pilot project, private, starts from here.
31
32
  > Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
32
33
  > [move to English](docs/governance/plans/2026-09-16-english-migration.md),
33
34
  > [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
34
35
  > [every barrier refuses](docs/governance/plans/2026-09-19-v0.4.0-every-barrier-refuses.md),
36
+ > [a proof that cannot be rewritten](docs/governance/plans/2026-09-20-v0.5.0-a-proof-that-cannot-be-rewritten.md),
37
+ > [the conformance suite](docs/governance/plans/2026-09-20-m12-conformance-suite.md),
35
38
  > [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
36
39
 
37
40
  ## A project's journey
@@ -86,8 +89,8 @@ To try it without installing anything, or to install it from this repository ins
86
89
  registry — always pinned to a release tag:
87
90
 
88
91
  ```bash
89
- uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.4.0" nstack init my-project
90
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.4.0"
92
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.5.0" nstack init my-project
93
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.5.0"
91
94
  ```
92
95
 
93
96
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -118,6 +121,7 @@ Each project then pins its version and changes it through `nstack update`.
118
121
  | `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
119
122
  | `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
120
123
  | `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
124
+ | `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
121
125
  | `nstack skills` | Exposes the playbooks as skills for the agent |
122
126
  | `nstack update` | Lays the new version on a branch to review |
123
127
 
@@ -5,18 +5,21 @@ 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.4.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): every barrier
8
+ > **Status: v0.5.0, published** ([PyPI](https://pypi.org/project/napkinstack/)): every barrier
9
9
  > the framework announces refuses what it claims to — the boundaries read the contracts a
10
10
  > module uses, a contract version someone relies on changes only with a proof, the module
11
11
  > checks and stale approvals are enforced, a verifier is not an author, and every verdict names
12
12
  > the framework that gave it. Proved on the project that exposed the defects: eight probes
13
- > replayed, each refused or accepted as announced. A first pilot project, private, starts from
14
- > v0.5.0, which closes a way around the contract check found while proving it (D44) and says
15
- > what holds on a repository no plan lets the forge guard (PDR-0006).
13
+ > replayed, each refused or accepted as announced. The contract proof now runs in the base's
14
+ > tree, so a change cannot rewrite what judges it; the diagnosis says whether anything refuses
15
+ > at all; and a record names what reached the default branch outside a pull request — it
16
+ > records, it never refuses. A first pilot project, private, starts from here.
16
17
  > Tracking: [engine roadmap](docs/governance/plans/2026-09-15-engine-v0.1.0.md),
17
18
  > [move to English](docs/governance/plans/2026-09-16-english-migration.md),
18
19
  > [frame, verify, approve](docs/governance/plans/2026-09-16-v0.3.0-frame-verify-approve.md),
19
20
  > [every barrier refuses](docs/governance/plans/2026-09-19-v0.4.0-every-barrier-refuses.md),
21
+ > [a proof that cannot be rewritten](docs/governance/plans/2026-09-20-v0.5.0-a-proof-that-cannot-be-rewritten.md),
22
+ > [the conformance suite](docs/governance/plans/2026-09-20-m12-conformance-suite.md),
20
23
  > [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
21
24
 
22
25
  ## A project's journey
@@ -71,8 +74,8 @@ To try it without installing anything, or to install it from this repository ins
71
74
  registry — always pinned to a release tag:
72
75
 
73
76
  ```bash
74
- uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.4.0" nstack init my-project
75
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.4.0"
77
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.5.0" nstack init my-project
78
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.5.0"
76
79
  ```
77
80
 
78
81
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -103,6 +106,7 @@ Each project then pins its version and changes it through `nstack update`.
103
106
  | `nstack compat [module] --base <base>` | A contract version consumed or stable changes only with the project's merged comparator |
104
107
  | `nstack e2e [module]` | Runs the module's end-to-end scenarios, when declared |
105
108
  | `nstack pr-check` | The test sheet and the cycle, read from the pull request description |
109
+ | `nstack landed --span <before>..<after>` | What reached this branch outside a pull request, recorded |
106
110
  | `nstack skills` | Exposes the playbooks as skills for the agent |
107
111
  | `nstack update` | Lays the new version on a branch to review |
108
112
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.5.0"
3
+ version = "0.6.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.5.0"
3
+ version = "0.6.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"
@@ -19,7 +19,7 @@ def _root(value: str) -> Path:
19
19
 
20
20
 
21
21
  def _modules(args: argparse.Namespace) -> int:
22
- found = modules.listing(args.root, args.changed_since)
22
+ found = modules.listing(args.root, args.changed_since, args.with_contract_sides)
23
23
  if found is None:
24
24
  print(f"FAIL [modules] base '{args.changed_since}' not found in {args.root}.\n"
25
25
  " Action: fetch the history (fetch-depth: 0), or pass an existing commit.")
@@ -63,7 +63,7 @@ def build_parser() -> argparse.ArgumentParser:
63
63
  sub = parser.add_subparsers(dest="command", required=True, metavar="command")
64
64
  _add(sub, "manifests", "manifests, lifecycles, deprecations (M1-M10)",
65
65
  lambda a: manifests.run(a.root))
66
- _add(sub, "boundaries", "declared graph against real graph (B1-B7)",
66
+ _add(sub, "boundaries", "declared graph against real graph (B1-B8)",
67
67
  lambda a: boundaries.run(a.root))
68
68
  _add(sub, "plan", "the discovery, the charter and the cycles (C1-C7)", lambda a: plan.run(a.root))
69
69
  sk = _add(sub, "skills", "generates or checks the skills (S1-S4)",
@@ -98,6 +98,9 @@ def build_parser() -> argparse.ArgumentParser:
98
98
  rn.add_argument("module")
99
99
  md = _add(sub, "modules", "the project's modules, or those with a file changed since a base", _modules)
100
100
  md.add_argument("--changed-since", metavar="BASE", help="only the modules with a file changed since BASE")
101
+ md.add_argument("--with-contract-sides", action="store_true",
102
+ help="also the producer and the declared consumers of a contract version "
103
+ "this change touches (with --changed-since)")
101
104
  md.add_argument("--json", action="store_true", help="a JSON list, for CI")
102
105
  ps = _add(sub, "pr-scope", "one PR = one module, review budget (P1-P2)",
103
106
  lambda a: pr_scope.run(a.root, a.base))
@@ -16,6 +16,8 @@ Rules:
16
16
  B5 no direct access to another module's data (tables declared elsewhere)
17
17
  B6 a consumed contract is provided: its contract, version and module match a provides entry
18
18
  B7 a provided contract exists: its path holds its document, inside a module's folder
19
+ B8 a contract consumed from a deprecated module (warning): a deprecated module takes no
20
+ new consumer, and the existing ones migrate before its removal date
19
21
 
20
22
  DETECTION — textual and deliberately simple, with no stack assumed.
21
23
  - A contract is read where a module's file names its path (contracts/billing-api/v1),
@@ -87,10 +89,13 @@ def load_modules(root: Path) -> dict[str, dict]:
87
89
  owns = section.get("owns") if isinstance(section.get("owns"), list) else []
88
90
  name = mod.get("name") if isinstance(mod.get("name"), str) and mod["name"] else manifest.parent.name
89
91
  code_name = mod.get("code_name")
92
+ deprecation = mod.get("deprecation") if isinstance(mod.get("deprecation"), dict) else {}
90
93
  modules[name] = {
91
94
  "path": manifest.parent,
92
95
  "dirname": manifest.parent.name,
93
96
  "code_name": code_name if isinstance(code_name, str) and code_name else name,
97
+ "lifecycle": mod.get("lifecycle") if isinstance(mod.get("lifecycle"), str) else None,
98
+ "removal": deprecation.get("removal_date"),
94
99
  "provides": contract_entries(data, "provides"),
95
100
  "consumes": contract_entries(data, "consumes"),
96
101
  "owns_data": {table for table in owns if isinstance(table, str)},
@@ -204,6 +209,15 @@ def check_contracts(root: Path, modules: dict[str, dict]) -> dict[str, dict[str,
204
209
  elif c.get("module") not in (None, producer):
205
210
  fail("B6", name, f"consumes {key[0]} {key[1]} from '{c.get('module')}', which is "
206
211
  f"provided by '{producer}'.\n Action: name the producer.")
212
+ # B8 - a deprecated producer. Reported, not refused: what is already declared is
213
+ # bounded by the removal date, which M5 turns red once it has passed.
214
+ elif modules[producer]["lifecycle"] == "deprecated":
215
+ when = modules[producer]["removal"]
216
+ warn("B8", name, f"consumes {key[0]} {key[1]} from '{producer}', which is deprecated"
217
+ + (f" (removal {when})" if when else "")
218
+ + ": a deprecated module takes no new consumer.\n Action: "
219
+ "migrate to the module that replaces it before that date "
220
+ "(docs/os/02-modules.md §6); M5 turns red once it has passed.")
207
221
 
208
222
  patterns = contract_patterns(modules)
209
223
  reads: dict[str, dict[str, set[str]]] = {name: {} for name in modules}
@@ -25,6 +25,7 @@ Output: 0 if everything passes, 1 otherwise. Every failure explains the rule br
25
25
  from __future__ import annotations
26
26
  import sys
27
27
  import datetime
28
+ import re
28
29
  import subprocess
29
30
  from pathlib import Path
30
31
 
@@ -86,6 +87,24 @@ def contract_entries(data: dict, section: str) -> list[dict]:
86
87
  and all(isinstance(entry.get(field), (str, type(None))) for field in CONTRACT_FIELDS[section])]
87
88
 
88
89
 
90
+ UNFILLED = re.compile(r"<[^<>\s][^<>]*>") # the angle-bracket placeholder of a template
91
+ TODO = re.compile(r"^TODO\b", re.I)
92
+
93
+
94
+ def unfilled(value) -> bool:
95
+ """Whether a declaration still holds the template's words: empty, carrying a placeholder
96
+ such as <github-handle>, or opening with TODO (D51).
97
+
98
+ A *declaration* is unfilled as soon as it carries a placeholder — nobody writes a success
99
+ criterion around one. A *cell* of a test sheet is unfilled only when it is nothing but a
100
+ placeholder, which is the template's example row: that one is `pull_request.PLACEHOLDER`,
101
+ and the two meanings are deliberately different. A placeholder opens on a non-space, so
102
+ "stays < 200 ms" is a comparison, not a placeholder.
103
+ """
104
+ text = str(value or "").strip()
105
+ return not text or bool(UNFILLED.search(text)) or bool(TODO.match(text))
106
+
107
+
89
108
  def is_description(path: str) -> bool:
90
109
  """A module's description — its manifest, AGENTS.md, README.md, docs/ — or an empty
91
110
  placeholder, `path` relative to the module's folder. Changing it changes no behaviour
@@ -208,9 +227,18 @@ def check_manifest(path: Path, today: datetime.date) -> None:
208
227
  fail(rel, "M6", f"contract {name}: removal date passed ({removal}). "
209
228
  "Finish the contraction (docs/os/03-contracts.md §4).")
210
229
 
211
- # M7 - standard verbs, once there is something to check (D33)
212
230
  commands = data.get("commands") or {}
213
231
  content = module_content(path.parent)
232
+
233
+ # M2 - the responsibility still the template's, once the module holds more than its
234
+ # description: a module is green as created (D33) and says what it does from its first
235
+ # file of code.
236
+ if content and unfilled(resp):
237
+ fail(rel, "M2", f'module.responsibility is still the template\'s: "{resp}"\n'
238
+ " Action: one sentence naming the capability this module covers, "
239
+ "in MANIFEST.yaml (docs/os/02-modules.md §5).")
240
+
241
+ # M7 - standard verbs, once there is something to check (D33)
214
242
  for verb in REQUIRED_COMMANDS:
215
243
  if content and not commands.get(verb):
216
244
  fail(rel, "M7", f"standard verb missing: commands.{verb}, and the module holds more "
@@ -31,6 +31,7 @@ from pathlib import Path
31
31
 
32
32
  import yaml
33
33
 
34
+ from napkinstack.fitness.manifests import unfilled
34
35
  from napkinstack.modules import HANDLE
35
36
 
36
37
  PROJECT = Path("docs") / "project"
@@ -114,8 +115,9 @@ def _check_charter(path: Path, fail) -> bool:
114
115
  fail("C2", path, f"status '{data.get('status')}': expected one of {sorted(CHARTER_STATUSES)}")
115
116
  check_decider("C2", path, data.get("decider"), fail)
116
117
  criteria = data.get("success_criteria")
117
- if not isinstance(criteria, list) or not [c for c in criteria if str(c or "").strip()]:
118
- fail("C2", path, "success_criteria: at least one, they say when the project itself ends")
118
+ if not isinstance(criteria, list) or not [c for c in criteria if not unfilled(c)]:
119
+ fail("C2", path, "success_criteria: at least one, filled in — they say when the project "
120
+ "itself ends. The template's example is not one")
119
121
  return data.get("status") == "accepted"
120
122
 
121
123
 
@@ -135,16 +137,17 @@ def _check_deliverables(path: Path, deliverables, fail) -> None:
135
137
  elif ident in seen:
136
138
  fail("C4", path, f"deliverable {ident}: id used twice")
137
139
  seen.add(ident)
138
- if not str(item.get("title") or "").strip():
139
- fail("C4", path, f"deliverable {where}: title missing")
140
+ if unfilled(item.get("title")):
141
+ fail("C4", path, f"deliverable {where}: title missing, or still the template's")
140
142
  state = item.get("state")
141
143
  if state not in DELIVERABLE_STATES:
142
144
  fail("C4", path, f"deliverable {where}: state '{state}', expected one of {sorted(DELIVERABLE_STATES)}")
143
145
  criteria = item.get("acceptance")
144
- filled = isinstance(criteria, list) and [c for c in criteria if str(c or "").strip()]
146
+ filled = isinstance(criteria, list) and [c for c in criteria if not unfilled(c)]
145
147
  if state in DELIVERABLE_STATES - WITHOUT_CRITERIA and not filled:
146
- fail("C4", path, f"deliverable {where}: state '{state}' requires acceptance criteria — "
147
- "the definition of ready, and the source of its test sheet (PDR-0003)")
148
+ fail("C4", path, f"deliverable {where}: state '{state}' requires acceptance criteria "
149
+ "filled in — the definition of ready, and the source of its test "
150
+ "sheet (PDR-0003)")
148
151
 
149
152
 
150
153
  def _check_cycle(path: Path, fail) -> str | None:
@@ -188,7 +191,7 @@ def _check_discovery(path: Path, fail) -> str | None:
188
191
  check_decider("C7", path, data.get("decider"), fail)
189
192
  if as_date(data.get("decided_on")) is None:
190
193
  fail("C7", path, f"a decision ({decision}) records decided_on, YYYY-MM-DD")
191
- if decision == "go" and not str(data.get("challenger") or "").strip():
194
+ if decision == "go" and unfilled(data.get("challenger")):
192
195
  fail("C7", path, "a go names its challenger: another session, or a human, challenged the "
193
196
  "document first (playbooks/discovery.md, stage 5)")
194
197
  return decision if isinstance(decision, str) else None
@@ -17,7 +17,8 @@ from pathlib import Path
17
17
 
18
18
  import yaml
19
19
 
20
- from napkinstack.fitness.manifests import changed_files, find_manifests, module_content
20
+ from napkinstack.fitness.manifests import (changed_files, contract_entries, find_manifests,
21
+ module_content)
21
22
 
22
23
  TEMPLATE = Path(__file__).resolve().parent / "templates" / "module"
23
24
  NAME = re.compile(r"[a-z][a-z0-9-]*")
@@ -134,9 +135,40 @@ def next_steps(root: Path, name: str, criticality: str, user_facing: bool) -> li
134
135
  return steps
135
136
 
136
137
 
137
- def listing(root: Path, base: str | None = None) -> list[dict[str, str]] | None:
138
- """Every module — a folder holding a MANIFEST.yaml, contracts/ and platform/ included —
139
- or, given a base, those with a file changed since it; None when the base is unknown."""
138
+ def _load(manifest: Path) -> dict:
139
+ try:
140
+ data = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {}
141
+ except yaml.YAMLError:
142
+ return {} # reported by nstack manifests (M2)
143
+ return data if isinstance(data, dict) else {}
144
+
145
+
146
+ def _contract_sides(root: Path, changed: list[str]) -> set[str]:
147
+ """The folders of the modules on either side of a contract version the change touches: the
148
+ module that provides it, and those that declare it in consumes. The handbook asks for the
149
+ checks of both sides (docs/os/03-contracts.md §5); the declared graph says who they are, so
150
+ one repository needs no broker to know (D53)."""
151
+ provided: dict[tuple[str, str], tuple[str, str]] = {}
152
+ declared: list[tuple[str, list[dict]]] = []
153
+ for manifest in find_manifests(root):
154
+ data = _load(manifest)
155
+ folder = manifest.parent.relative_to(root).as_posix()
156
+ for entry in contract_entries(data, "provides"):
157
+ key, path = (entry.get("contract"), entry.get("version")), entry.get("path")
158
+ if isinstance(path, str) and all(isinstance(part, str) for part in key):
159
+ provided[key] = (path.strip("/"), folder)
160
+ declared.append((folder, contract_entries(data, "consumes")))
161
+ touched = {key: folder for key, (path, folder) in provided.items()
162
+ if any(file == path or file.startswith(f"{path}/") for file in changed)}
163
+ return set(touched.values()) | {folder for folder, consumes in declared for entry in consumes
164
+ if (entry.get("contract"), entry.get("version")) in touched}
165
+
166
+
167
+ def listing(root: Path, base: str | None = None,
168
+ with_contract_sides: bool = False) -> list[dict[str, str]] | None:
169
+ """Every module — a folder holding a MANIFEST.yaml, contracts/ and platform/ included — or,
170
+ given a base, those with a file changed since it, and with `with_contract_sides` both sides
171
+ of a contract version the change touches (D53); None when the base is unknown."""
140
172
  found = [{"name": manifest.parent.name, "folder": manifest.parent.relative_to(root).as_posix()}
141
173
  for manifest in find_manifests(root)]
142
174
  if base is None:
@@ -145,7 +177,11 @@ def listing(root: Path, base: str | None = None) -> list[dict[str, str]] | None:
145
177
  capture_output=True).returncode:
146
178
  return None
147
179
  changed = changed_files(root, base)
148
- return [module for module in found if any(path.startswith(f"{module['folder']}/") for path in changed)]
180
+ folders = {module["folder"] for module in found
181
+ if any(path.startswith(f"{module['folder']}/") for path in changed)}
182
+ if with_contract_sides:
183
+ folders |= _contract_sides(root, changed)
184
+ return [module for module in found if module["folder"] in folders]
149
185
 
150
186
 
151
187
  def _modules(root: Path) -> dict[str, Path]:
@@ -55,8 +55,12 @@ def _explain(exc: Exception, command: str, where: Path, source: str, ref: str) -
55
55
  return (f"FAIL [{command}] Working tree modified in {where}: an update starts from a "
56
56
  f"committed state (PDR-0001).\n{action}commit or stash (git stash), then run again.")
57
57
  if match := DOWNGRADE.search(text):
58
+ # The action is a command, not a description of one: an install pinned to an exact
59
+ # version is not moved by `uv tool upgrade`, which is what a reader tries first.
58
60
  return (f"FAIL [{command}] Target version {match[2]} older than the project version ({match[1]}): "
59
- f"no going back (PDR-0001).\n{action}use nstack {match[1]} or newer.")
61
+ f"no going back (PDR-0001).\n{action}move this workstation forward first — "
62
+ 'uv tool install "napkinstack@latest" --with-executables-from pre-commit — '
63
+ f"or pin the version you want, {match[1]} or newer.")
60
64
  if text.startswith("Updating is only supported in git-tracked subprojects"):
61
65
  return (f"FAIL [{command}] {where} is not a git repository: the merge relies on "
62
66
  f"history.\n{action}git init, commit, then run again.")
@@ -64,9 +68,17 @@ def _explain(exc: Exception, command: str, where: Path, source: str, ref: str) -
64
68
  return (f"FAIL [{command}] The project does not come from a published version (_commit in {ANSWERS}): "
65
69
  f"no merge base.\n{action}create the project from a vX.Y.Z tag.")
66
70
  if isinstance(exc, OSError) or text == "Local template must be a directory.":
67
- detail = [line.split("|", 1)[-1].strip() for line in text.splitlines() if line.strip()][-1]
71
+ lines = [line.split("|", 1)[-1].strip() for line in text.splitlines() if line.strip()]
72
+ # git writes the cause on an `error:` line and the outcome on a `fatal:` one; keeping
73
+ # the last line alone drops the only one that says what happened (D54).
74
+ detail = " — ".join(line for line in lines if line.startswith(("error:", "fatal:"))) or lines[-1]
75
+ # A git `error:` line means git refused something local, so the action is local too:
76
+ # Copier copies the template's uncommitted state into its clone before reading it.
77
+ local = ("git refused a file of the template's working tree, which Copier copies as it "
78
+ "is: commit or stash it, or remove the file git names above.")
79
+ remote = "check --source and --ref (a vX.Y.Z tag), and network access."
68
80
  return (f"FAIL [{command}] Template {source} at version {ref} unreachable: {detail}\n"
69
- f"{action}check --source and --ref (a vX.Y.Z tag), and network access.")
81
+ + action + (local if detail.startswith("error:") else remote))
70
82
  return f"FAIL [{command}] Copier: {text}"
71
83
 
72
84
 
@@ -71,12 +71,6 @@ commands:
71
71
  # run: # optional: local start, with doubles for the dependencies
72
72
  # e2e: # optional: end-to-end scenarios; evidence written to .evidence/
73
73
 
74
- # Review budget. Inherited from the project when absent (docs/os/05-workflow.md §4).
75
- review_budget:
76
- max_lines: 400
77
- max_files: 15
78
- max_modules: 1 # not adjustable
79
-
80
74
  docs:
81
75
  readme: README.md
82
76
  agents: AGENTS.md
File without changes