napkinstack 0.16.0__tar.gz → 0.18.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.18.0}/PKG-INFO +5 -5
  2. {napkinstack-0.16.0 → napkinstack-0.18.0}/README.md +4 -4
  3. {napkinstack-0.16.0 → napkinstack-0.18.0}/pyproject.toml +1 -1
  4. {napkinstack-0.16.0 → napkinstack-0.18.0}/pyproject.toml.orig +1 -1
  5. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/attest.py +58 -5
  6. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/board.py +1 -1
  7. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/cli.py +5 -3
  8. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/evidence.py +50 -0
  9. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/fitness/plan.py +260 -13
  10. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/hold.py +22 -9
  11. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/modules.py +14 -8
  12. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/provenance.py +4 -0
  13. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/pull_request.py +9 -5
  14. {napkinstack-0.16.0 → napkinstack-0.18.0}/LICENSE +0 -0
  15. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/__init__.py +0 -0
  16. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/assurance.py +0 -0
  17. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/compat.py +0 -0
  18. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/discovery.py +0 -0
  19. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/doctor.py +0 -0
  20. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/fitness/__init__.py +0 -0
  21. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/fitness/boundaries.py +0 -0
  22. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/fitness/hygiene.py +0 -0
  23. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/fitness/manifests.py +0 -0
  24. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/fitness/pr_scope.py +0 -0
  25. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/landed.py +0 -0
  26. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/project.py +0 -0
  27. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/settings.py +0 -0
  28. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/skills.py +0 -0
  29. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
  30. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
  31. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/templates/contracts/README.md +0 -0
  32. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
  33. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
  34. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
  35. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -0
  36. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/templates/module/README.md +0 -0
  37. {napkinstack-0.16.0 → napkinstack-0.18.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  38. {napkinstack-0.16.0 → napkinstack-0.18.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.18.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.18.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.18.0" nstack init my-project
88
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.18.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.18.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.18.0" nstack init my-project
73
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.18.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.18.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.18.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"
@@ -46,6 +46,7 @@ class Step:
46
46
  class Check:
47
47
  name: str # the CI job's name
48
48
  steps: tuple[Step, ...]
49
+ kept: bool = False # reads only the tree and the base: a pass on the same commit, base and engine stands (#275)
49
50
 
50
51
 
51
52
  # The project's CI, job by job, as a workstation runs it (skeleton/.github/workflows/). A job or a
@@ -69,7 +70,7 @@ CHECKS: tuple[Check, ...] = (
69
70
  'nstack pr-check --root . --base "$NSTACK_BASE" --body-file "$NSTACK_BODY"'),)),
70
71
  Check("Module checks", (
71
72
  Step("bash .nstack/check-modules.sh", "Every changed module",
72
- 'BASE="$NSTACK_BASE" bash .nstack/check-modules.sh'),)),
73
+ 'BASE="$NSTACK_BASE" bash .nstack/check-modules.sh'),), kept=True),
73
74
  )
74
75
 
75
76
 
@@ -88,6 +89,23 @@ def capped(text: str) -> str:
88
89
  + data[-half:].decode("utf-8", "ignore"))
89
90
 
90
91
 
92
+ def unplaced(value, root: Path):
93
+ """What a record keeps, said nowhere on the machine that attested: the checkout's root as
94
+ `<root>`, the home folder as `~` (#277). The notes are pushed, and public with the repository;
95
+ a path names a person and their folders. Strings inside lists and mappings too."""
96
+ if isinstance(value, str):
97
+ for folder, said in ((root.resolve(), "<root>"), (root.absolute(), "<root>"),
98
+ (Path.home().resolve(), "~"), (Path.home(), "~")):
99
+ if str(folder) not in ("", "/"): # a whole folder only: never a neighbour, never inside a longer path
100
+ value = re.sub(r"(?<![\w.-])" + re.escape(str(folder)) + r"(?=$|[/\s'\"`)\]}>:,;])", said, value)
101
+ return value
102
+ if isinstance(value, list):
103
+ return [unplaced(item, root) for item in value]
104
+ if isinstance(value, dict):
105
+ return {key: unplaced(item, root) for key, item in value.items()}
106
+ return value
107
+
108
+
91
109
  def latest(root: Path, commit: str) -> dict | None:
92
110
  """The record of a commit: the latest run its note holds, or None."""
93
111
  shown = _git(root, "notes", f"--ref={NOTES}", "show", commit, check=False)
@@ -207,7 +225,31 @@ def why(output: str) -> list[str]:
207
225
  return said or [line for line in lines if line.strip()][-REASON:]
208
226
 
209
227
 
210
- def run(root: Path, base: str, body_file: Path | None, labels: str | None, accept_identity: bool = False) -> int:
228
+ # An engine whose code a string names: published, a forge's commit, or a checkout's own commit.
229
+ NAMED = re.compile(r".* \(commit [0-9a-f]{7,}\)|.*@[0-9a-f]{7,}|a local checkout at (?!an unknown commit)\S+(?<!-dirty)")
230
+
231
+
232
+ def _kept(previous: dict | None, check: Check, engine: str, origin: str | None, base_commit: str | None,
233
+ who: str, said: str | None = None) -> dict | None:
234
+ """The check as the commit's record last passed it, when what it reads is the same: the commit,
235
+ the base commit, the engine — published, or one whose commit names its code — and the person who
236
+ ran it. A sheet filled in between changes the description, which this check never reads (#275).
237
+ Not compared: what the tree's ignored files, the environment or the tools outside it hold."""
238
+ said = origin if said is None else said # the origin as the record wrote it, its paths unplaced (D162)
239
+ if not check.kept or previous is None or base_commit is None or not (origin is None or NAMED.fullmatch(origin)):
240
+ return None
241
+ if (previous.get("engine"), previous.get("origin"), previous.get("published"), previous.get("base_commit"),
242
+ previous.get("by")) != (engine, said, origin is None, base_commit, who):
243
+ return None
244
+ checks = previous.get("checks") if isinstance(previous.get("checks"), list) else []
245
+ found = next((c for c in checks if isinstance(c, dict) and c.get("name") == check.name), None)
246
+ if not found or found.get("passed") is not True:
247
+ return None
248
+ return {**found, "kept_from": found.get("kept_from") or previous.get("at")}
249
+
250
+
251
+ def run(root: Path, base: str, body_file: Path | None, labels: str | None, accept_identity: bool = False,
252
+ again: bool = False) -> int:
211
253
  print(provenance.judged_by(root), flush=True)
