napkinstack 0.16.0__tar.gz → 0.17.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 (38) hide show
  1. {napkinstack-0.16.0 → napkinstack-0.17.0}/PKG-INFO +5 -5
  2. {napkinstack-0.16.0 → napkinstack-0.17.0}/README.md +4 -4
  3. {napkinstack-0.16.0 → napkinstack-0.17.0}/pyproject.toml +1 -1
  4. {napkinstack-0.16.0 → napkinstack-0.17.0}/pyproject.toml.orig +1 -1
  5. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/board.py +1 -1
  6. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/cli.py +2 -2
  7. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/fitness/plan.py +198 -13
  8. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/hold.py +2 -2
  9. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/pull_request.py +1 -1
  10. {napkinstack-0.16.0 → napkinstack-0.17.0}/LICENSE +0 -0
  11. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/__init__.py +0 -0
  12. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/assurance.py +0 -0
  13. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/attest.py +0 -0
  14. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/compat.py +0 -0
  15. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/discovery.py +0 -0
  16. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/doctor.py +0 -0
  17. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/evidence.py +0 -0
  18. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/fitness/__init__.py +0 -0
  19. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/fitness/boundaries.py +0 -0
  20. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/fitness/hygiene.py +0 -0
  21. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/fitness/manifests.py +0 -0
  22. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/fitness/pr_scope.py +0 -0
  23. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/landed.py +0 -0
  24. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/modules.py +0 -0
  25. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/project.py +0 -0
  26. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/provenance.py +0 -0
  27. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/settings.py +0 -0
  28. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/skills.py +0 -0
  29. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
  30. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
  31. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/templates/contracts/README.md +0 -0
  32. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
  33. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
  34. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
  35. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -0
  36. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/templates/module/README.md +0 -0
  37. {napkinstack-0.16.0 → napkinstack-0.17.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  38. {napkinstack-0.16.0 → napkinstack-0.17.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.16.0
3
+ Version: 0.17.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.16.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
23
+ > **Status: v0.17.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.16.0" nstack init my-project
88
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.16.0"
87
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.17.0" nstack init my-project
88
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.17.0"
89
89
  ```
90
90
 
91
91
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -111,7 +111,7 @@ Each project then pins its version and changes it through `nstack update`.
111
111
  | `nstack new-contract <name>` | Creates a contract and, the first time, the module that holds them all |
112
112
  | `nstack check`, `test`, `bootstrap` `[module]`; `nstack run <module>` | Run the commands declared in the module's manifest |
113
113
  | `nstack discover <idea-file>` | Starts a discovery for your agent: the idea kept, its document created |
114
- | `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures |
114
+ | `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures; then the project across its cycles, and who holds what |
115
115
  | `nstack fitness` | Manifests, boundaries between modules, skills, plan, hygiene — each also runs alone: `manifests`, `boundaries`, `skills`, `plan`, `hygiene` |
116
116
  | `nstack pr-scope` | One PR = one module, review budget |
117
117
  | `nstack modules [--changed-since <base>]` | The project's modules, or those with a file changed since a base |
@@ -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.16.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
8
+ > **Status: v0.17.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.16.0" nstack init my-project
73
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.16.0"
72
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.17.0" nstack init my-project
73
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.17.0"
74
74
  ```
75
75
 
76
76
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -96,7 +96,7 @@ Each project then pins its version and changes it through `nstack update`.
96
96
  | `nstack new-contract <name>` | Creates a contract and, the first time, the module that holds them all |
97
97
  | `nstack check`, `test`, `bootstrap` `[module]`; `nstack run <module>` | Run the commands declared in the module's manifest |
98
98
  | `nstack discover <idea-file>` | Starts a discovery for your agent: the idea kept, its document created |
99
- | `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures |
99
+ | `nstack plan` | The discovery, the charter and the cycles: formats, one cycle at a time, closures; then the project across its cycles, and who holds what |
100
100
  | `nstack fitness` | Manifests, boundaries between modules, skills, plan, hygiene — each also runs alone: `manifests`, `boundaries`, `skills`, `plan`, `hygiene` |
101
101
  | `nstack pr-scope` | One PR = one module, review budget |
102
102
  | `nstack modules [--changed-since <base>]` | The project's modules, or those with a file changed since a base |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.16.0"
3
+ version = "0.17.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.16.0"
3
+ version = "0.17.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"
@@ -80,7 +80,7 @@ def desired(root: Path, holders: dict[str, str] | None = None, pulls: dict[str,
80
80
  for path in plan.cycle_files(root):
81
81
  data = plan.read_front_matter(path)[0]
82
82
  found = NUMBER.match(path.stem)
83
- if data and data.get("status") in SHOWN and found:
83
+ if data and plan.one_of(data.get("status"), SHOWN) and found:
84
84
  cycles.setdefault(found[1], []).append((path, data))
85
85
  said = [f"cycle {number} not shown: {len(files)} cycle files carry its number "
86
86
  f"({', '.join(path.name for path, _ in files)})" for number, files in cycles.items() if len(files) > 1]
@@ -100,8 +100,8 @@ def build_parser() -> argparse.ArgumentParser:
100
100
  lambda a: manifests.run(a.root))
101
101
  _add(sub, "boundaries", "declared graph against real graph, release artefacts included (B1-B9)",
102
102
  lambda a: boundaries.run(a.root))
103
- _add(sub, "plan", "the discovery, the charter and the cycles (C1-C8), and who holds what",
104
- lambda a: plan.run(a.root) | hold.show(a.root))
103
+ _add(sub, "plan", "the discovery, the charter and the cycles (C1-C8), the project across its cycles, "
104
+ "and who holds what", lambda a: plan.run(a.root) | plan.show(a.root) | hold.show(a.root))
105
105
  sk = _add(sub, "skills", "generates or checks the skills (S1-S4)",
106
106
  lambda a: skills.run(a.root, check_only=a.check))
107
107
  sk.add_argument("--check", action="store_true", help="check without writing")
@@ -12,14 +12,16 @@ Rules:
12
12
  C3 cycle: a goal, a known status, appetite_weeks, start and end, end = start + appetite
13
13
  C4 deliverables: ids D1, D2… unique, a title, a known state, acceptance criteria once ready
14
14
  C5 at most one accepted cycle, and only under an accepted charter
15
- C6 a closed or stopped cycle records its outcome and the date it ended
15
+ C6 a closed or stopped cycle records its outcome and the date it ended, and may record the
16
+ charter's success criteria it moved: each by its number, met or observing since a day
16
17
  C7 discovery: a known decision, its decider and date once decided, a challenger for a go;
17
18
  a charter follows a go
18
19
  C8 a seat is written when its moment comes: README.md holds its sentence once the
19
20
  discovery's decision is go, and none of its form's words once the charter is accepted;
20
21
  modules/README.md none of its own once a module holds more than its description
21
22
 
22
- Templates, whose file name starts with "_", are not checked.
23
+ Templates, whose file name starts with "_", are not checked. `nstack plan` then says the project
24
+ across its cycles (`project`), a display that never fails.
23
25
 
24
26
  Usage : nstack plan [--root ROOT]
25
27
  Output: 0 when the charter and the cycles are valid, or absent; 1 otherwise.
@@ -58,6 +60,7 @@ CYCLE_STATUSES = {"proposed", "accepted", "closed", "stopped"}
58
60
  DELIVERABLE_STATES = {"proposed", "ready", "in-progress", "accepted", "deferred", "dropped"}
59
61
  WITHOUT_CRITERIA = {"proposed", "deferred", "dropped"} # acceptance criteria not required yet
60
62
  OUTCOMES = {"closed": {"completed", "shipped"}, "stopped": {"reframed", "stopped"}}
63
+ SUCCESS_STATES = {"met", "observing"} # a criterion never recorded is not yet (PDR-0019)
61
64
  DELIVERABLE_ID = re.compile(r"D[1-9][0-9]*")
62
65
  FRONT_MATTER = re.compile(r"\A---\n(.*?)\n---\n", re.S)
63
66
  DECIDER = re.compile(rf"@?{HANDLE}") # one person, the way GitHub names people
@@ -76,14 +79,21 @@ def check_decider(rule: str, path: Path, value, fail) -> None:
76
79
 
77
80
 
78
81
  def read_front_matter(path: Path) -> tuple[dict | None, str]:
79
- """The front matter as a mapping, or None and the reason it cannot be read (C1)."""
80
- match = FRONT_MATTER.match(path.read_text(encoding="utf-8"))
82
+ """The front matter as a mapping, or None and the reason it cannot be read (C1). A file that
83
+ is not UTF-8, or a date no calendar holds — YAML builds it and Python refuses it — is a reason
84
+ like the others, never a traceback (#268)."""
85
+ try:
86
+ match = FRONT_MATTER.match(path.read_text(encoding="utf-8"))
87
+ except UnicodeDecodeError as exc:
88
+ return None, f"not UTF-8 text: {exc.reason} at byte {exc.start}; save the file as UTF-8"
81
89
  if not match:
82
90
  return None, "no front matter: the file must start with a YAML block between two --- lines"
83
91
  try:
84
92
  data = yaml.safe_load(match[1])
85
93
  except yaml.YAMLError as exc:
86
94
  return None, f"front matter unreadable: {exc}"
95
+ except ValueError as exc:
96
+ return None, f"front matter unreadable: a date that does not exist ({exc}); dates are YYYY-MM-DD"
87
97
  if not isinstance(data, dict):
88
98
  return None, "front matter must be a YAML mapping"
89
99
  return data, ""
@@ -100,6 +110,12 @@ def as_date(value) -> datetime.date | None:
100
110
  return None
101
111
 
102
112
 
113
+ def one_of(value, choices) -> bool:
114
+ """A front matter's value among the words a rule knows. A list or a mapping is none of them,
115
+ and never a traceback: `in` alone fails on a value it cannot hash."""
116
+ return isinstance(value, str) and value in choices
117
+
118
+
103
119
  def cycle_files(root: Path) -> list[Path]:
104
120
  folder = root / CYCLES
105
121
  return sorted(p for p in folder.glob("*.md") if not p.name.startswith("_")) if folder.is_dir() else []
@@ -188,6 +204,133 @@ def progress_of(path: Path, data: dict, today: datetime.date | None = None, appe
188
204
  return f"Cycle {path.stem}: " + " · ".join(said)
189
205
 
190
206
 
207
+ LATER = re.compile(r"^## Later[ \t]*\n(.*?)(?=^## |\Z)", re.S | re.M)
208
+ # The words the cycle form writes under *Later* (.nstack/forms/cycle.md; a test binds the two).
209
+ LATER_FORM = "<Candidates for the next framing: what framing left out, and ideas that came up during the cycle.>"
210
+ ITEM = re.compile(r"^(?:[-*+]|\d+[.)])\s", re.M) # a top-level item: one candidate each
211
+
212
+
213
+ def _cycle_said(path: Path, data: dict | None) -> str:
214
+ """One cycle of the project's history: its status, the date that matters for it, and its
215
+ deliverables as `progress_of` counts them — one figure, wherever it is said. What its own
216
+ rules refuse (C3, C6) is said plainly, never as a value printed raw."""
217
+ if data is None:
218
+ return "front matter unreadable (C1)"
219
+ status, counts = data.get("status"), progress_of(path, data, appetite=False).split(": ", 1)[1]
220
+ if not one_of(status, CYCLE_STATUSES):
221
+ return f"status unknown (C3) · {counts}"
222
+ if status in OUTCOMES:
223
+ ended = as_date(data.get("ended_on"))
224
+ outcome = data.get("outcome") if one_of(data.get("outcome"), OUTCOMES[status]) else "outcome unknown (C6)"
225
+ return f"{status} ({outcome})" + (f" on {ended}" if ended else "") + f" · {counts}"
226
+ key, said = ("end", "in progress, ends") if status == "accepted" else ("start", "proposed, starts")
227
+ date = as_date(data.get(key))
228
+ return (f"{said} {date}" if date else said.split(",")[0]) + f" · {counts}"
229
+
230
+
231
+ def _later(path: Path) -> str | None:
232
+ """What the cycle's *Later* holds for the next framing: its candidates counted when the
233
+ section is a list — an item each, its wrapped lines with it —, said as prose otherwise,
234
+ nothing when the section is absent or still the form's."""
235
+ match = LATER.search(path.read_text(encoding="utf-8", errors="replace"))
236
+ text = " ".join(match[1].split()) if match else "" # the form wraps its line
237
+ if not text or text == LATER_FORM:
238
+ return None
239
+ lines = [line for line in match[1].splitlines() if line.strip()]
240
+ count = len(ITEM.findall(match[1]))
241
+ prose = any(not ITEM.match(line) and not line[:1].isspace() for line in lines)
242
+ held = f"{count} candidate" + ("" if count == 1 else "s")
243
+ held = (f"{held} listed, and prose" if count else "candidates written as prose") if prose else held
244
+ return f"Later in {path.stem}, for the next framing: {held}"
245
+
246
+
247
+ def criteria(root: Path) -> list[int] | None:
248
+ """The numbers of the charter's success criteria, from 1 in the order it lists them, that
249
+ are filled in — the template's example left among them is listed and never counted, so the
250
+ charter's goal can be reached without declaring it met; None when no charter can be read."""
251
+ path = root / CHARTER
252
+ data = read_front_matter(path)[0] if path.is_file() else None
253
+ listed = data.get("success_criteria") if data else None
254
+ return [n for n, c in enumerate(listed, start=1) if not unfilled(c)] if isinstance(listed, list) else None
255
+
256
+
257
+ def _record(record) -> tuple[int, str, datetime.date | None] | None:
258
+ """One success record as the view reads it and C6 accepts it: a mapping, its criterion a
259
+ number from 1, its state known, a day when it is observed. None otherwise."""
260
+ if not isinstance(record, dict):
261
+ return None
262
+ number, state, since = record.get("criterion"), record.get("state"), as_date(record.get("since"))
263
+ if isinstance(number, bool) or not isinstance(number, int) or number < 1 or not one_of(state, SUCCESS_STATES):
264
+ return None
265
+ return None if state == "observing" and since is None else (number, state, since)
266
+
267
+
268
+ def success(read: list[tuple[Path, dict | None]]) -> dict[int, tuple[str, datetime.date | None]]:
269
+ """Each criterion's state: its latest record, in the order of the cycles — a later closure
270
+ may record a criterion met again as observing (PDR-0019). Only closures record."""
271
+ states: dict[int, tuple[str, datetime.date | None]] = {}
272
+ for _, data in read:
273
+ records = data.get("success") if data and one_of(data.get("status"), OUTCOMES) else None
274
+ for found in map(_record, records if isinstance(records, list) else []):
275
+ if found:
276
+ states[found[0]] = found[1:]
277
+ return states
278
+
279
+
280
+ def _success_said(numbers: list[int], states: dict[int, tuple[str, datetime.date | None]]) -> str:
281
+ """The project's own progress: the criteria met, then each one observed and since when."""
282
+ met = sum(1 for number in numbers if states.get(number, ("",))[0] == "met")
283
+ said = f"{met} of {len(numbers)} success criteri{'on' if len(numbers) == 1 else 'a'} met"
284
+ if numbers and met == len(numbers):
285
+ return said + " — the charter's goal is reached: the decider says whether the project is finished"
286
+ observed = [f"criterion {number} observed since {states[number][1]}"
287
+ for number in numbers if states.get(number, ("",))[0] == "observing"]
288
+ return " · ".join([said, *observed]) if observed or states else said + ", recorded at each cycle's closure"
289
+
290
+
291
+ def project(root: Path) -> list[str]:
292
+ """The project across its cycles, for `nstack plan`: the charter's success criteria counted,
293
+ every cycle in one line, and what the latest cycle left for the next framing — read from the
294
+ files, never written down. Deliverables are not summed across cycles: a deliverable deferred
295
+ by one cycle is often framed again in the next, and a total would count it twice. Nothing
296
+ outside a project, or before a charter or a cycle exists."""
297
+ charter, cycles = root / CHARTER, cycle_files(root)
298
+ if stage(root) is None or (not charter.is_file() and not cycles):
299
+ return []
300
+ data = read_front_matter(charter)[0] if charter.is_file() else None
301
+ read = [(path, read_front_matter(path)[0]) for path in cycles]
302
+ if data is None:
303
+ head = "no charter readable" if charter.is_file() else "no charter"
304
+ elif data.get("status") == "accepted":
305
+ head = _success_said(criteria(root) or [], success(read))
306
+ else:
307
+ head = "the charter is proposed" if data.get("status") == "proposed" else "the charter's status unknown (C2)"
308
+ statuses = [data.get("status") if data else None for _, data in read]
309
+ known = ("accepted", "proposed", "closed", "stopped")
310
+ tally = [f"{statuses.count(s)} {s}" for s in known if statuses.count(s)]
311
+ other = len(statuses) - sum(statuses.count(s) for s in known)
312
+ tally += [f"{other} other"] if other else []
313
+ lines = [f"Project: {head} · {len(read)} cycle{'' if len(read) == 1 else 's'}"
314
+ + (f": {', '.join(tally)}" if tally else "")]
315
+ width = max(len(path.stem) for path in cycles) if cycles else 0
316
+ lines += [f" {path.stem.ljust(width)} {_cycle_said(path, data)}" for path, data in read]
317
+ # The cycle running, else the last one ended — where the next framing starts —, else the last.
318
+ ended = [path for path, data in read if data and one_of(data.get("status"), OUTCOMES)]
319
+ latest = next((path for path, data in read if data and data.get("status") == "accepted"),
320
+ ended[-1] if ended else (cycles[-1] if cycles else None))
321
+ if latest and (later := _later(latest)):
322
+ lines.append(f" {later}")
323
+ return lines
324
+
325
+
326
+ def show(root: Path) -> int:
327
+ """`nstack plan`'s view of the project: said, never a failure of its own — a file the plan's
328
+ checks cannot read stops them first."""
329
+ if lines := project(root):
330
+ print("\n" + "\n".join(lines))
331
+ return 0
332
+
333
+
191
334
  def _left(path: Path, form: tuple[str, ...]) -> list[str]:
192
335
  """The words of `form` a seat still holds, as whole lines: a page may hold any tag."""
193
336
  lines = {line.strip() for line in path.read_text(encoding="utf-8", errors="replace").splitlines()}
@@ -230,7 +373,7 @@ def _check_charter(path: Path, fail) -> bool:
230
373
  if data is None:
231
374
  fail("C1", path, reason)
232
375
  return False
233
- if data.get("status") not in CHARTER_STATUSES:
376
+ if not one_of(data.get("status"), CHARTER_STATUSES):
234
377
  fail("C2", path, f"status '{data.get('status')}': expected one of {sorted(CHARTER_STATUSES)}")
235
378
  check_decider("C2", path, data.get("decider"), fail)
236
379
  criteria = data.get("success_criteria")
@@ -259,11 +402,11 @@ def _check_deliverables(path: Path, deliverables, fail) -> None:
259
402
  if unfilled(item.get("title")):
260
403
  fail("C4", path, f"deliverable {where}: title missing, or still the template's")
261
404
  state = item.get("state")
262
- if state not in DELIVERABLE_STATES:
405
+ if not one_of(state, DELIVERABLE_STATES):
263
406
  fail("C4", path, f"deliverable {where}: state '{state}', expected one of {sorted(DELIVERABLE_STATES)}")
264
407
  criteria = item.get("acceptance")
265
408
  filled = isinstance(criteria, list) and [c for c in criteria if not unfilled(c)]
266
- if state in DELIVERABLE_STATES - WITHOUT_CRITERIA and not filled:
409
+ if one_of(state, DELIVERABLE_STATES - WITHOUT_CRITERIA) and not filled:
267
410
  # The criteria are right, and the refusal names the door the framework already had.
268
411
  # Work whose product is knowledge is a SPIKE: its criteria name what will be recorded
269
412
  # and the threshold it is read against, and a refuted result is a delivered one. The
@@ -283,7 +426,47 @@ def _check_deliverables(path: Path, deliverables, fail) -> None:
283
426
  "proof.")
284
427
 
285
428
 
286
- def _check_cycle(path: Path, fail) -> str | None:
429
+ def _check_success(path: Path, data: dict, status, numbers: list[int] | None, fail) -> None:
430
+ """C6: the criteria a closure moved, each by its number in the charter's list (PDR-0019)."""
431
+ records = data.get("success")
432
+ if records is None:
433
+ return
434
+ action = ("Action: name each criterion by its number in docs/project/charter.md, from 1, as met, or as "
435
+ "observing with since: YYYY-MM-DD, the day observation started (.nstack/playbooks/framing.md, "
436
+ "the end of a cycle)")
437
+ if not one_of(status, OUTCOMES):
438
+ fail("C6", path, f"success: recorded by a cycle that is {status or 'of no known status'}, not closed "
439
+ "or stopped — a criterion moves at a closure, when the decider says so. Action: "
440
+ "record it in the closure's pull request, with status and outcome")
441
+ return
442
+ if not isinstance(records, list):
443
+ fail("C6", path, f"success: a list of records, one per criterion moved. {action}")
444
+ return
445
+ seen: set[int] = set()
446
+ for index, record in enumerate(records, start=1):
447
+ where = f"success record no. {index}"
448
+ if not isinstance(record, dict):
449
+ fail("C6", path, f"{where}: expected a mapping (criterion, state, since). {action}")
450
+ continue
451
+ number, state = record.get("criterion"), record.get("state")
452
+ if isinstance(number, bool) or not isinstance(number, int) or number < 1:
453
+ fail("C6", path, f"{where}: criterion '{number}', expected a number from 1. {action}")
454
+ elif number in seen:
455
+ fail("C6", path, f"{where}: criterion {number} recorded twice in this closure — keep the one "
456
+ "the decider said")
457
+ elif numbers is not None and number not in numbers:
458
+ fail("C6", path, f"{where}: criterion {number}, and the charter's filled criteria are numbered "
459
+ f"{', '.join(map(str, numbers)) or 'nothing'}. If a decision changed the charter's "
460
+ "list, renumber this record to match it, or remove it, with that decision")
461
+ if isinstance(number, int):
462
+ seen.add(number)
463
+ if not one_of(state, SUCCESS_STATES):
464
+ fail("C6", path, f"{where}: state '{state}', expected one of {sorted(SUCCESS_STATES)}. {action}")
465
+ elif state == "observing" and as_date(record.get("since")) is None:
466
+ fail("C6", path, f"{where}: observing since when? {action}")
467
+
468
+
469
+ def _check_cycle(path: Path, fail, numbers: list[int] | None = None) -> str | None:
287
470
  data, reason = read_front_matter(path)
288
471
  if data is None:
289
472
  fail("C1", path, reason)
@@ -291,7 +474,7 @@ def _check_cycle(path: Path, fail) -> str | None:
291
474
  if not str(data.get("goal") or "").strip():
292
475
  fail("C3", path, "goal missing: one sentence")
293
476
  status = data.get("status")
294
- if status not in CYCLE_STATUSES:
477
+ if not one_of(status, CYCLE_STATUSES):
295
478
  fail("C3", path, f"status '{status}': expected one of {sorted(CYCLE_STATUSES)}")
296
479
  weeks = data.get("appetite_weeks")
297
480
  if isinstance(weeks, bool) or not isinstance(weeks, int) or weeks < 1:
@@ -304,11 +487,12 @@ def _check_cycle(path: Path, fail) -> str | None:
304
487
  fail("C3", path, f"end {end}: the appetite sets it, start + {weeks} week(s) = "
305
488
  f"{start + datetime.timedelta(weeks=weeks)}")
306
489
  _check_deliverables(path, data.get("deliverables"), fail)
307
- if status in OUTCOMES:
308
- if data.get("outcome") not in OUTCOMES[status]:
490
+ if one_of(status, OUTCOMES):
491
+ if not one_of(data.get("outcome"), OUTCOMES[status]):
309
492
  fail("C6", path, f"a {status} cycle records its outcome: one of {sorted(OUTCOMES[status])}")
310
493
  if as_date(data.get("ended_on")) is None:
311
494
  fail("C6", path, f"a {status} cycle records ended_on, the date it ended")
495
+ _check_success(path, data, status, numbers, fail)
312
496
  return status if isinstance(status, str) else None
313
497
 
314
498
 
@@ -318,7 +502,7 @@ def _check_discovery(path: Path, fail) -> str | None:
318
502
  fail("C1", path, reason)
319
503
  return None
320
504
  decision = data.get("decision")
321
- if decision not in DECISIONS:
505
+ if not one_of(decision, DECISIONS):
322
506
  fail("C7", path, f"decision '{decision}': expected one of {sorted(DECISIONS)}")
323
507
  elif decision != "proposed":
324
508
  check_decider("C7", path, data.get("decider"), fail)
@@ -352,7 +536,8 @@ def run(root: Path) -> int:
352
536
  if accepted_charter and discovery.is_file() and decision != "go":
353
537
  fail("C7", charter, f"the charter is accepted, but the discovery's decision is '{decision}': "
354
538
  "a charter follows a go (docs/project/discovery.md)")
355
- accepted = [path for path in cycles if _check_cycle(path, fail) == "accepted"]
539
+ numbers = criteria(root)
540
+ accepted = [path for path in cycles if _check_cycle(path, fail, numbers) == "accepted"]
356
541
  if len(accepted) > 1:
357
542
  names = ", ".join(path.name for path in accepted)
358
543
  fail("C5", root / CYCLES, f"{len(accepted)} accepted cycles ({names}): one cycle at a time — "
@@ -179,7 +179,7 @@ def _deliverable(root: Path, ident: str) -> tuple[str, str] | str:
179
179
  if ident not in items:
180
180
  return (f"{ident} is not in {path.stem}.\n Action: name one of {', '.join(items) or 'its deliverables'}.")
181
181
  state = items[ident].get("state")
182
- if state not in TAKEABLE:
182
+ if not plan.one_of(state, TAKEABLE):
183
183
  return (f"{ident} is '{state}': work starts on a ready deliverable (definition of ready).\n"
184
184
  " Action: take a ready one — nstack plan lists them.")
185
185
  return path.stem, str(items[ident].get("title") or ident)
@@ -500,7 +500,7 @@ def show(root: Path) -> int:
500
500
  continue
501
501
  ident, state = str(item.get("id")), item.get("state")
502
502
  found = read(root, path.stem, ident)
503
- if not found.held and state not in TAKEABLE:
503
+ if not found.held and not plan.one_of(state, TAKEABLE):
504
504
  continue
505
505
  if not found.held:
506
506
  lines.append(f" {ident} free")
@@ -393,7 +393,7 @@ def check_cycle(root: Path, body: str, labels: set[str], today: datetime.date, f
393
393
  elif match[1] not in states:
394
394
  fail("K3", f"deliverable {match[1]} is not in {path.name}.\n Action: name one of "
395
395
  f"{', '.join(states) or 'its deliverables'}, or re-frame the cycle with the decider.")
396
- elif states[match[1]] not in {"ready", "in-progress"}:
396
+ elif not plan.one_of(states[match[1]], {"ready", "in-progress"}):
397
397
  fail("K3", f"deliverable {match[1]} is '{states[match[1]]}': work starts on a ready "
398
398
  "deliverable (definition of ready).")
399
399
  elif states[match[1]] == "ready":
File without changes