napkinstack 0.19.4__tar.gz → 0.20.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 (39) hide show
  1. {napkinstack-0.19.4 → napkinstack-0.20.0}/PKG-INFO +4 -4
  2. {napkinstack-0.19.4 → napkinstack-0.20.0}/README.md +3 -3
  3. {napkinstack-0.19.4 → napkinstack-0.20.0}/pyproject.toml +1 -1
  4. {napkinstack-0.19.4 → napkinstack-0.20.0}/pyproject.toml.orig +1 -1
  5. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/doctor.py +12 -4
  6. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/fitness/boundaries.py +2 -1
  7. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/fitness/manifests.py +2 -1
  8. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/fitness/pr_scope.py +2 -1
  9. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/hold.py +87 -8
  10. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/pull_request.py +37 -7
  11. {napkinstack-0.19.4 → napkinstack-0.20.0}/LICENSE +0 -0
  12. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/__init__.py +0 -0
  13. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/assurance.py +0 -0
  14. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/attest.py +0 -0
  15. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/board.py +0 -0
  16. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/cli.py +0 -0
  17. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/compat.py +0 -0
  18. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/discovery.py +0 -0
  19. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/evidence.py +0 -0
  20. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/fitness/__init__.py +0 -0
  21. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/fitness/hygiene.py +0 -0
  22. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/fitness/plan.py +0 -0
  23. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/landed.py +0 -0
  24. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/modules.py +0 -0
  25. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/owners.py +0 -0
  26. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/project.py +0 -0
  27. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/provenance.py +0 -0
  28. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/settings.py +0 -0
  29. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/skills.py +0 -0
  30. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
  31. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
  32. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/templates/contracts/README.md +0 -0
  33. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
  34. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
  35. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
  36. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -0
  37. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/templates/module/README.md +0 -0
  38. {napkinstack-0.19.4 → napkinstack-0.20.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  39. {napkinstack-0.19.4 → napkinstack-0.20.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.19.4
3
+ Version: 0.20.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
@@ -21,7 +21,7 @@ modules, contracts, guardrails in CI. On the Django or Rails model, one command
21
21
  the project, which then receives new versions on demand; no application stack is imposed.
22
22
  Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
23
23
 
24
- > **Status: v0.19.4, published** on [PyPI](https://pypi.org/project/napkinstack/).
24
+ > **Status: v0.20.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
25
25
  > What each version changes, engine and project apart: [`CHANGELOG.md`](CHANGELOG.md).
26
26
  > What is open: [`docs/governance/workstreams.md`](docs/governance/workstreams.md).
27
27
 
@@ -85,8 +85,8 @@ To try it without installing anything, or to install it from this repository ins
85
85
  registry — always pinned to a release tag:
86
86
 
87
87
  ```bash
88
- uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.19.4" nstack init my-project
89
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.19.4"
88
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.20.0" nstack init my-project
89
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.20.0"
90
90
  ```
91
91
 
92
92
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -5,7 +5,7 @@ modules, contracts, guardrails in CI. On the Django or Rails model, one command
5
5
  the project, which then receives new versions on demand; no application stack is imposed.
6
6
  Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
7
7
 
8
- > **Status: v0.19.4, published** on [PyPI](https://pypi.org/project/napkinstack/).
8
+ > **Status: v0.20.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.19.4" nstack init my-project
73
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.19.4"
72
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.20.0" nstack init my-project
73
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.20.0"
74
74
  ```
75
75
 
76
76
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napkinstack"
3
- version = "0.19.4"
3
+ version = "0.20.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.19.4"
3
+ version = "0.20.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"
@@ -415,10 +415,18 @@ def _kept_for_a_human(root: Path, shown: str, text: str) -> tuple[str, str]:
415
415
 
416
416
 
417
417
  def _agent_identities(root: Path) -> list[str]:
418
- """The bot identities the recent history's commits name, as GitHub's noreply addresses say them."""
419
- shown = subprocess.run(["git", "log", "-n", "500", "--format=%ae%n%ce"], cwd=root, capture_output=True, text=True)
420
- found = [address.lower() for address in shown.stdout.split()
421
- if address.lower().endswith("[bot]@users.noreply.github.com")]
418
+ """The bot identities of the recent history's commits that name a session, as GitHub's noreply
419
+ addresses say them. The line is what the kernel asks of an agent's commit: a forge's own bot,
420
+ Dependabot's, carries none and neither verifies nor approves (#310)."""
421
+ shown = subprocess.run(["git", "log", "-n", "500", "--format=%ae%x1f%ce%x1f%B%x1e"],
422
+ cwd=root, capture_output=True, text=True)
423
+ found = []
424
+ for record in shown.stdout.split("\x1e"):
425
+ author, committer, message = (record.strip().split("\x1f") + ["", ""])[:3]
426
+ # In the whole message: a squash keeps it above a last paragraph of co-authors.
427
+ if re.search(r"^Agent-Session:[ \t]*\S", message, re.M | re.I):
428
+ found += [address.lower() for address in (author, committer)
429
+ if address.lower().endswith("[bot]@users.noreply.github.com")]
422
430
  return list(dict.fromkeys(found))
423
431
 
424
432
 
@@ -211,7 +211,8 @@ def check_contracts(root: Path, modules: dict[str, dict]) -> dict[str, dict[str,
211
211
  offered = ", ".join(f"{k[0]} {k[1]} by {v}" for k, v in sorted(provided.items(), key=str)) or "none"
212
212
  if producer is None:
213
213
  fail("B6", name, f"consumes {key[0]} {key[1]}, which no module provides (provided: {offered}).\n"
214
- " Action: consume a provided version, or have its producer declare it.")
214
+ " Action: consume a provided version, or have its producer declare it first, "
215
+ "in a pull request of its own (.nstack/handbook/03-contracts.md §4).")
215
216
  elif c.get("module") not in (None, producer):
216
217
  fail("B6", name, f"consumes {key[0]} {key[1]} from '{c.get('module')}', which is "
217
218
  f"provided by '{producer}'.\n Action: name the producer.")
@@ -130,7 +130,8 @@ def unfilled(value) -> bool:
130
130
  def is_description(path: str) -> bool:
131
131
  """A module's description — its manifest, AGENTS.md, README.md, docs/ — or an empty
132
132
  placeholder, `path` relative to the module's folder. Changing it changes no behaviour
133
- (D24); a module holding nothing else has nothing to check yet (D33)."""
133
+ (D24); a module holding nothing else has nothing to check yet (D33). The one exception is read
134
+ from the manifests' values, not their path: pull_request.touched_modules (D184)."""
134
135
  # A page at any depth describes; it runs nothing. A contract's own page —
135
136
  # contracts/<name>/v1/README.md — is the case that asked: the module that holds it must
136
137
  # stay green until the contract gains a schema or a test (PDR-0012). Once a module consumes
@@ -6,7 +6,8 @@ Fitness function 3 — Pull request scope and size.
6
6
 
7
7
  A module is touched when the pull request changes it beyond its description — the same
8
8
  count as `nstack pr-check`'s (D34): editing a manifest, an AGENTS.md, a README or docs/
9
- changes no module, and contracts/ is a module like any other.
9
+ changes no module, unless the manifest moves its criticality or its user-facing surface (D184),
10
+ and contracts/ is a module like any other.
10
11
 
11
12
  The `cross-module` label lifts P1, the `over-budget` label documents P2: both are visible,
12
13
  so the exceptions are countable (.nstack/handbook/10-measurement.md §3).
@@ -10,6 +10,10 @@ from it: the refusal is git's, on any forge. A hold is never merged; its work is
10
10
  Usage : nstack claim D<n> [--session S] [--person P] [--take-over]
11
11
  nstack unclaim D<n> [--session S] [--person P]
12
12
  Output: 0 when the hold is taken, renewed or released; 1 when refused — saying who holds it.
13
+
14
+ Two sessions of one person are two holders (D181, D185): a session renews only a hold it is on; a
15
+ person's other session takes it with --take-over, on that person's word. A person with no session
16
+ has nothing to tell apart, and their name answers.
13
17
  """
14
18
 
15
19
  from __future__ import annotations
@@ -360,7 +364,7 @@ def _show(root: Path, name: str | None, ident: str, title: str, person: str, bra
360
364
  said: list[str] = []
361
365
  try:
362
366
  if branch is None:
363
- since = (f" from {previous.person}, on whose work nobody had worked since {when(previous.worked)}"
367
+ since = (f" from {previous.person}, last worked on {when(previous.worked)}"
364
368
  if previous is not None else "")
365
369
  for number in _open_pulls(root, forge, ident):
366
370
  _call(forge, "POST", f"/issues/{number}/comments", {"body": (
@@ -387,7 +391,7 @@ def claim(root: Path, ident: str, session: str | None = None, person: str | None
387
391
  print(f"FAIL [hold] {found}")
388
392
  return 1
389
393
  cycle, title = found
390
- person = _person(root, person)
394
+ person, session = _person(root, person), (session or "").strip() or None
391
395
  if any(not value.isprintable() for value in (person, session or "")):
392
396
  print("FAIL [hold] a person or a session holds a line break or a control character: it would "
393
397
  "write the record's lines.\n Action: name them on one line.")
@@ -402,11 +406,13 @@ def claim(root: Path, ident: str, session: str | None = None, person: str | None
402
406
  days = limit(root)
403
407
  stale_at = current.worked + days * DAY
404
408
  previous = None
409
+ mine = same(current.person, person)
410
+ other_session = _another_session(current, session)
405
411
  if not current.held:
406
412
  event = "claim"
407
- elif same(current.person, person):
413
+ elif mine and not other_session:
408
414
  event = "renew"
409
- elif take_over and time.time() >= stale_at:
415
+ elif take_over and (mine or time.time() >= stale_at): # a person moves their own hold on their word
410
416
  event, previous = "take-over", current
411
417
  elif take_over:
412
418
  print(f"FAIL [hold] {ident} is {said(current)}: it is not stale — work on it is younger than "
@@ -419,7 +425,9 @@ def claim(root: Path, ident: str, session: str | None = None, person: str | None
419
425
  print(f"FAIL [hold] {ident} is {said(current)}" + (f" — stale, no work for {days} days." if stale else ".")
420
426
  + (f"\n Action: {again} — recorded, with its previous holder."
421
427
  if stale else "\n Action: take another ready deliverable — nstack plan lists them; "
422
- "or ask its holder to give it back (nstack unclaim)."))
428
+ "or ask its holder to give it back (nstack unclaim)."
429
+ + (f"\n Another session of the same person holds it: only if that person asks, "
430
+ f"{again} — recorded, with the session it leaves." if mine else "")))
423
431
  return 1
424
432
  sha = _event(root, current.tip, ident, event, person, session, previous)
425
433
  ok, where = _record(root, name, cycle, ident, sha)
@@ -436,7 +444,9 @@ def claim(root: Path, ident: str, session: str | None = None, person: str | None
436
444
  else:
437
445
  print(f"FAIL [hold] {ident}: the remote refused the hold.\n {where}")
438
446
  return 1
439
- done = {"claim": "taken", "renew": "renewed", "take-over": f"taken over from {previous.person if previous else ''}"}
447
+ left = f"{previous.person}, session {', '.join(previous.sessions)}" if previous and previous.sessions \
448
+ else (previous.person if previous else "")
449
+ done = {"claim": "taken", "renew": "renewed", "take-over": f"taken over from {left}"}
440
450
  print(f"{ident} {done[event]}: held by {person}" + (f", session {session}" if session else "") + f"; {where}.")
441
451
  if event != "renew":
442
452
  made, branch = _work_branch(root, name, ident, title, session)
@@ -445,6 +455,12 @@ def claim(root: Path, ident: str, session: str | None = None, person: str | None
445
455
  return 0
446
456
 
447
457
 
458
+ def _another_session(current: Hold, session: str | None) -> bool:
459
+ """Named, and on none of the hold's events. A person with no session has nothing to tell apart."""
460
+ named = (session or "").strip().casefold()
461
+ return bool(named and current.sessions and named not in {held.casefold() for held in current.sessions})
462
+
463
+
448
464
  def _ended(root: Path, ident: str, current: str | None) -> str | None:
449
465
  """Where a hold of an ended cycle is still open: a hold lasts its cycle (#280). Nothing it
450
466
  guarded can be delivered any more, and a new cycle's hold is a branch of its own."""
@@ -459,7 +475,7 @@ def _ended(root: Path, ident: str, current: str | None) -> str | None:
459
475
  def release(root: Path, ident: str, session: str | None = None, person: str | None = None) -> int:
460
476
  cycle = plan.accepted_cycle(root)
461
477
  stem = cycle[0].stem if cycle else None
462
- person = _person(root, person)
478
+ person, session = _person(root, person), (session or "").strip() or None
463
479
  if any(not value.isprintable() for value in (person, session or "")):
464
480
  print("FAIL [hold] a person or a session holds a line break or a control character.\n"
465
481
  " Action: name them on one line.")
@@ -481,7 +497,13 @@ def release(root: Path, ident: str, session: str | None = None, person: str | No
481
497
  charter = plan.read_front_matter(root / plan.CHARTER)[0] if (root / plan.CHARTER).is_file() else None
482
498
  decider = str((charter or {}).get("decider") or "")
483
499
  stale = time.time() >= current.worked + limit(root) * DAY
484
- if not (same(current.person, person) or (decider and same(decider, person)) or stale):
500
+ is_decider = bool(decider and same(decider, person))
501
+ if same(current.person, person) and _another_session(current, session) and not stale and not is_decider:
502
+ print(f"FAIL [hold] {ident} is {said(current)}: another session of the same person does not give it "
503
+ f"back.\n Action: only if that person asks, nstack claim {ident} --take-over --person {person} "
504
+ f"--session {session}.")
505
+ return 1
506
+ if not (same(current.person, person) or is_decider or stale):
485
507
  print(f"FAIL [hold] {ident} is {said(current)}: only its holder or the decider ({decider or 'none named'}) "
486
508
  "releases a hold that is not stale.\n Action: ask them.")
487
509
  return 1
@@ -529,9 +551,66 @@ def show(root: Path) -> int:
529
551
  where = f"from {name}" if name else "local only: no remote"
530
552
  print(f"\nHolds in {path.stem}: {held} held, of which {stale} stale ({where}; stale after {days} days without work):")
531
553
  print("\n".join(lines) or " no ready deliverable")
554
+ left = _empty_starts(root, name, items if isinstance(items, list) else [])
555
+ if left:
556
+ print("\nEmpty start branches left — one empty commit each, nothing to lose, and git branch -d refuses them:")
557
+ print("\n".join(left))
532
558
  return 0
533
559
 
534
560
 
561
+ def _empty_starts(root: Path, name: str | None, items: list) -> list[str]:
562
+ """What to run for each branch `claim` started that its deliverable no longer needs (#316): its
563
+ state moved on, or its work landed from other branches — the default branch names it since —
564
+ or it belongs to another cycle. An id no cycle here knows may be newer than this checkout, and
565
+ is left alone. Only such a branch: the claim's own subject, one empty commit on the default
566
+ branch, here or on the remote."""
567
+ states = {str(item.get("id")).lower(): item.get("state") for item in items if isinstance(item, dict)}
568
+ known = set()
569
+ for path in plan.cycle_files(root):
570
+ listed = (plan.read_front_matter(path)[0] or {}).get("deliverables")
571
+ known |= {str(item.get("id")).lower() for item in (listed if isinstance(listed, list) else []) if isinstance(item, dict)}
572
+ base = f"{MIRROR}/base" if name else "refs/heads/main"
573
+ branches: dict[str, set[str]] = {}
574
+ for where, prefix, strip in (("remote", f"{MIRROR}/work", 3), ("local", "refs/heads", 2)):
575
+ if where == "remote" and not name:
576
+ continue
577
+ shown = _git(root, "for-each-ref", f"--format=%(refname:lstrip={strip})%09%(refname)%09%(subject)", f"{prefix}/d*").stdout
578
+ for line in shown.splitlines():
579
+ branch, ref, subject = (line.split("\t", 2) + ["", ""])[:3]
580
+ ident = branch.split("-")[0]
581
+ if (ident not in known or not subject.startswith(f"Start {ident.upper()} — ")
582
+ or _git(root, "rev-list", "--count", f"{base}..{ref}").stdout.strip() != "1"
583
+ or _git(root, "rev-parse", f"{ref}^{{tree}}").stdout != _git(root, "rev-parse", f"{ref}~1^{{tree}}").stdout):
584
+ continue
585
+ if ident in states and plan.one_of(states[ident], TAKEABLE) and not _landed(root, ident, ref, base):
586
+ continue
587
+ if ident not in states and _held_in_a_live_cycle(root, ident.upper()):
588
+ continue
589
+ branches.setdefault(branch, set()).add(where)
590
+ return [f" {branch} " + "; ".join(([f"git push {name} --delete {branch}"] if "remote" in places else [])
591
+ + ([f"git branch -D {branch}"] if "local" in places else []))
592
+ for branch, places in sorted(branches.items())]
593
+
594
+
595
+ def _landed(root: Path, ident: str, ref: str, base: str) -> bool:
596
+ """The default branch records the deliverable's delivery since the start branch left it: the
597
+ `Deliverable:` line of a pull request, or its id in parentheses or opening a subject — a commit
598
+ merely speaking of it is no delivery, and a live hold's branch is not named for it."""
599
+ name = ident.upper()
600
+ pattern = rf"\({name}\)|^Deliverable:[ \t]*{name}([^0-9]|$)|^{name} [—–-]"
601
+ return bool(_git(root, "log", "-1", "--format=%H", "--extended-regexp", f"--grep={pattern}", f"{ref}~1..{base}").stdout.strip())
602
+
603
+
604
+ def _held_in_a_live_cycle(root: Path, ident: str) -> bool:
605
+ """A hold on the remote this checkout's cycle files do not explain: its id moved on a commit not
606
+ pulled here. A hold of a closed cycle ended with it."""
607
+ prefix = f"{MIRROR}/hold" if remote(root) else f"refs/heads/{PREFIX}"
608
+ closed = {path.stem for path in plan.cycle_files(root)
609
+ if plan.one_of((plan.read_front_matter(path)[0] or {}).get("status"), plan.OUTCOMES)}
610
+ cycles = _git(root, "for-each-ref", "--format=%(refname)", f"{prefix}/*/{ident}").stdout.split()
611
+ return any(ref.split("/")[-2] not in closed and read(root, ref.split("/")[-2], ident).held for ref in cycles)
612
+
613
+
535
614
  def judge(root: Path, ident: str) -> Hold | str | None:
536
615
  """The hold of a deliverable of the accepted cycle for `pr-check`: None when nobody holds
537
616
  it, the reason when it cannot be read."""
@@ -8,7 +8,7 @@ Rules:
8
8
  head, the stricter
9
9
  T2 a verifier named who is not an author of the change, and every scenario row filled
10
10
  in: id, given · when · then, a kind (automated, explored, human only — reason), a
11
- result (passed, failed, not verified)
11
+ result (passed, failed, not verified); a | that splits a row is said (D187)
12
12
  T3 no scenario passed without its evidence and the commit it was verified on
13
13
  T4 evidence at the head commit: an automated scenario is run again, one explored by hand
14
14
  is confirmed at the head instead — and at criticality critical, run again like the rest
@@ -22,7 +22,8 @@ Rules:
22
22
  sessions, or its person's account (`nstack claim`)
23
23
 
24
24
  Delivery work: a pull request that changes a module — a folder holding a MANIFEST.yaml —
25
- beyond its description: its manifest, AGENTS.md, README.md and docs/ (D24). A NapkinStack
25
+ beyond its description: its manifest, AGENTS.md, README.md and docs/ (D24) — or whose manifest
26
+ moves its criticality or its user-facing surface, which is the decider's call (D184). A NapkinStack
26
27
  update or a documentation fix is neither delivery work nor a reason for a sheet.
27
28
  The out-of-cycle label lifts K1 to K3 and K5, visibly and countably (.nstack/handbook/10-measurement.md).
28
29
 
@@ -66,6 +67,8 @@ COLUMNS = ("#", "given · when · then", "kind", "result", "evidence", "commit")
66
67
  # Optional seventh column, read only when a scenario needs it: the head commit and what
67
68
  # changed since. A sheet no fix has disturbed never has to carry it (T4, D71).
68
69
  CONFIRMED = "confirmed"
70
+ CELLS = "cells read" # how many a row held
71
+ PIPED = "a pipe in a code span" # a bare |: the table ends the cell there, as GitHub does
69
72
  KINDS = {"automated", "explored"}
70
73
  RESULTS = {"passed", "failed", "not verified"}
71
74
  SECTION = re.compile(r"^##[ \t]+Test sheet[ \t]*$", re.I | re.M)
@@ -99,7 +102,8 @@ def _manifest(text: str) -> dict:
99
102
 
100
103
  def touched_modules(root: Path, base: str, files: list[str]) -> dict[str, list[dict]]:
101
104
  """Folder, relative to the root, to its manifests — at the head and at the base — for
102
- every module whose behaviour the files may change, a deleted module included."""
105
+ every module whose behaviour the files may change, a deleted module included, or whose
106
+ manifest moved what it owes."""
103
107
  manifests = {path.relative_to(root).as_posix(): [_manifest(path.read_text(encoding="utf-8"))]
104
108
  for path in find_manifests(root)}
105
109
  listed = _git(root, "ls-tree", "-r", "--name-only", base).stdout.split()
@@ -112,11 +116,19 @@ def touched_modules(root: Path, base: str, files: list[str]) -> dict[str, list[d
112
116
  for path, found in sorted(manifests.items()):
113
117
  folder = path.removesuffix("/MANIFEST.yaml")
114
118
  if any(file.startswith(f"{folder}/") and (not is_description(file[len(folder) + 1:]) or file in relied)
115
- for file in files):
119
+ for file in files) or _owes_differently(found):
116
120
  touched[folder] = found
117
121
  return touched
118
122
 
119
123
 
124
+ def _owes_differently(manifests: list[dict]) -> bool:
125
+ """True when the manifest moved what its module owes (.nstack/handbook/05-workflow.md §7): the
126
+ rest of a manifest is a description, these two say which checks the module answers to (D184)."""
127
+ owed = [(module.get("criticality"), module.get("user_facing") is True)
128
+ for module in (data.get("module") for data in manifests) if isinstance(module, dict)]
129
+ return len(set(owed)) > 1
130
+
131
+
120
132
  CONTRACT_PAGE = re.compile(r"(?P<path>.+/(?P<contract>[^/]+)/(?P<version>v\d+))/README\.md")
121
133
 
122
134
 
@@ -193,7 +205,8 @@ def read_sheet(body: str) -> tuple[str, list[str], list[dict[str, str]]]:
193
205
  values = _cells(line)
194
206
  if all(value in EMPTY or PLACEHOLDER.fullmatch(value) for value in values[1:]):
195
207
  continue # the template's example row
196
- rows.append(dict(zip(header, values + [""] * (len(header) - len(values)))))
208
+ piped = any("|" in span for span in re.findall(r"`[^`]*`", line.replace("\\|", "")))
209
+ rows.append({**dict(zip(header, values + [""] * (len(header) - len(values)))), CELLS: len(values), PIPED: piped})
197
210
  return verifier, header, rows
198
211
 
199
212
 
@@ -272,6 +285,18 @@ def _result(cell: str) -> str:
272
285
  return re.split(r"[ \t]+[—–-][ \t]+", cell.replace("*", "").strip(), maxsplit=1)[0].lower()
273
286
 
274
287
 
288
+ def _split(row: dict, width: int) -> bool:
289
+ """A bare | moved the row's columns. A row that reads well has not moved, whatever it holds:
290
+ cells beyond the header are ignored, as GitHub ignores them, and backticks prove nothing."""
291
+ result = _result(row["result"])
292
+ commit = SHA.fullmatch(row["commit"].strip().strip("`").lower())
293
+ kind = row["kind"].lower() in KINDS or HUMAN_ONLY.fullmatch(row["kind"])
294
+ if kind and result in RESULTS and (commit or result == "not verified"):
295
+ return False
296
+ late = SHA.fullmatch(row.get(CONFIRMED, "").strip().strip("`").lower()) # one cell after the commit's
297
+ return bool(row[PIPED] or late or row[CELLS] > width)
298
+
299
+
275
300
  def check_sheet(body: str, head: str, reasons: list[str], fail: Fail,
276
301
  authorship: tuple[set[str], set[str], list[str]] = (set(), set(), []),
277
302
  strict: bool = False, unchanged: Callable[[str], bool] = lambda commit: False) -> list[str]:
@@ -299,6 +324,11 @@ def check_sheet(body: str, head: str, reasons: list[str], fail: Fail,
299
324
  human_only, rerun, unconfirmed = [], [], []
300
325
  for row in rows:
301
326
  ident = row["#"] or "?"
327
+ if _split(row, len(header)):
328
+ fail("T2", f"scenario {ident}: a | splits its row ({row[CELLS]} cells, the header {len(header)}): its "
329
+ "columns cannot be read.\n Action: a | in a cell ends it, in a code span too: "
330
+ "write it \\|.")
331
+ continue
302
332
  kind = row["kind"]
303
333
  reason = HUMAN_ONLY.fullmatch(kind)
304
334
  result = _result(row["result"])
@@ -445,8 +475,8 @@ def check_hold(root: Path, body: str, labels: set[str], authorship: tuple[set[st
445
475
  return
446
476
  fail("K5", f"{ident} is {hold.said(found)}: this pull request comes from none of its sessions "
447
477
  f"and not from its person.\n Action: the holder gives it back (nstack unclaim {ident}), "
448
- f"or once stale nstack claim {ident} --take-over. The holder working from another session "
449
- f"names it on the hold first: nstack claim {ident} --session <it>.")
478
+ f"or once stale nstack claim {ident} --take-over. The holder's person moves it to another "
479
+ f"session on their word: nstack claim {ident} --take-over --person {found.person} --session <it>.")
450
480
 
451
481
 
452
482
  def charter_without_pdr(root: Path, base: str, files: list[str]) -> list[str]:
File without changes