212
254
  who, address = _identity(root)
213
255
  remote = _remote(root)
@@ -233,6 +275,8 @@ def run(root: Path, base: str, body_file: Path | None, labels: str | None, accep
233
275
  return 1
234
276
  commit = _git(root, "rev-parse", "HEAD").stdout.strip()
235
277
  engine, origin = provenance.engine()
278
+ base_commit = _git(root, "rev-parse", "--verify", "--quiet", f"{base}^{{commit}}", check=False).stdout.strip() or None
279
+ previous = None if again else latest(root, commit)
236
280
  # The option, else PR_LABELS as the caller set it: one way for attest and pr-check (D147).
237
281
  env = dict(os.environ, PR_LABELS=os.environ.get("PR_LABELS", "") if labels is None else labels)
238
282
  # The engine that runs this command runs the checks: its folder first on the path.
@@ -247,6 +291,12 @@ def run(root: Path, base: str, body_file: Path | None, labels: str | None, accep
247
291
  env.update(NSTACK_BASE=base, NSTACK_BODY=str(Path(body).resolve()), NSTACK_TMP=tmp,
248
292
  NSTACK_ATTESTING=commit)
249
293
  for check in CHECKS:
294
+ kept = _kept(previous, check, engine, origin, base_commit, who, unplaced(origin, root))
295
+ if kept is not None:
296
+ results.append(kept)
297
+ print(f"ok [attest] {check.name}: kept from the record of {kept['kept_from']} — same commit, base "
298
+ "and engine (nstack attest --again runs it)", flush=True)
299
+ continue
250
300
  steps = []
251
301
  for step in check.steps:
252
302
  done = subprocess.run(["bash", "-c", step.command], cwd=root, env=env,
@@ -263,12 +313,15 @@ def run(root: Path, base: str, body_file: Path | None, labels: str | None, accep
263
313
  print("\n".join(f" {line}" for line in why(step["output"])), flush=True)
264
314
  record = {"record": RECORD, "commit": commit,
265
315
  "at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="microseconds"),
266
- "engine": engine, "published": origin is None, "by": who, "session": _session(root, commit),
267
- "base": base, "checks": results, "evidence": evidence_of(root, commit)}
316
+ "engine": engine, "origin": origin, "published": origin is None, "by": who, "session": _session(root, commit),
317
+ "base": base, "base_commit": base_commit, "checks": results,
318
+ # The audits of the run the module checks come from: kept with them, never read again (#275).
319
+ "evidence": (previous.get("evidence") if isinstance(previous.get("evidence"), list) else [])
320
+ if previous and any(r.get("kept_from") for r in results) else evidence_of(root, commit)}
268
321
  remote = fetch(root)
269
322
  before = _git(root, "notes", f"--ref={NOTES}", "show", commit, check=False)
270
323
  lines = (before.stdout.rstrip("\n") + "\n" if before.returncode == 0 and before.stdout.strip() else "")
271
- lines += json.dumps(record, separators=(",", ":"), ensure_ascii=False) + "\n"
324
+ lines += json.dumps(unplaced(record, root), separators=(",", ":"), ensure_ascii=False) + "\n"
272
325
  subprocess.run(["git", "notes", f"--ref={NOTES}", "add", "--force", "--file=-", commit], cwd=root,
273
326
  input=lines, text=True, capture_output=True, check=True)
274
327
  passed = sum(1 for result in results if result["passed"])
@@ -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 decisions due at a closure "
104
+ "(C10), the project across its cycles, 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")
@@ -175,12 +175,14 @@ def build_parser() -> argparse.ArgumentParser:
175
175
  ld.add_argument("--span", default="HEAD~1..HEAD",
176
176
  help="the commits to read, BEFORE..AFTER (default: the last commit)")
177
177
  at = _add(sub, "attest", "runs the CI's checks here and records the run on the commit, in git",
178
- lambda a: attest.run(a.root, a.base, a.body_file, a.labels, a.accept_identity))
178
+ lambda a: attest.run(a.root, a.base, a.body_file, a.labels, a.accept_identity, a.again))
179
179
  at.add_argument("--base", default="origin/main")
180
180
  at.add_argument("--body-file", type=Path, help="the pull request's description, for its checks")
181
181
  at.add_argument("--labels", help="the pull request's labels, comma-separated (default: PR_LABELS)")
182
182
  at.add_argument("--accept-identity", action="store_true",
183
183
  help="record under an identity the project's history does not show yet — a first record of yours")
184
+ at.add_argument("--again", action="store_true",
185
+ help="runs every check, even the module checks this commit's record passed with the same base and engine")
184
186
  cl = _add(sub, "claim", "takes a ready deliverable for a session, before its first change; "
185
187
  "the shared remote refuses a second taker",
186
188
  lambda a: hold.claim(a.root, a.deliverable, a.session, a.person, a.take_over))
@@ -9,6 +9,8 @@ version that brings the audit only reports; the project's next minor version jud
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import contextlib
13
+ import datetime
12
14
  import json
13
15
  import os
14
16
  import re
@@ -16,6 +18,7 @@ import select
16
18
  import signal
17
19
  import subprocess
18
20
  import sys
21
+ import tempfile
19
22
  import time
20
23
  from pathlib import Path
21
24
 
@@ -262,6 +265,53 @@ def prepare(root: Path, module: Path) -> Path | str:
262
265
  return folder
263
266
 
264
267
 
268
+ HELD = "NSTACK_E2E_HELD" # set for what the run starts: an e2e inside an e2e waits for nothing
269
+
270
+
271
+ @contextlib.contextmanager
272
+ def one_at_a_time(root: Path, target: str):
273
+ """One e2e at a time on this machine, for this user, whichever project or command starts it —
274
+ `nstack e2e`, or `nstack attest` through it: what an end-to-end run drives (a sound server, a
275
+ display, a port) is the machine's, and two runs reading it see each other's (#287). An advisory
276
+ lock, `flock(2)`, released by the system when the holder exits however it exits; the one that
277
+ waits says for whom."""
278
+ try:
279
+ import fcntl
280
+ except ImportError: # no flock on this system: the runs are not kept apart
281
+ yield
282
+ return
283
+ if os.environ.get(HELD):
284
+ yield
285
+ return
286
+ folder = os.environ.get("XDG_RUNTIME_DIR")
287
+ path = Path(folder) / "nstack-e2e.lock" if folder else Path(tempfile.gettempdir()) / f"nstack-e2e-{os.getuid()}.lock"
288
+ try: # never through a link: in a shared temporary folder, another user could have placed one
289
+ descriptor = os.open(path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
290
+ except OSError as error:
291
+ print(f"-> {target}: not kept apart from another end-to-end run: {path} cannot be opened ({error.strerror})",
292
+ flush=True)
293
+ yield
294
+ return
295
+ with open(descriptor, "r+", encoding="utf-8") as handle:
296
+ try:
297
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
298
+ except BlockingIOError:
299
+ handle.seek(0)
300
+ holder = handle.read().strip() or "another run"
301
+ print(f"-> {target}: waits for the end-to-end run already on this machine — {holder}", flush=True)
302
+ fcntl.flock(handle, fcntl.LOCK_EX)
303
+ handle.seek(0)
304
+ handle.truncate()
305
+ started = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
306
+ handle.write(f"{target}, in {root.resolve()}, process {os.getpid()}, since {started}\n")
307
+ handle.flush()
308
+ try:
309
+ yield
310
+ finally:
311
+ handle.seek(0)
312
+ handle.truncate()
313
+
314
+
265
315
  def run(command: str, cwd: Path, environment: dict, folder: Path) -> tuple[int, str | None]:
266
316
  """The command, its output shown live and kept as it comes, in bytes; the process waited for,
267
317
  the pipe drained two seconds after. Returns its exit code, and what still held the pipe."""
@@ -12,14 +12,19 @@ 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
22
+ C10 (a warning) a closure reads the decisions whose criterion is due: an accepted record of
23
+ docs/pdr/ or docs/adr/ whose success criterion names a closed or stopped cycle, or a date
24
+ its end has reached, says so in a reading line (PDR-0020)
21
25
 
22
- Templates, whose file name starts with "_", are not checked.
26
+ Templates, whose file name starts with "_", are not checked. `nstack plan` then says the project
27
+ across its cycles (`project`), a display that never fails.
23
28
 
24
29
  Usage : nstack plan [--root ROOT]
25
30
  Output: 0 when the charter and the cycles are valid, or absent; 1 otherwise.
@@ -58,6 +63,7 @@ CYCLE_STATUSES = {"proposed", "accepted", "closed", "stopped"}
58
63
  DELIVERABLE_STATES = {"proposed", "ready", "in-progress", "accepted", "deferred", "dropped"}
59
64
  WITHOUT_CRITERIA = {"proposed", "deferred", "dropped"} # acceptance criteria not required yet
60
65
  OUTCOMES = {"closed": {"completed", "shipped"}, "stopped": {"reframed", "stopped"}}
66
+ SUCCESS_STATES = {"met", "observing"} # a criterion never recorded is not yet (PDR-0019)
61
67
  DELIVERABLE_ID = re.compile(r"D[1-9][0-9]*")
62
68
  FRONT_MATTER = re.compile(r"\A---\n(.*?)\n---\n", re.S)
63
69
  DECIDER = re.compile(rf"@?{HANDLE}") # one person, the way GitHub names people
@@ -76,14 +82,21 @@ def check_decider(rule: str, path: Path, value, fail) -> None:
76
82
 
77
83
 
78
84
  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"))
85
+ """The front matter as a mapping, or None and the reason it cannot be read (C1). A file that
86
+ is not UTF-8, or a date no calendar holds — YAML builds it and Python refuses it — is a reason
87
+ like the others, never a traceback (#268)."""
88
+ try:
89
+ match = FRONT_MATTER.match(path.read_text(encoding="utf-8"))
90
+ except UnicodeDecodeError as exc:
91
+ return None, f"not UTF-8 text: {exc.reason} at byte {exc.start}; save the file as UTF-8"
81
92
  if not match:
82
93
  return None, "no front matter: the file must start with a YAML block between two --- lines"
83
94
  try:
84
95
  data = yaml.safe_load(match[1])
85
96
  except yaml.YAMLError as exc:
86
97
  return None, f"front matter unreadable: {exc}"
98
+ except ValueError as exc:
99
+ return None, f"front matter unreadable: a date that does not exist ({exc}); dates are YYYY-MM-DD"
87
100
  if not isinstance(data, dict):
88
101
  return None, "front matter must be a YAML mapping"
89
102
  return data, ""
@@ -100,6 +113,12 @@ def as_date(value) -> datetime.date | None:
100
113
  return None
101
114
 
102
115
 
116
+ def one_of(value, choices) -> bool:
117
+ """A front matter's value among the words a rule knows. A list or a mapping is none of them,
118
+ and never a traceback: `in` alone fails on a value it cannot hash."""
119
+ return isinstance(value, str) and value in choices
120
+
121
+
103
122
  def cycle_files(root: Path) -> list[Path]:
104
123
  folder = root / CYCLES
105
124
  return sorted(p for p in folder.glob("*.md") if not p.name.startswith("_")) if folder.is_dir() else []
@@ -188,6 +207,133 @@ def progress_of(path: Path, data: dict, today: datetime.date | None = None, appe
188
207
  return f"Cycle {path.stem}: " + " · ".join(said)
189
208
 
190
209
 
210
+ LATER = re.compile(r"^## Later[ \t]*\n(.*?)(?=^## |\Z)", re.S | re.M)
211
+ # The words the cycle form writes under *Later* (.nstack/forms/cycle.md; a test binds the two).
212
+ LATER_FORM = "<Candidates for the next framing: what framing left out, and ideas that came up during the cycle.>"
213
+ ITEM = re.compile(r"^(?:[-*+]|\d+[.)])\s", re.M) # a top-level item: one candidate each
214
+
215
+
216
+ def _cycle_said(path: Path, data: dict | None) -> str:
217
+ """One cycle of the project's history: its status, the date that matters for it, and its
218
+ deliverables as `progress_of` counts them — one figure, wherever it is said. What its own
219
+ rules refuse (C3, C6) is said plainly, never as a value printed raw."""
220
+ if data is None:
221
+ return "front matter unreadable (C1)"
222
+ status, counts = data.get("status"), progress_of(path, data, appetite=False).split(": ", 1)[1]
223
+ if not one_of(status, CYCLE_STATUSES):
224
+ return f"status unknown (C3) · {counts}"
225
+ if status in OUTCOMES:
226
+ ended = as_date(data.get("ended_on"))
227
+ outcome = data.get("outcome") if one_of(data.get("outcome"), OUTCOMES[status]) else "outcome unknown (C6)"
228
+ return f"{status} ({outcome})" + (f" on {ended}" if ended else "") + f" · {counts}"
229
+ key, said = ("end", "in progress, ends") if status == "accepted" else ("start", "proposed, starts")
230
+ date = as_date(data.get(key))
231
+ return (f"{said} {date}" if date else said.split(",")[0]) + f" · {counts}"
232
+
233
+
234
+ def _later(path: Path) -> str | None:
235
+ """What the cycle's *Later* holds for the next framing: its candidates counted when the
236
+ section is a list — an item each, its wrapped lines with it —, said as prose otherwise,
237
+ nothing when the section is absent or still the form's."""
238
+ match = LATER.search(path.read_text(encoding="utf-8", errors="replace"))
239
+ text = " ".join(match[1].split()) if match else "" # the form wraps its line
240
+ if not text or text == LATER_FORM:
241
+ return None
242
+ lines = [line for line in match[1].splitlines() if line.strip()]
243
+ count = len(ITEM.findall(match[1]))
244
+ prose = any(not ITEM.match(line) and not line[:1].isspace() for line in lines)
245
+ held = f"{count} candidate" + ("" if count == 1 else "s")
246
+ held = (f"{held} listed, and prose" if count else "candidates written as prose") if prose else held
247
+ return f"Later in {path.stem}, for the next framing: {held}"
248
+
249
+
250
+ def criteria(root: Path) -> list[int] | None:
251
+ """The numbers of the charter's success criteria, from 1 in the order it lists them, that
252
+ are filled in — the template's example left among them is listed and never counted, so the
253
+ charter's goal can be reached without declaring it met; None when no charter can be read."""
254
+ path = root / CHARTER
255
+ data = read_front_matter(path)[0] if path.is_file() else None
256
+ listed = data.get("success_criteria") if data else None
257
+ return [n for n, c in enumerate(listed, start=1) if not unfilled(c)] if isinstance(listed, list) else None
258
+
259
+
260
+ def _record(record) -> tuple[int, str, datetime.date | None] | None:
261
+ """One success record as the view reads it and C6 accepts it: a mapping, its criterion a
262
+ number from 1, its state known, a day when it is observed. None otherwise."""
263
+ if not isinstance(record, dict):
264
+ return None
265
+ number, state, since = record.get("criterion"), record.get("state"), as_date(record.get("since"))
266
+ if isinstance(number, bool) or not isinstance(number, int) or number < 1 or not one_of(state, SUCCESS_STATES):
267
+ return None
268
+ return None if state == "observing" and since is None else (number, state, since)
269
+
270
+
271
+ def success(read: list[tuple[Path, dict | None]]) -> dict[int, tuple[str, datetime.date | None]]:
272
+ """Each criterion's state: its latest record, in the order of the cycles — a later closure
273
+ may record a criterion met again as observing (PDR-0019). Only closures record."""
274
+ states: dict[int, tuple[str, datetime.date | None]] = {}
275
+ for _, data in read:
276
+ records = data.get("success") if data and one_of(data.get("status"), OUTCOMES) else None
277
+ for found in map(_record, records if isinstance(records, list) else []):
278
+ if found:
279
+ states[found[0]] = found[1:]
280
+ return states
281
+
282
+
283
+ def _success_said(numbers: list[int], states: dict[int, tuple[str, datetime.date | None]]) -> str:
284
+ """The project's own progress: the criteria met, then each one observed and since when."""
285
+ met = sum(1 for number in numbers if states.get(number, ("",))[0] == "met")
286
+ said = f"{met} of {len(numbers)} success criteri{'on' if len(numbers) == 1 else 'a'} met"
287
+ if numbers and met == len(numbers):
288
+ return said + " — the charter's goal is reached: the decider says whether the project is finished"
289
+ observed = [f"criterion {number} observed since {states[number][1]}"
290
+ for number in numbers if states.get(number, ("",))[0] == "observing"]
291
+ return " · ".join([said, *observed]) if observed or states else said + ", recorded at each cycle's closure"
292
+
293
+
294
+ def project(root: Path) -> list[str]:
295
+ """The project across its cycles, for `nstack plan`: the charter's success criteria counted,
296
+ every cycle in one line, and what the latest cycle left for the next framing — read from the
297
+ files, never written down. Deliverables are not summed across cycles: a deliverable deferred
298
+ by one cycle is often framed again in the next, and a total would count it twice. Nothing
299
+ outside a project, or before a charter or a cycle exists."""
300
+ charter, cycles = root / CHARTER, cycle_files(root)
301
+ if stage(root) is None or (not charter.is_file() and not cycles):
302
+ return []
303
+ data = read_front_matter(charter)[0] if charter.is_file() else None
304
+ read = [(path, read_front_matter(path)[0]) for path in cycles]
305
+ if data is None:
306
+ head = "no charter readable" if charter.is_file() else "no charter"
307
+ elif data.get("status") == "accepted":
308
+ head = _success_said(criteria(root) or [], success(read))
309
+ else:
310
+ head = "the charter is proposed" if data.get("status") == "proposed" else "the charter's status unknown (C2)"
311
+ statuses = [data.get("status") if data else None for _, data in read]
312
+ known = ("accepted", "proposed", "closed", "stopped")
313
+ tally = [f"{statuses.count(s)} {s}" for s in known if statuses.count(s)]
314
+ other = len(statuses) - sum(statuses.count(s) for s in known)
315
+ tally += [f"{other} other"] if other else []
316
+ lines = [f"Project: {head} · {len(read)} cycle{'' if len(read) == 1 else 's'}"
317
+ + (f": {', '.join(tally)}" if tally else "")]
318
+ width = max(len(path.stem) for path in cycles) if cycles else 0
319
+ lines += [f" {path.stem.ljust(width)} {_cycle_said(path, data)}" for path, data in read]
320
+ # The cycle running, else the last one ended — where the next framing starts —, else the last.
321
+ ended = [path for path, data in read if data and one_of(data.get("status"), OUTCOMES)]
322
+ latest = next((path for path, data in read if data and data.get("status") == "accepted"),
323
+ ended[-1] if ended else (cycles[-1] if cycles else None))
324
+ if latest and (later := _later(latest)):
325
+ lines.append(f" {later}")
326
+ return lines
327
+
328
+
329
+ def show(root: Path) -> int:
330
+ """`nstack plan`'s view of the project: said, never a failure of its own — a file the plan's
331
+ checks cannot read stops them first."""
332
+ if lines := project(root):
333
+ print("\n" + "\n".join(lines))
334
+ return 0
335
+
336
+
191
337
  def _left(path: Path, form: tuple[str, ...]) -> list[str]:
192
338
  """The words of `form` a seat still holds, as whole lines: a page may hold any tag."""
193
339
  lines = {line.strip() for line in path.read_text(encoding="utf-8", errors="replace").splitlines()}
@@ -230,7 +376,7 @@ def _check_charter(path: Path, fail) -> bool:
230
376
  if data is None:
231
377
  fail("C1", path, reason)
232
378
  return False
233
- if data.get("status") not in CHARTER_STATUSES:
379
+ if not one_of(data.get("status"), CHARTER_STATUSES):
234
380
  fail("C2", path, f"status '{data.get('status')}': expected one of {sorted(CHARTER_STATUSES)}")
235
381
  check_decider("C2", path, data.get("decider"), fail)
236
382
  criteria = data.get("success_criteria")
@@ -259,11 +405,11 @@ def _check_deliverables(path: Path, deliverables, fail) -> None:
259
405
  if unfilled(item.get("title")):
260
406
  fail("C4", path, f"deliverable {where}: title missing, or still the template's")
261
407
  state = item.get("state")
262
- if state not in DELIVERABLE_STATES:
408
+ if not one_of(state, DELIVERABLE_STATES):
263
409
  fail("C4", path, f"deliverable {where}: state '{state}', expected one of {sorted(DELIVERABLE_STATES)}")
264
410
  criteria = item.get("acceptance")
265
411
  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:
412
+ if one_of(state, DELIVERABLE_STATES - WITHOUT_CRITERIA) and not filled:
267
413
  # The criteria are right, and the refusal names the door the framework already had.
268
414
  # Work whose product is knowledge is a SPIKE: its criteria name what will be recorded
269
415
  # and the threshold it is read against, and a refuted result is a delivered one. The
@@ -283,7 +429,47 @@ def _check_deliverables(path: Path, deliverables, fail) -> None:
283
429
  "proof.")
284
430
 
285
431
 
286
- def _check_cycle(path: Path, fail) -> str | None:
432
+ def _check_success(path: Path, data: dict, status, numbers: list[int] | None, fail) -> None:
433
+ """C6: the criteria a closure moved, each by its number in the charter's list (PDR-0019)."""
434
+ records = data.get("success")
435
+ if records is None:
436
+ return
437
+ action = ("Action: name each criterion by its number in docs/project/charter.md, from 1, as met, or as "
438
+ "observing with since: YYYY-MM-DD, the day observation started (.nstack/playbooks/framing.md, "
439
+ "the end of a cycle)")
440
+ if not one_of(status, OUTCOMES):
441
+ fail("C6", path, f"success: recorded by a cycle that is {status or 'of no known status'}, not closed "
442
+ "or stopped — a criterion moves at a closure, when the decider says so. Action: "
443
+ "record it in the closure's pull request, with status and outcome")
444
+ return
445
+ if not isinstance(records, list):
446
+ fail("C6", path, f"success: a list of records, one per criterion moved. {action}")
447
+ return
448
+ seen: set[int] = set()
449
+ for index, record in enumerate(records, start=1):
450
+ where = f"success record no. {index}"
451
+ if not isinstance(record, dict):
452
+ fail("C6", path, f"{where}: expected a mapping (criterion, state, since). {action}")
453
+ continue
454
+ number, state = record.get("criterion"), record.get("state")
455
+ if isinstance(number, bool) or not isinstance(number, int) or number < 1:
456
+ fail("C6", path, f"{where}: criterion '{number}', expected a number from 1. {action}")
457
+ elif number in seen:
458
+ fail("C6", path, f"{where}: criterion {number} recorded twice in this closure — keep the one "
459
+ "the decider said")
460
+ elif numbers is not None and number not in numbers:
461
+ fail("C6", path, f"{where}: criterion {number}, and the charter's filled criteria are numbered "
462
+ f"{', '.join(map(str, numbers)) or 'nothing'}. If a decision changed the charter's "
463
+ "list, renumber this record to match it, or remove it, with that decision")
464
+ if isinstance(number, int):
465
+ seen.add(number)
466
+ if not one_of(state, SUCCESS_STATES):
467
+ fail("C6", path, f"{where}: state '{state}', expected one of {sorted(SUCCESS_STATES)}. {action}")
468
+ elif state == "observing" and as_date(record.get("since")) is None:
469
+ fail("C6", path, f"{where}: observing since when? {action}")
470
+
471
+
472
+ def _check_cycle(path: Path, fail, numbers: list[int] | None = None) -> str | None:
287
473
  data, reason = read_front_matter(path)
288
474
  if data is None:
289
475
  fail("C1", path, reason)
@@ -291,7 +477,7 @@ def _check_cycle(path: Path, fail) -> str | None:
291
477
  if not str(data.get("goal") or "").strip():
292
478
  fail("C3", path, "goal missing: one sentence")
293
479
  status = data.get("status")
294
- if status not in CYCLE_STATUSES:
480
+ if not one_of(status, CYCLE_STATUSES):
295
481
  fail("C3", path, f"status '{status}': expected one of {sorted(CYCLE_STATUSES)}")
296
482
  weeks = data.get("appetite_weeks")
297
483
  if isinstance(weeks, bool) or not isinstance(weeks, int) or weeks < 1:
@@ -304,11 +490,12 @@ def _check_cycle(path: Path, fail) -> str | None:
304
490
  fail("C3", path, f"end {end}: the appetite sets it, start + {weeks} week(s) = "
305
491
  f"{start + datetime.timedelta(weeks=weeks)}")
306
492
  _check_deliverables(path, data.get("deliverables"), fail)
307
- if status in OUTCOMES:
308
- if data.get("outcome") not in OUTCOMES[status]:
493
+ if one_of(status, OUTCOMES):
494
+ if not one_of(data.get("outcome"), OUTCOMES[status]):
309
495
  fail("C6", path, f"a {status} cycle records its outcome: one of {sorted(OUTCOMES[status])}")
310
496
  if as_date(data.get("ended_on")) is None:
311
497
  fail("C6", path, f"a {status} cycle records ended_on, the date it ended")
498
+ _check_success(path, data, status, numbers, fail)
312
499
  return status if isinstance(status, str) else None
313
500
 
314
501
 
@@ -318,7 +505,7 @@ def _check_discovery(path: Path, fail) -> str | None:
318
505
  fail("C1", path, reason)
319
506
  return None
320
507
  decision = data.get("decision")
321
- if decision not in DECISIONS:
508
+ if not one_of(decision, DECISIONS):
322
509
  fail("C7", path, f"decision '{decision}': expected one of {sorted(DECISIONS)}")
323
510
  elif decision != "proposed":
324
511
  check_decider("C7", path, data.get("decider"), fail)
@@ -330,6 +517,63 @@ def _check_discovery(path: Path, fail) -> str | None:
330
517
  return decision if isinstance(decision, str) else None
331
518
 
332
519
 
520
+ RECORDS = (Path("docs") / "pdr", Path("docs") / "adr")
521
+ CRITERION = re.compile(r"^## Success criterion[^\n]*\n(.*?)(?=^## |\Z)", re.S | re.M)
522
+ READING = re.compile(r"Read at `?(.+?)`?'s closure, (\d{4}-\d{2}-\d{2}): (?:met|missed|superseded)\b", re.I)
523
+ DAY = re.compile(r"(?<![\d-])\d{4}-\d{2}-\d{2}(?![\d-])")
524
+ NUMBERED = re.compile(r"\bcycle\s+0*(\d+)\b", re.I)
525
+ HEADER = re.compile(r"^[-*\s]*\**(Status|Date)\**\s*:\**\s*(\S+)", re.M | re.I)
526
+
527
+
528
+ def _names(text: str, stem: str) -> bool:
529
+ """Whether `text` names the cycle of file `stem`: by its file (`03-the-window`), or as `cycle 03`."""
530
+ number = re.match(r"\d+", stem)
531
+ return bool(re.search(rf"(?<![\w-]){re.escape(stem)}(?![\w-])", text)
532
+ or number and int(number[0]) in {int(n) for n in NUMBERED.findall(text)})
533
+
534
+
535
+ def _due(root: Path, read: list[tuple[Path, dict | None]]) -> list[str]:
536
+ """C10: each accepted decision record whose success criterion is due at a closure and says no
537
+ reading of it (PDR-0020). Only the criterion section is read: a cycle named by its file or as
538
+ *cycle NN*, a date written YYYY-MM-DD. A named cycle is due at its closure; a date at the first
539
+ closure that reaches it — read once, a later date waits for a later closure, and a reading on or
540
+ after a date answers it, even at a cycle that stopped before it. A closure before the record's own
541
+ date is not one it could be read at. Said, never refused."""
542
+ closures = sorted(((ended, path.stem) for path, data in read
543
+ if data and one_of(data.get("status"), OUTCOMES)
544
+ and (ended := as_date(data.get("ended_on")))))
545
+ if not closures:
546
+ return []
547
+ said = []
548
+ for path in sorted(p for folder in RECORDS if (root / folder).is_dir()
549
+ for p in (root / folder).glob("*.md")
550
+ if p.is_file() and p.name != "README.md" and not p.name.startswith("_")):
551
+ text = path.read_text(encoding="utf-8", errors="replace")
552
+ header = {key.lower(): value.strip("*,.") for key, value in HEADER.findall(text.split("\n## ", 1)[0])}
553
+ section = CRITERION.search(text)
554
+ if header.get("status", "").lower() != "accepted" or not section:
555
+ continue
556
+ criterion = re.sub(r"<!--.*?-->", "", section[1], flags=re.S)
557
+ readings = [(name, day) for name, on in READING.findall(criterion) if (day := as_date(on))]
558
+ criterion = "\n".join(line for line in criterion.splitlines() if not READING.search(line))
559
+ since = as_date(header.get("date", ""))
560
+ open_ = [(ended, stem) for ended, stem in closures if since is None or ended >= since]
561
+ due = {closure for closure in open_ if _names(criterion, closure[1])}
562
+ for day in filter(None, map(as_date, DAY.findall(criterion))):
563
+ # A reading on or after the date has seen it pass, at whichever closure it was written.
564
+ reached = next((closure for closure in open_ if closure[0] >= day), None)
565
+ if reached and not any(on >= day for _, on in readings):
566
+ due.add(reached)
567
+ for ended, stem in sorted(due):
568
+ if not any(_names(name, stem) and on >= ended for name, on in readings):
569
+ said.append(f"[C10] {path.relative_to(root)}\n its success criterion is due at {stem}'s "
570
+ f"closure ({ended}), and holds no line saying it was read.\n Action: read it, and "
571
+ f"write in its success criterion: \"Read at {stem}'s closure, <YYYY-MM-DD>: met | "
572
+ "missed | superseded\" — a missed one is the decider's to answer, by a decision of its "
573
+ "own (.nstack/playbooks/framing.md, Decisions).")
574
+ return said
575
+
576
+
333
577
  def run(root: Path) -> int:
334
578
  failures: list[str] = []
335
579
 
@@ -352,7 +596,8 @@ def run(root: Path) -> int:
352
596
  if accepted_charter and discovery.is_file() and decision != "go":
353
597
  fail("C7", charter, f"the charter is accepted, but the discovery's decision is '{decision}': "
354
598
  "a charter follows a go (docs/project/discovery.md)")
355
- accepted = [path for path in cycles if _check_cycle(path, fail) == "accepted"]
599
+ numbers = criteria(root)
600
+ accepted = [path for path in cycles if _check_cycle(path, fail, numbers) == "accepted"]
356
601
  if len(accepted) > 1:
357
602
  names = ", ".join(path.name for path in accepted)
358
603
  fail("C5", root / CYCLES, f"{len(accepted)} accepted cycles ({names}): one cycle at a time — "
@@ -364,6 +609,8 @@ def run(root: Path) -> int:
364
609
  print(f"Plan: charter {'present' if charter.is_file() else 'absent'}, {len(cycles)} cycle(s).")
365
610
  if len(accepted) == 1 and accepted_charter:
366
611
  print(progress(root))
612
+ for warning in _due(root, [(path, read_front_matter(path)[0]) for path in cycles]):
613
+ print(f" WARNING {warning}")
367
614
  for failure in failures:
368
615
  print(f" FAIL {failure}")
369
616
  if failures:
@@ -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)
@@ -445,12 +445,20 @@ def claim(root: Path, ident: str, session: str | None = None, person: str | None
445
445
  return 0
446
446
 
447
447
 
448
+ def _ended(root: Path, ident: str, current: str | None) -> str | None:
449
+ """Where a hold of an ended cycle is still open: a hold lasts its cycle (#280). Nothing it
450
+ guarded can be delivered any more, and a new cycle's hold is a branch of its own."""
451
+ for path in plan.cycle_files(root):
452
+ status = (plan.read_front_matter(path)[0] or {}).get("status")
453
+ if path.stem != current and plan.one_of(status, plan.OUTCOMES) and read(root, path.stem, ident).held:
454
+ return (f"{ident}'s hold ended with {path.stem} ({status}): a hold lasts its cycle, nothing to release. "
455
+ f"Its branch {PREFIX}/{path.stem}/{ident} is kept, the hold's record.")
456
+ return None
457
+
458
+
448
459
  def release(root: Path, ident: str, session: str | None = None, person: str | None = None) -> int:
449
460
  cycle = plan.accepted_cycle(root)
450
- if cycle is None:
451
- print("FAIL [hold] no accepted cycle: there is no hold to release.")
452
- return 1
453
- stem = cycle[0].stem
461
+ stem = cycle[0].stem if cycle else None
454
462
  person = _person(root, person)
455
463
  if any(not value.isprintable() for value in (person, session or "")):
456
464
  print("FAIL [hold] a person or a session holds a line break or a control character.\n"
@@ -461,9 +469,14 @@ def release(root: Path, ident: str, session: str | None = None, person: str | No
461
469
  except Unreachable as error:
462
470
  print(f"FAIL [hold] the remote cannot be read.\n {error}\n Action: check it, then again.")
463
471
  return 1
464
- current = read(root, stem, ident)
472
+ current = read(root, stem, ident) if stem else Hold()
465
473
  if not current.held:
466
- print(f"FAIL [hold] {ident} is not held: nothing to release.")
474
+ ended = _ended(root, ident, stem)
475
+ if ended:
476
+ print(ended)
477
+ return 0
478
+ print(f"FAIL [hold] {ident} is not held: nothing to release." if stem else
479
+ "FAIL [hold] no accepted cycle: there is no hold to release.")
467
480
  return 1
468
481
  charter = plan.read_front_matter(root / plan.CHARTER)[0] if (root / plan.CHARTER).is_file() else None
469
482
  decider = str((charter or {}).get("decider") or "")
@@ -500,11 +513,11 @@ def show(root: Path) -> int:
500
513
  continue
501
514
  ident, state = str(item.get("id")), item.get("state")
502
515
  found = read(root, path.stem, ident)
503
- if not found.held and state not in TAKEABLE:
516
+ if not found.held and not plan.one_of(state, TAKEABLE):
504
517
  continue
505
518
  if not found.held:
506
519
  lines.append(f" {ident} free")
507
- elif state not in TAKEABLE:
520
+ elif not plan.one_of(state, TAKEABLE):
508
521
  lines.append(f" {ident} ended — {state}; was {said(found)}")
509
522
  elif time.time() >= found.worked + days * DAY:
510
523
  held, stale = held + 1, stale + 1
@@ -376,6 +376,11 @@ def run_verb(root: Path, verb: str, name: str | None, arguments: list[str] | Non
376
376
  print(f"FAIL [release] {odd.relative_to(root)} is a link or a file, not a folder of the project's own: "
377
377
  "nothing is emptied through it.\n Action: remove it, then release again.")
378
378
  return 1
379
+ left = sorted(path.relative_to(dist).as_posix() for path in dist.rglob("*") if not path.is_dir()) if dist.is_dir() else []
380
+ if left: # never silent: another target's package may be among them (#276)
381
+ named = ", ".join(left[:5]) + (f", and {len(left) - 5} more" if len(left) > 5 else "")
382
+ print(f"-> {target}: {dist.relative_to(root).as_posix()} emptied first, "
383
+ f"{len(left)} file{'s' if len(left) > 1 else ''}: {named}", flush=True)
379
384
  shutil.rmtree(dist, ignore_errors=True)
380
385
  dist.mkdir(parents=True)
381
386
  environment = {**os.environ, "NSTACK_DIST": str(dist.resolve())}
@@ -383,14 +388,15 @@ def run_verb(root: Path, verb: str, name: str | None, arguments: list[str] | Non
383
388
  # Its evidence: a folder of its own, its output kept, its logs read after (ADR-0009).
384
389
  from napkinstack import evidence
385
390
 
386
- folder = evidence.prepare(root, known[target])
387
- if isinstance(folder, str):
388
- print(f"FAIL [e2e] module '{target}': {folder}")
389
- return 1
390
- print(f"-> {target}: {command}", flush=True)
391
- code, held = evidence.run(command, known[target], {**os.environ, "NSTACK_EVIDENCE": str(folder.resolve())},
392
- folder)
393
- passed = evidence.finish(root, target, known[target], folder, code, held)
391
+ with evidence.one_at_a_time(root, target):
392
+ folder = evidence.prepare(root, known[target])
393
+ if isinstance(folder, str):
394
+ print(f"FAIL [e2e] module '{target}': {folder}")
395
+ return 1
396
+ print(f"-> {target}: {command}", flush=True)
397
+ code, held = evidence.run(command, known[target], {**os.environ, evidence.HELD: "1",
398
+ "NSTACK_EVIDENCE": str(folder.resolve())}, folder)
399
+ passed = evidence.finish(root, target, known[target], folder, code, held)
394
400
  if code == evidence.NOT_RUN and passed:
395
401
  print(f"-> {target}: e2e not run here: its output says why, in {folder.relative_to(root)}/output.txt.")
396
402
  continue
@@ -62,6 +62,10 @@ def engine() -> tuple[str, str | None]:
62
62
  path = Path(url2pathname(urlparse(data.get("url", "")).path))
63
63
  commit = subprocess.run(["git", "describe", "--always", "--dirty"], cwd=path,
64
64
  capture_output=True, text=True).stdout.strip() if path.is_dir() else ""
65
+ if commit and not commit.endswith("-dirty") and subprocess.run(
66
+ ["git", "status", "--porcelain", "--untracked-files=normal"], cwd=path,
67
+ capture_output=True, text=True).stdout.strip():
68
+ commit += "-dirty" # a file git does not track yet runs as surely as one it does
65
69
  return version(), f"a local checkout at {commit or 'an unknown commit'}"
66
70
 
67
71
 
@@ -321,10 +321,13 @@ def check_sheet(body: str, head: str, reasons: list[str], fail: Fail,
321
321
  # declared, so it is re-run. One that was explored by hand costs a round: its evidence keeps the commit
322
322
  # it was produced on, and the verifier confirms at the head, naming what moved since.
323
323
  # At `critical` nothing stands in for a re-run: that is what the top value buys.
324
- confirmed = row.get(CONFIRMED, "").strip().split(" ", 1)[0].strip("`").lower()
324
+ # Each clause of the cell starts with the commit it confirms: a later confirmation
325
+ # joins an earlier one after a `;`, never replacing it (#288).
326
+ confirmed = [clause.strip().split(" ", 1)[0].strip("`").lower()
327
+ for clause in row.get(CONFIRMED, "").split(";")]
325
328
  if kind.lower() == "automated" or strict:
326
329
  rerun.append(ident)
327
- elif not (SHA.fullmatch(confirmed) and head.startswith(confirmed)):
330
+ elif not any(SHA.fullmatch(commit) and head.startswith(commit) for commit in confirmed):
328
331
  unconfirmed.append(ident)
329
332
  if result == "failed":
330
333
  fail("T5", f"scenario {ident}: failed.\n Action: fix the change, or have the decider "
@@ -346,8 +349,9 @@ def check_sheet(body: str, head: str, reasons: list[str], fail: Fail,
346
349
  fail("T4", f"scenarios explored before the head {head[:7]} and not confirmed on it: "
347
350
  f"{', '.join(unconfirmed)}.\n Action: re-read what changed since, then "
348
351
  f"add the head commit in a Confirmed column: `{head[:7]} — <what moved, and why "
349
- "it leaves this scenario's result standing>`.\n Running the whole sheet "
350
- "again is the other way, and it is not required: a confirmation is not a round "
352
+ "it leaves this scenario's result standing>`, after a `;` when the cell holds an "
353
+ "earlier one.\n Running the whole sheet again is the other way, and it is not "
354
+ "required: a confirmation is not a round "
351
355
  "(.nstack/handbook/05-workflow.md §7).")
352
356
  return human_only
353
357
 
@@ -393,7 +397,7 @@ def check_cycle(root: Path, body: str, labels: set[str], today: datetime.date, f
393
397
  elif match[1] not in states:
394
398
  fail("K3", f"deliverable {match[1]} is not in {path.name}.\n Action: name one of "
395
399
  f"{', '.join(states) or 'its deliverables'}, or re-frame the cycle with the decider.")
396
- elif states[match[1]] not in {"ready", "in-progress"}:
400
+ elif not plan.one_of(states[match[1]], {"ready", "in-progress"}):
397
401
  fail("K3", f"deliverable {match[1]} is '{states[match[1]]}': work starts on a ready "
398
402
  "deliverable (definition of ready).")
399
403
  elif states[match[1]] == "ready":
File without changes