napkinstack 0.12.0__tar.gz → 0.13.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 (37) hide show
  1. {napkinstack-0.12.0 → napkinstack-0.13.0}/PKG-INFO +4 -4
  2. {napkinstack-0.12.0 → napkinstack-0.13.0}/README.md +3 -3
  3. {napkinstack-0.12.0 → napkinstack-0.13.0}/pyproject.toml +1 -1
  4. {napkinstack-0.12.0 → napkinstack-0.13.0}/pyproject.toml.orig +1 -1
  5. napkinstack-0.13.0/src/napkinstack/board.py +359 -0
  6. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/cli.py +4 -1
  7. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/fitness/plan.py +41 -0
  8. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/hold.py +22 -5
  9. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/pull_request.py +2 -2
  10. {napkinstack-0.12.0 → napkinstack-0.13.0}/LICENSE +0 -0
  11. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/__init__.py +0 -0
  12. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/assurance.py +0 -0
  13. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/attest.py +0 -0
  14. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/compat.py +0 -0
  15. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/discovery.py +0 -0
  16. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/doctor.py +0 -0
  17. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/fitness/__init__.py +0 -0
  18. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/fitness/boundaries.py +0 -0
  19. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/fitness/hygiene.py +0 -0
  20. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/fitness/manifests.py +0 -0
  21. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/fitness/pr_scope.py +0 -0
  22. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/landed.py +0 -0
  23. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/modules.py +0 -0
  24. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/project.py +0 -0
  25. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/provenance.py +0 -0
  26. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/settings.py +0 -0
  27. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/skills.py +0 -0
  28. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/contracts/AGENTS.md +0 -0
  29. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/contracts/MANIFEST.yaml +0 -0
  30. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/contracts/README.md +0 -0
  31. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/contracts/docs/runbook.md +0 -0
  32. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/contracts/tests/.gitkeep +0 -0
  33. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/module/AGENTS.md +0 -0
  34. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/module/MANIFEST.yaml +0 -0
  35. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/module/README.md +0 -0
  36. {napkinstack-0.12.0 → napkinstack-0.13.0}/src/napkinstack/templates/module/src/.gitkeep +0 -0
  37. {napkinstack-0.12.0 → napkinstack-0.13.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.12.0
3
+ Version: 0.13.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.12.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
23
+ > **Status: v0.13.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.12.0" nstack init my-project
88
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.12.0"
87
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.13.0" nstack init my-project
88
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.13.0"
89
89
  ```
90
90
 
91
91
  **Two channels, one published artefact.** The registry publishes: the PyPI artefact of a
@@ -5,7 +5,7 @@ modules, contracts, guardrails in CI. On the Django or Rails model, one command
5
5
  the project, which then receives new versions on demand; no application stack is imposed.
6
6
  Positioning and vocabulary: [`PRODUCT.md`](PRODUCT.md) §1.
7
7
 
8
- > **Status: v0.12.0, published** on [PyPI](https://pypi.org/project/napkinstack/).
8
+ > **Status: v0.13.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.12.0" nstack init my-project
73
- uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.12.0"
72
+ uvx --from "git+https://github.com/NapkinStack/engineering-os@v0.13.0" nstack init my-project
73
+ uv tool install "napkinstack @ git+https://github.com/NapkinStack/engineering-os@v0.13.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.12.0"
3
+ version = "0.13.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.12.0"
3
+ version = "0.13.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"
@@ -0,0 +1,359 @@
1
+ """
2
+ The plan shown on the forge, one way (PDR-0016, ADR-0008).
3
+
4
+ The cycle files are the plan; this command shows them on GitHub — a milestone per accepted,
5
+ closed or stopped cycle, an issue per deliverable — and keeps the display equal to them. Its own
6
+ objects carry a marker in their body; an object without one is never touched. What the forge
7
+ holds decides nothing but whether the display must be rewritten.
8
+
9
+ Usage : nstack board [--root ROOT] [--dry-run]
10
+ Output: 0 whatever the forge answers — what was made, changed, restored or not shown is said.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import datetime
16
+ import os
17
+ import re
18
+ import time
19
+ import urllib.parse
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+
23
+ from napkinstack.fitness import plan
24
+ from napkinstack.pull_request import DELIVERABLE
25
+
26
+ SHOWN = {"accepted", "closed", "stopped"} # a proposed cycle decides nothing yet
27
+ OPEN_STATES = {"proposed", "ready", "in-progress"}
28
+ LABEL = "deliverable"
29
+ STATE_LABEL = "state: {}"
30
+ EDITED_THERE = "Generated from {path}: the plan is edited there, in a pull request — an edit made here is undone."
31
+ PACE = 1.0 # seconds between two writes: GitHub's advice for content-creating requests
32
+ NUMBER = re.compile(r"^(\d+)")
33
+ HEADING = re.compile(r"^#\s+(.+?)\s*$", re.M)
34
+
35
+
36
+ def marker(kind: str, key: str) -> str:
37
+ return f"<!-- nstack:{kind} {key} -->"
38
+
39
+
40
+ MARKER = re.compile(r"<!-- nstack:(cycle|deliverable) (\S+) -->")
41
+
42
+
43
+ @dataclass
44
+ class Shown:
45
+ """What the files call for, for one forge object."""
46
+
47
+ kind: str # cycle | deliverable
48
+ key: str # the cycle's number, or the deliverable's identifier
49
+ title: str
50
+ body: str
51
+ state: str # open | closed
52
+ reason: str | None = None # an issue's state_reason when closed
53
+ due: str | None = None # a milestone's due date, ISO
54
+ cycle: str | None = None # a deliverable's cycle number
55
+ labels: list[str] = field(default_factory=list)
56
+ assignee: str | None = None
57
+ start: datetime.date | None = None # a cycle's start: how far back pull requests are read
58
+
59
+
60
+ def _handle(person: str) -> str | None:
61
+ """A GitHub login from a hold's person, when it names one: a handle, or a noreply address."""
62
+ found = re.fullmatch(r"\s*@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\s*", person)
63
+ if found:
64
+ return found[1]
65
+ noreply = re.search(r"<(?:\d+\+)?([A-Za-z0-9-]+)@users\.noreply\.github\.com>", person)
66
+ return noreply[1] if noreply else None
67
+
68
+
69
+ def _reason(state: str) -> str:
70
+ return "completed" if state == "accepted" else "not_planned"
71
+
72
+
73
+ def desired(root: Path, holders: dict[str, str] | None = None, pulls: dict[str, list[int]] | None = None,
74
+ today: datetime.date | None = None) -> tuple[list[Shown], list[str]]:
75
+ """The display the cycle files call for, and what cannot be shown and why. `holders`:
76
+ deliverable identifier to the hold's person; `pulls`: identifier to the pull requests whose
77
+ description names it."""
78
+ holders, pulls = holders or {}, pulls or {}
79
+ cycles: dict[str, list[tuple[Path, dict]]] = {}
80
+ for path in plan.cycle_files(root):
81
+ data = plan.read_front_matter(path)[0]
82
+ found = NUMBER.match(path.stem)
83
+ if data and data.get("status") in SHOWN and found:
84
+ cycles.setdefault(found[1], []).append((path, data))
85
+ said = [f"cycle {number} not shown: {len(files)} cycle files carry its number "
86
+ f"({', '.join(path.name for path, _ in files)})" for number, files in cycles.items() if len(files) > 1]
87
+ seen: dict[str, list[str]] = {}
88
+ for number, files in cycles.items():
89
+ items = files[0][1].get("deliverables") if len(files) == 1 else None
90
+ for item in items if isinstance(items, list) else []:
91
+ if isinstance(item, dict) and item.get("id"):
92
+ seen.setdefault(str(item["id"]), []).append(number)
93
+ twice = {ident for ident, numbers in seen.items() if len(numbers) > 1}
94
+ said += [f"{ident} not shown: its identifier is used in cycles {', '.join(seen[ident])}" for ident in sorted(twice)]
95
+ shown: list[Shown] = []
96
+ for number, files in sorted(cycles.items()):
97
+ if len(files) > 1:
98
+ continue
99
+ path, data = files[0]
100
+ relative = path.relative_to(root).as_posix()
101
+ closed = data.get("status") != "accepted"
102
+ text = path.read_text(encoding="utf-8", errors="replace")
103
+ front = plan.FRONT_MATTER.match(text)
104
+ heading = HEADING.search(text[front.end():] if front else text)
105
+ title = heading[1] if heading and heading[1].lower().startswith("cycle") else f"Cycle {path.stem}"
106
+ goal = str(data.get("goal") or "").strip()
107
+ end = plan.as_date(data.get("end"))
108
+ shown.append(Shown(
109
+ kind="cycle", key=number, title=title, state="closed" if closed else "open",
110
+ body="\n\n".join(part for part in (goal, plan.progress_of(path, data, today, appetite=False),
111
+ EDITED_THERE.format(path=relative), marker("cycle", number)) if part),
112
+ due=str(end) if end else None, start=plan.as_date(data.get("start"))))
113
+ items = data.get("deliverables")
114
+ for item in items if isinstance(items, list) else []:
115
+ if not isinstance(item, dict) or not item.get("id") or str(item["id"]) in twice:
116
+ continue
117
+ ident, state = str(item["id"]), str(item.get("state") or "")
118
+ listed = item.get("acceptance")
119
+ criteria = [f"- [{'x' if state == 'accepted' else ' '}] {line}"
120
+ for line in (listed if isinstance(listed, list) else [listed] if isinstance(listed, str) else [])
121
+ if isinstance(line, str)]
122
+ person = holders.get(ident)
123
+ lines = [f"**Module**: `{item.get('module')}`" if item.get("module") else "",
124
+ f"**State**: {state}" + (f" · **held by** {person}" if person else ""),
125
+ "**Acceptance criteria**\n\n" + "\n".join(criteria) if criteria else "",
126
+ "**Pull requests**: " + ", ".join(f"#{n}" for n in pulls.get(ident, [])[:10]) if pulls.get(ident) else "",
127
+ EDITED_THERE.format(path=relative), marker("deliverable", ident)]
128
+ shut = closed or state not in OPEN_STATES # a closed cycle's unfinished work closes with it
129
+ shown.append(Shown(
130
+ kind="deliverable", key=ident, title=f"{ident} — {item.get('title') or ident}",
131
+ body="\n\n".join(line for line in lines if line), cycle=number,
132
+ state="closed" if shut else "open", reason=_reason(state) if shut else None,
133
+ labels=[LABEL, STATE_LABEL.format(state)], assignee=_handle(person) if person else None))
134
+ return shown, said
135
+
136
+
137
+ # --- the forge -------------------------------------------------------------------------------
138
+
139
+ def _pages(forge, path: str, stop=lambda item: False) -> list[dict]:
140
+ """Every object a listing returns, page after page — or until `stop` says the rest is older."""
141
+ from napkinstack.hold import _call
142
+
143
+ found, page = [], 1
144
+ while True:
145
+ answer = _call(forge, "GET", f"{path}{'&' if '?' in path else '?'}per_page=100&page={page}")
146
+ if not isinstance(answer, list) or not answer:
147
+ return found
148
+ for item in answer:
149
+ if isinstance(item, dict):
150
+ if stop(item):
151
+ return found
152
+ found.append(item)
153
+ if len(answer) < 100:
154
+ return found
155
+ page += 1
156
+
157
+
158
+ def _own(objects: list[dict], field_name: str, kind: str) -> dict[str, dict]:
159
+ """The display's own objects, by the key their marker carries; others are never touched."""
160
+ own = {}
161
+ for item in objects:
162
+ found = MARKER.search(str(item.get(field_name) or ""))
163
+ if found and found[1] == kind:
164
+ own.setdefault(found[2], item)
165
+ return own
166
+
167
+
168
+ def _holders(root: Path, idents: list[tuple[str, str]]) -> dict[str, str] | None:
169
+ """The person holding each open deliverable, or None when the holds cannot be read."""
170
+ from napkinstack import hold
171
+
172
+ try:
173
+ hold.fetch(root)
174
+ except hold.Unreachable:
175
+ return None
176
+ stems = {NUMBER.match(path.stem)[1]: path.stem for path in plan.cycle_files(root) if NUMBER.match(path.stem)}
177
+ holders = {}
178
+ for number, ident in idents:
179
+ found = hold.read(root, stems.get(number, number), ident)
180
+ if found.held:
181
+ holders[ident] = found.person
182
+ return holders
183
+
184
+
185
+ def _pulls(forge, since: datetime.date | None) -> dict[str, list[int]]:
186
+ """The pull requests whose description names a deliverable, newest first, back to the
187
+ earliest start of a shown cycle — read with the rule K3 reads."""
188
+ def older(item: dict) -> bool:
189
+ created = str(item.get("created_at") or "")[:10]
190
+ return bool(since and created and created < since.isoformat())
191
+
192
+ pulls: dict[str, list[int]] = {}
193
+ for item in _pages(forge, "/pulls?state=all&sort=created&direction=desc", older):
194
+ found = DELIVERABLE.search(str(item.get("body") or ""))
195
+ if found and item.get("number"):
196
+ pulls.setdefault(found[1].upper(), []).append(int(item["number"]))
197
+ return pulls
198
+
199
+
200
+ def _clean(text) -> str:
201
+ return "\n".join(line.rstrip() for line in str(text or "").replace("\r\n", "\n").strip().split("\n"))
202
+
203
+
204
+ def run(root: Path, dry_run: bool = False, today: datetime.date | None = None) -> int:
205
+ from napkinstack import hold
206
+
207
+ first, said = desired(root, today=today)
208
+ if not first:
209
+ _tell(said, "Plan on the forge: nothing to show — no accepted, closed or stopped cycle")
210
+ return 0
211
+ forge = hold._forge(root, hold.remote(root))
212
+ if forge is None:
213
+ _tell(["not shown: no GitHub remote, or no token"], "Plan on the forge")
214
+ return 0
215
+ lines = list(said)
216
+ try:
217
+ open_ones = [(item.cycle, item.key) for item in first if item.kind == "deliverable" and item.state == "open"]
218
+ holders = _holders(root, open_ones)
219
+ if holders is None:
220
+ lines.append("holders not shown: the holds cannot be read — assignees left as they are")
221
+ starts = [item.start for item in first if item.kind == "cycle" and item.start]
222
+ shown, _ = desired(root, holders or {}, _pulls(forge, min(starts) if starts else None), today)
223
+ milestones = _pages(forge, "/milestones?state=all")
224
+ # Every issue, not those carrying the label: a label removed by hand leaves the marker.
225
+ issues = [item for item in _pages(forge, "/issues?state=all") if "pull_request" not in item]
226
+ lines += _apply(forge, shown, milestones, issues, dry_run, keep_assignees=holders is None)
227
+ except hold.Refused as error:
228
+ limited = re.search(r"HTTP 429|rate limit", str(error), re.I)
229
+ lines.append(f"not shown further: {error}" + (" — partially shown, the rest at the next run" if limited else ""))
230
+ except Exception as error: # an answer nobody foresaw: said, never a failed merge
231
+ lines.append(f"not shown further: {type(error).__name__}: {error}")
232
+ _tell(lines, f"Plan on the forge ({forge[1]})" + (" — dry run, nothing applied" if dry_run else ""))
233
+ return 0
234
+
235
+
236
+ def _tell(lines: list[str], head: str) -> None:
237
+ """Said in the output; in GitHub Actions, also in the job's summary, and what was not shown as
238
+ a warning a person sees on the run."""
239
+ print(f"{head}:")
240
+ print("\n".join(f" {line}" for line in lines) or " in step with the cycle files: nothing to change")
241
+ summary = os.environ.get("GITHUB_STEP_SUMMARY")
242
+ if summary:
243
+ with open(summary, "a", encoding="utf-8") as out:
244
+ out.write(f"### {head}\n\n" + ("\n".join(f"- {line}" for line in lines) or "In step: nothing to change.") + "\n")
245
+ if os.environ.get("GITHUB_ACTIONS") == "true":
246
+ for line in lines:
247
+ if "not shown" in line:
248
+ print(f"::warning title=Plan on the forge::{line}")
249
+
250
+
251
+ def _apply(forge, shown: list[Shown], milestones: list[dict], issues: list[dict], dry_run: bool,
252
+ keep_assignees: bool = False) -> list[str]:
253
+ from napkinstack.hold import Refused, _call
254
+
255
+ said: list[str] = []
256
+
257
+ def call(*args):
258
+ if dry_run:
259
+ return {}
260
+ answer = _call(forge, *args)
261
+ time.sleep(PACE)
262
+ return answer
263
+
264
+ own_milestones, own_issues = _own(milestones, "description", "cycle"), _own(issues, "body", "deliverable")
265
+ foreign_titles = {item.get("title") for item in milestones if not MARKER.search(str(item.get("description") or ""))}
266
+ present = {str(label.get("name") or "").lower() for label in _pages(forge, "/labels")} # one name, any case
267
+ for name in sorted(label for label in {label for item in shown for label in item.labels} if label.lower() not in present):
268
+ call("POST", "/labels", {"name": name, "color": "1d76db" if name == LABEL else "c5def5",
269
+ "description": "Shown from the cycle files by nstack board"})
270
+ said.append(f"made label {name}")
271
+
272
+ numbers: dict[str, int | None] = {}
273
+ for item in (entry for entry in shown if entry.kind == "cycle"):
274
+ # Noon, UTC: GitHub keeps a due date as midnight, Pacific time, of the day it receives —
275
+ # sent at midnight UTC, it would read the day before.
276
+ want = {"title": item.title, "description": item.body, "state": item.state,
277
+ **({"due_on": f"{item.due}T12:00:00Z"} if item.due else {})}
278
+ have = own_milestones.get(item.key)
279
+ if have is None:
280
+ if item.title in foreign_titles:
281
+ said.append(f"cycle {item.key} not shown: a milestone titled '{item.title}' exists that the display "
282
+ "did not make — rename it, or put the marker back")
283
+ numbers[item.key] = None
284
+ continue
285
+ made = call("POST", "/milestones", want)
286
+ numbers[item.key] = made.get("number") if isinstance(made, dict) else None
287
+ said.append(f"made milestone {item.title}")
288
+ continue
289
+ numbers[item.key] = have.get("number")
290
+ if not have.get("number"):
291
+ continue
292
+ changed = [name for name in ("title", "state") if have.get(name) != want[name]]
293
+ if _clean(have.get("description")) != _clean(want["description"]):
294
+ changed.append("description")
295
+ if item.due and str(have.get("due_on") or "")[:10] != item.due:
296
+ changed.append("due date")
297
+ if changed:
298
+ call("PATCH", f"/milestones/{have['number']}", want)
299
+ said.append(f"restored milestone {item.title}: {', '.join(changed)} — the plan is edited in the cycle file")
300
+ ours = sum(1 for issue in own_issues.values()
301
+ if isinstance(issue.get("milestone"), dict) and issue["milestone"].get("number") == have.get("number"))
302
+ theirs = int(have.get("open_issues") or 0) + int(have.get("closed_issues") or 0) - ours
303
+ if theirs > 0:
304
+ said.append(f"{theirs} item(s) in {item.title} not from the plan: GitHub's bar counts them, the "
305
+ "description's line does not")
306
+
307
+ assignable: dict[str, bool] = {}
308
+
309
+ def can_assign(login: str) -> bool:
310
+ if login not in assignable:
311
+ try:
312
+ _call(forge, "GET", f"/assignees/{urllib.parse.quote(login)}")
313
+ assignable[login] = True
314
+ except Refused:
315
+ assignable[login] = False
316
+ return assignable[login]
317
+
318
+ for item in (entry for entry in shown if entry.kind == "deliverable"):
319
+ have = own_issues.get(item.key)
320
+ if have is not None and not have.get("number"):
321
+ continue
322
+ assignees = [item.assignee] if item.assignee and can_assign(item.assignee) else []
323
+ if keep_assignees and have is not None:
324
+ assignees = sorted(a.get("login") for a in have.get("assignees") or [] if isinstance(a, dict))
325
+ want = {"title": item.title, "body": item.body, "labels": item.labels,
326
+ "milestone": numbers.get(item.cycle), "assignees": assignees}
327
+ if have is None:
328
+ made = call("POST", "/issues", want)
329
+ if item.state == "closed" and isinstance(made, dict) and made.get("number"):
330
+ call("PATCH", f"/issues/{made['number']}", {"state": "closed", "state_reason": item.reason})
331
+ said.append(f"made issue {item.title}")
332
+ continue
333
+ changed = ["title"] if have.get("title") != want["title"] else []
334
+ if _clean(have.get("body")) != _clean(want["body"]):
335
+ changed.append("body")
336
+ if ({str(label.get("name") or "").lower() for label in have.get("labels") or [] if isinstance(label, dict)}
337
+ != {label.lower() for label in item.labels}):
338
+ changed.append("labels")
339
+ milestone = have.get("milestone")
340
+ if (milestone.get("number") if isinstance(milestone, dict) else None) != want["milestone"]:
341
+ changed.append("milestone")
342
+ if {a.get("login") for a in have.get("assignees") or [] if isinstance(a, dict)} != set(assignees):
343
+ changed.append("assignees")
344
+ if have.get("state") != item.state:
345
+ changed.append("state")
346
+ want.update(state=item.state, state_reason=item.reason or "reopened")
347
+ elif item.state == "closed" and have.get("state_reason") != item.reason:
348
+ changed.append("state reason")
349
+ want.update(state="closed", state_reason=item.reason)
350
+ if changed:
351
+ call("PATCH", f"/issues/{have['number']}", want)
352
+ said.append(f"restored issue {item.title}: {', '.join(changed)}")
353
+
354
+ wanted = {item.key for item in shown if item.kind == "deliverable"}
355
+ for key, have in own_issues.items():
356
+ if key not in wanted and have.get("state") == "open" and have.get("number"):
357
+ call("PATCH", f"/issues/{have['number']}", {"state": "closed", "state_reason": "not_planned"})
358
+ said.append(f"closed issue {have.get('title')}: its deliverable is no longer in a shown cycle")
359
+ return said
@@ -8,7 +8,7 @@ import os
8
8
  import sys
9
9
  from pathlib import Path
10
10
 
11
- from napkinstack import assurance, attest, hold
11
+ from napkinstack import assurance, attest, board, hold
12
12
  from napkinstack import (compat, discovery, doctor, landed, modules, provenance, settings,
13
13
  pull_request, skills)
14
14
  from napkinstack.fitness import boundaries, hygiene, manifests, plan, pr_scope
@@ -175,6 +175,9 @@ def build_parser() -> argparse.ArgumentParser:
175
175
  rs.add_argument("deliverable", help="the deliverable's identifier, D<n>")
176
176
  rs.add_argument("--session", help="the agent's session, written on the release")
177
177
  rs.add_argument("--person", help="who releases it, such as @alice (default: git's user)")
178
+ bd = _add(sub, "board", "shows the cycles and their deliverables on the forge, one way: milestones and issues",
179
+ lambda a: board.run(a.root, a.dry_run))
180
+ bd.add_argument("--dry-run", action="store_true", help="say what would change, change nothing")
178
181
  ds = _add(sub, "discover", "starts a discovery from an idea file, for the team's agent",
179
182
  lambda a: discovery.run(a.root, a.idea))
180
183
  ds.add_argument("idea", type=Path, help="the idea, a .md or .txt file")
@@ -149,6 +149,45 @@ def accepted_cycle(root: Path) -> tuple[Path, dict] | None:
149
149
  return None
150
150
 
151
151
 
152
+ PROGRESS = (("accepted", "accepted"), ("in-progress", "in progress"), ("ready", "ready"),
153
+ ("proposed", "proposed"), ("deferred", "deferred")) # dropped: out of the count
154
+
155
+
156
+ def progress(root: Path, today: datetime.date | None = None) -> str | None:
157
+ """The accepted cycle in one line, read from its front matter and never written down:
158
+ deliverables by state — a dropped one counts in nothing — and where the cycle stands in its
159
+ appetite (#219). None when no cycle is accepted."""
160
+ cycle = accepted_cycle(root)
161
+ return None if cycle is None else progress_of(*cycle, today=today)
162
+
163
+
164
+ def progress_of(path: Path, data: dict, today: datetime.date | None = None, appetite: bool = True) -> str:
165
+ """One cycle's line, accepted or not: what `progress` says. Without the appetite — the
166
+ day and the days left, which change every day — it is what the forge's display repeats
167
+ (ADR-0008)."""
168
+ items = data.get("deliverables")
169
+ states = [item.get("state") for item in (items if isinstance(items, list) else []) if isinstance(item, dict)]
170
+ counted = [state for state in states if state != "dropped"]
171
+ said = [f"{states.count('accepted')} of {len(counted)} accepted"]
172
+ said += [f"{counted.count(state)} {name}" for state, name in PROGRESS[1:] if counted.count(state)]
173
+ other = len(counted) - sum(counted.count(state) for state, _ in PROGRESS)
174
+ said += [f"{other} other"] if other else []
175
+ today = today or datetime.date.today()
176
+ start, end = as_date(data.get("start")), as_date(data.get("end"))
177
+
178
+ def days(count: int) -> str:
179
+ return f"{count} day" + ("" if count == 1 else "s")
180
+
181
+ if start and end and appetite:
182
+ if today < start:
183
+ said.append(f"starts in {days((start - today).days)} ({start})")
184
+ elif today >= end: # the first day the cycle is over (the cycle form), as K2 reads it
185
+ said.append(f"ended on {end}, {days((today - end).days)} ago — the circuit breaker: the decider chooses")
186
+ else:
187
+ said.append(f"day {(today - start).days + 1} of {(end - start).days}, {(end - today).days} left (ends {end})")
188
+ return f"Cycle {path.stem}: " + " · ".join(said)
189
+
190
+
152
191
  def _left(path: Path, form: tuple[str, ...]) -> list[str]:
153
192
  """The words of `form` a seat still holds, as whole lines: a page may hold any tag."""
154
193
  lines = {line.strip() for line in path.read_text(encoding="utf-8", errors="replace").splitlines()}
@@ -323,6 +362,8 @@ def run(root: Path) -> int:
323
362
  f"({CHARTER}, status: accepted)")
324
363
 
325
364
  print(f"Plan: charter {'present' if charter.is_file() else 'absent'}, {len(cycles)} cycle(s).")
365
+ if len(accepted) == 1 and accepted_charter:
366
+ print(progress(root))
326
367
  for failure in failures:
327
368
  print(f" FAIL {failure}")
328
369
  if failures:
@@ -14,6 +14,7 @@ Output: 0 when the hold is taken, renewed or released; 1 when refused — sayin
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import base64
17
18
  import datetime
18
19
  import json
19
20
  import os
@@ -76,7 +77,7 @@ def fetch(root: Path, again: bool = False) -> str | None:
76
77
  name = remote(root)
77
78
  if name is not None:
78
79
  try:
79
- got = _git(root, "fetch", "--quiet", "--prune", "--no-tags", name, f"+HEAD:{MIRROR}/base",
80
+ got = _git(root, *_ci_credential(root, name), "fetch", "--quiet", "--prune", "--no-tags", name, f"+HEAD:{MIRROR}/base",
80
81
  f"+refs/heads/{PREFIX}/*:{MIRROR}/hold/*", f"+refs/heads/d*:{MIRROR}/work/d*",
81
82
  timeout=60)
82
83
  except subprocess.TimeoutExpired:
@@ -86,6 +87,19 @@ def fetch(root: Path, again: bool = False) -> str | None:
86
87
  return name
87
88
 
88
89
 
90
+ def _ci_credential(root: Path, name: str) -> list[str]:
91
+ """In GitHub Actions, the job's token for this one command: the checkout keeps no credential
92
+ (`persist-credentials: false`), and a private repository's holds are read with it (ADR-0008).
93
+ Never on a workstation, and never to another host."""
94
+ token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
95
+ url = _git(root, "config", f"remote.{name}.url").stdout.strip()
96
+ if os.environ.get("GITHUB_ACTIONS") != "true" or not token or not url.startswith("https://github.com/"):
97
+ return []
98
+ secret = base64.b64encode(f"x-access-token:{token}".encode()).decode()
99
+ print(f"::add-mask::{secret}", flush=True) # never printed in clear by the runner, as actions/checkout does
100
+ return ["-c", f"http.https://github.com/.extraheader=AUTHORIZATION: basic {secret}"]
101
+
102
+
89
103
  def _hold_ref(root: Path, cycle: str, ident: str) -> str:
90
104
  return f"{MIRROR}/hold/{cycle}/{ident}" if remote(root) else f"refs/heads/{PREFIX}/{cycle}/{ident}"
91
105
 
@@ -161,7 +175,7 @@ def _deliverable(root: Path, ident: str) -> tuple[str, str] | str:
161
175
  return ("no accepted cycle: a deliverable is taken from the cycle in progress.\n"
162
176
  " Action: the decider accepts a cycle (.nstack/playbooks/framing.md).")
163
177
  path, data = cycle
164
- items = {str(item.get("id")): item for item in data.get("deliverables") or [] if isinstance(item, dict)}
178
+ items = {str(item.get("id")): item for item in (data.get("deliverables") if isinstance(data.get("deliverables"), list) else []) if isinstance(item, dict)}
165
179
  if ident not in items:
166
180
  return (f"{ident} is not in {path.stem}.\n Action: name one of {', '.join(items) or 'its deliverables'}.")
167
181
  state = items[ident].get("state")
@@ -441,8 +455,9 @@ def show(root: Path) -> int:
441
455
  except Unreachable as error:
442
456
  print(f"\nHolds in {path.stem}: holds not read — the remote cannot be read.\n {error}")
443
457
  return 0
444
- lines = []
445
- for item in data.get("deliverables") or []:
458
+ lines, held, stale = [], 0, 0
459
+ items = data.get("deliverables")
460
+ for item in items if isinstance(items, list) else []:
446
461
  if not isinstance(item, dict):
447
462
  continue
448
463
  ident, state = str(item.get("id")), item.get("state")
@@ -454,12 +469,14 @@ def show(root: Path) -> int:
454
469
  elif state not in TAKEABLE:
455
470
  lines.append(f" {ident} ended — {state}; was {said(found)}")
456
471
  elif time.time() >= found.worked + days * DAY:
472
+ held, stale = held + 1, stale + 1
457
473
  lines.append(f" {ident} STALE — {said(found)}: its holder renews (nstack claim {ident}); "
458
474
  f"anyone else may nstack claim {ident} --take-over")
459
475
  else:
476
+ held += 1
460
477
  lines.append(f" {ident} {said(found)}")
461
478
  where = f"from {name}" if name else "local only: no remote"
462
- print(f"\nHolds in {path.stem} ({where}; stale after {days} days without work):")
479
+ print(f"\nHolds in {path.stem}: {held} held, of which {stale} stale ({where}; stale after {days} days without work):")
463
480
  print("\n".join(lines) or " no ready deliverable")
464
481
  return 0
465
482
 
@@ -377,14 +377,14 @@ def check_cycle(root: Path, body: str, labels: set[str], today: datetime.date, f
377
377
  return
378
378
  path, data = cycle
379
379
  end = plan.as_date(data.get("end"))
380
- if end is not None and today > end:
380
+ if end is not None and today >= end: # `end` is the first day the cycle is over (the cycle form)
381
381
  fail("K2", f"circuit breaker: {path.name} ended on {end}, with no automatic extension.\n"
382
382
  " Action: the decider chooses — ship what is accepted (status: closed), "
383
383
  "frame a new cycle with a new appetite, or stop the project (status: stopped).")
384
384
  return
385
385
  match = DELIVERABLE.search(body)
386
386
  match = match and [match[0], match[1].upper()]
387
- states = {str(item.get("id")): item.get("state") for item in data.get("deliverables") or []
387
+ states = {str(item.get("id")): item.get("state") for item in (data.get("deliverables") if isinstance(data.get("deliverables"), list) else [])
388
388
  if isinstance(item, dict)}
389
389
  if not match:
390
390
  fail("K3", f"no deliverable named.\n Action: \"Deliverable: D<n>\" in the description, "
File without changes