Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/governance/workstreams.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ state to its index: [`docs/adr/`](../adr/README.md), [`docs/pdr/`](../pdr/README
| D207 | **An attested project's screenshots had no route** (#341). On the third project, with `checks: attested`, two criteria asked for screenshots in the sheet: a verifier found them named only, paths in a scratch folder gone after the run. The playbook asked for a CI artefact — none with attested checks — or an image attached to the pull request — which an App cannot do through the API. The project committed small PNGs under the module's `docs/evidence/`, unguided | **Done**: the verification playbook names the route — the verifier commits the screenshots and videos proving a criterion on a branch of its own, `evidence/<pull request>`, never on the change's branch: its head stays the one verified (T4) and the verifier writes no commit of the change (T2) —, links each file from the sheet at that branch's commit, made-up data only, each file read before it is pushed, bounded by the `check-added-large-files` hook as it is committed; the branch goes once the cycle is closed. `tests.md`, `verification.md`'s automated step and `.gitignore` say it. Not built: an upload by `nstack attest`, weight for no user yet. A fresh verifier's finding fixed: a first draft committed beside the module, which T2 and T4 refuse. Test seen red first: the playbook names the branch and the hook, and the skeleton runs it |
| D208 | **A person named verifier was the author's word** (#363). On the third project, 2026-11-04, pull request #643, opened by an agent App, said `Verifier: @napkinstack-admin` over results, evidence and commits it had written itself; that account never touched the pull request. T2 compared the name with the change's authors only: the sheet passed it, and on a project that owes no approval the merge waited on T4 alone, which the author turns green by running its own tests at the head. The project's session caught it by reading | **Done**: `nstack pr-check` reads, in one GraphQL query, whose hand is on the pull request — its description's editors, its reviewers, its commenters — and T2 refuses a person named verifier who has none — each person named, so that a second name does not carry the first; a bot named with its `[bot]` suffix is the same account. A verifier session is out of scope: it acts under the same App as the author's. Unread — no token, no answer, a field refused, more than one page —, it is said as a warning, never failed; on a workstation and under `nstack attest`, with no pull request number or recording the head, it is not read. The skeleton's `pull-request.yml` gives it `PR_NUMBER` and the job's token, with `pull-requests: read`, and runs again on a submitted review, as it does on an edit of the description; a comment is read at the next run. `RULES.md`, `05-workflow.md` §7 and `verification.md` say it. A fresh verifier's findings fixed: a field refused by the forge crashed the check, a review did not start it again, a second name carried the first, and four mutants survived. Test seen red first: the case of #643 refused, each kind of hand accepted, the query's shape and its unread answers, and the workflow's wiring |
| D209 | **An unattested head read "compliant" on the forge** (#367). On a fourth project, 2026-11-07, `checks: attested`, a pull request was opened with no record on its head: the CI job "Test sheet and cycle" said `attested — judged where nstack attest runs, not in CI` and `Pull request rules: compliant.` — `check_attested` returned before reading anything under GitHub Actions, the checkout keeping no credential to fetch the notes; the status `nstack / local checks` is posted only by a session holding a token, and that one had none. Only an observer's local `pr-check` said A1 | **Done**: the record is read from the remote's notes in CI as on a workstation — the fetch carries the job's token in git's environment (`http.extraheader`), masked for the runner, never in an argument a log shows, only towards GitHub and only under GitHub Actions — on a workstation the remote's own credential answers, an expired token in the environment would refuse it; an unattested head is refused (A1), a failed or incomplete record too (A2). A remote that cannot be read — no token, a refusal — is a warning that names why, never the silent line. The A1 action says the check reads the record again at its next run, a push or an edit of the description. `07-governance.md` §7, `RULES.md` and the workflow's `contents: read` say it. Tests seen red first: a record pushed with the notes, read by a fresh checkout under CI; no record on the remote refused; a failed record refused; no remote, and a remote that refuses, warned with git's reason; notes listed but not fetched unread; the token reaching git in CI and never on a workstation. A fresh verifier's findings fixed: the token was sent on a workstation too (an expired one refused the fetch, seen on a private repository), the refusing remote was untested, git's reason could be a fragment, the secret was not masked; three mutants killed. Probed on a private repository: an installation token in the environment reads its notes |
| D210 | **`nstack claim` took an earlier cycle's start branch, then made none.** On a project, a cycle's D5 claim answered *work continues on d5-…, which exists: no other branch is made* — the start branch of an earlier cycle's D5, another title, its one empty *Start* commit never deleted on the remote: any `d<n>-…` branch counted as the deliverable's work (D149). Once that branch was deleted, claiming again only renewed the hold, and still made no work branch | **Done**: an empty branch whose first commit off the default branch is a claim's start for another title of the same id is an earlier cycle's, never this deliverable's work — the claim makes its own; a hand-named branch, and this deliverable's own start, are still taken. A renewed hold with no work branch left gets its first one again, unless the cycle's default branch has merged the deliverable; with one, the renewal says nothing of it. `project.md` says both. Six tests: two seen red first, the merge guard's seen red without it |
| v0.6.0 | **What is announced refuses, and the documents say what is true**: D51 to D54, and the documentary lot of the internal audit | **Done (2026-09-20)**: five workstreams merged (#83, #84, #85, #86, #87), **v0.6.0 published**, tag on `8ae544c`, verified on published artefacts — including an update of `tool-library` from v0.5.0, 9 files and no conflict, still compliant. Found doing it: D55. Next: the pilot, and M11 beside it |
| Pilot | **`NapkinStack/flet`, the first real project**, run by a session that knew nothing of this repository, with the maintainer as decider and this repository's agent as observer — public on the Free plan, where the whole checklist is available, rather than private on Team where G5 is paid and G6 does not exist | **Set aside by the maintainer, 2026-10-15**, at its cycle boundary; the real product followed since is `summit`. **Ran from 2026-09-20**: discovery decided *go* after three rounds and two independent challenges, charter accepted; cycle 01 closed `stopped/reframed`, **cycle 02 closed** with two deliverables accepted and one deferred (its #27), **cycle 03 framed and approved** (#28, #30), which starts its first `critical` module; the framework taken to v0.6.1 by itself at the boundary (#29), v0.6.2 proposed (#31). It has produced D56 to D63, D68, D70 to D77, replicated D26 and D55, and measured D49 — the first figures on what an independent verifier finds (PDR-0003) |
| M11 | **Ceremony follows consequence** (PDR-0011): the framework charges what a failure costs, per module and per project, and states each price before it is paid; with PDR-0004's routing, and PDR-0007, PDR-0008 and PDR-0010 — through to v0.7.0 | **Published as v0.7.0 (2026-10-12).** Planned 2026-09-23 (#98). Merged: **M11f** (#101), **M11a** (#102), **M11b** (#103), **M11d** (#104), **M11e** (#105), **M11c** (#106), **M11h** (#107), **M11g** (#108) closed by its probe — not clean, returned to M13; **M11m** (D68, D74, D75); **M11l** (PDR-0010, D59, D60); **M11n** (D79); **M11o** (PDR-0010 extended: every document has its moment); **M11p** (PDR-0002's three levels given a place: `docs/project/rules/`); **M11j** (PDR-0007: the unframed stage); **M11q** (no folder created empty; PDR-0012 proposed: a project's tree is the project's). **M11k** (PDR-0008's print mode: `nstack settings`; D77, D78). **M11s** (PDR-0012 accepted; the framework's files under `.nstack/`; an update carries what a project adapted); **M11u** (an update rewrites the old addresses a project wrote in its own files, D85). **M11t** (`nstack new-contract`: the contracts module is created by the first contract; PDR-0012 built in full); **M11v** (what the third project found: D81, D82, D83, D86, D87). **M11i**: v0.7.0 published 2026-10-12 (#125, D88). To do: **M11r** PDR-0008's apply mode and D57, after a probe on the real forge; the two earlier projects take v0.7.0 themselves, observed |
Expand Down
78 changes: 78 additions & 0 deletions platform/tests/test_hold.py
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,84 @@ def test_another_deliverable_s_branch_is_not_taken_for_this_one(shared, capsys):
assert git(shared, "rev-parse", "--verify", "--quiet", "d1-resume-an-interrupted-recording")


def _start_branch(shared: Path, branch: str, title: str) -> None:
"""A branch `claim` started for a D1 titled otherwise — an earlier cycle's, never deleted."""
tree = git(shared, "rev-parse", "main^{tree}")
start = git(shared, "commit-tree", tree, "-p", "main", "-m", f"Start D1 — {title}")
git(shared, "update-ref", f"refs/heads/{branch}", start)


def test_another_cycle_s_start_branch_of_the_same_id_is_not_taken_for_this_one(shared, capsys):
_start_branch(shared, "d1-an-older-title", "An older title")
capsys.readouterr()
assert hold.claim(clone(shared, "alice"), "D1", session="alice-d1", person="@alice") == 0
out = capsys.readouterr().out
assert "work continues on d1-an-older-title" not in out, out
assert "work branch d1-resume-an-interrupted-recording created" in out, out
assert git(shared, "rev-parse", "--verify", "--quiet", "d1-resume-an-interrupted-recording")


def test_this_deliverable_s_own_start_branch_is_still_taken(shared, capsys):
_start_branch(shared, "d1-resume-an-interrupted-recording", "Resume an interrupted recording")
capsys.readouterr()
assert hold.claim(clone(shared, "alice"), "D1", session="alice-d1", person="@alice") == 0
assert "work continues on d1-resume-an-interrupted-recording" in capsys.readouterr().out


def test_a_retitled_deliverable_s_worked_branch_is_still_its_own(shared, capsys):
alice = clone(shared, "alice")
hold.claim(alice, "D1", session="alice-d1", person="@alice")
git(alice, "switch", "-q", "d1-resume-an-interrupted-recording")
(alice / "resume.txt").write_text("work\n", encoding="utf-8")
git(alice, "add", "resume.txt")
git(alice, "commit", "-q", "-m", "Resume from the last chunk")
git(alice, "push", "-q", "origin", "d1-resume-an-interrupted-recording")
git(alice, "switch", "-q", "main")
cycle = alice / "docs/project/cycles/03-the-window.md"
cycle.write_text(cycle.read_text(encoding="utf-8").replace("Resume an interrupted recording", "Resume a cut recording"),
encoding="utf-8")
git(alice, "commit", "-q", "-am", "Retitle D1")
git(alice, "push", "-q", "origin", "main")
capsys.readouterr()
assert hold.claim(alice, "D1", session="alice-d1", person="@alice") == 0
assert "work branch" not in capsys.readouterr().out
assert git(shared, "for-each-ref", "--format=%(refname:short)", "refs/heads/d1-*") == "d1-resume-an-interrupted-recording"


def test_a_renewed_hold_with_no_work_branch_makes_one(shared, capsys):
alice = clone(shared, "alice")
hold.claim(alice, "D1", session="alice-d1", person="@alice")
git(shared, "branch", "-D", "d1-resume-an-interrupted-recording")
git(alice, "branch", "-D", "d1-resume-an-interrupted-recording")
capsys.readouterr()
assert hold.claim(alice, "D1", session="alice-d1", person="@alice") == 0
out = capsys.readouterr().out
assert "D1 renewed" in out and "work branch d1-resume-an-interrupted-recording created" in out, out
assert git(shared, "rev-parse", "--verify", "--quiet", "d1-resume-an-interrupted-recording")


def test_a_renewed_hold_whose_work_is_merged_makes_no_branch_again(shared, capsys):
alice = clone(shared, "alice")
hold.claim(alice, "D1", session="alice-d1", person="@alice")
git(alice, "commit", "-q", "--allow-empty", "-m", "Resume an interrupted recording (D1) (#5)")
git(alice, "push", "-q", "origin", "main")
git(shared, "branch", "-D", "d1-resume-an-interrupted-recording")
git(alice, "branch", "-D", "d1-resume-an-interrupted-recording")
capsys.readouterr()
assert hold.claim(alice, "D1", session="alice-d1", person="@alice") == 0
assert "work branch" not in capsys.readouterr().out
assert not git(shared, "for-each-ref", "refs/heads/d1-*")


def test_a_renewed_hold_with_its_work_branch_says_nothing_of_it(shared, capsys):
alice = clone(shared, "alice")
hold.claim(alice, "D1", session="alice-d1", person="@alice")
capsys.readouterr()
assert hold.claim(alice, "D1", session="alice-d1", person="@alice") == 0
out = capsys.readouterr().out
assert "D1 renewed" in out and "work branch" not in out and "work continues" not in out, out


# --- D165: a hold lasts its cycle (#280) -------------------------------------------------------

@pytest.mark.parametrize("after", ["between two cycles", "under the next cycle"])
Expand Down
6 changes: 5 additions & 1 deletion skeleton/.nstack/handbook/project.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,11 @@ nstack claim D30 --take-over --person @alice # a hold nobody worked o

Two agents one person runs are two holders: a session renews only a hold it is on. Another
session of that person is refused like anyone else, and takes the hold with `--take-over` when
the person says so, stale or not; a person with no `--session` renews their own.
the person says so, stale or not; a person with no `--session` renews their own. A renewed
hold with no work branch left gets its first one again, unless the deliverable is merged.

An empty branch its claim started for another title of the same id — an earlier cycle's `D30` —
is never this deliverable's work: the claim makes its own beside it.

When a deliverable's work went on other branches, the empty branch its claim started stays:
`nstack plan` names it once the default branch records the deliverable's delivery — its
Expand Down
42 changes: 35 additions & 7 deletions src/napkinstack/hold.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,24 +251,47 @@ def slug(title: str) -> str:
return "-".join(kept)[:40] or "work"


def _work_branch(root: Path, name: str | None, ident: str, title: str, session: str | None) -> tuple[str, str | None]:
def _empty(root: Path, ref: str, base: str) -> bool:
"""One commit off the default branch, with its parent's tree: nothing was worked on it."""
return (_git(root, "rev-list", "--count", f"{base}..{ref}").stdout.strip() == "1"
and _git(root, "rev-parse", f"{ref}^{{tree}}").stdout == _git(root, "rev-parse", f"{ref}~1^{{tree}}").stdout)


def _started_for_another(root: Path, ref: str, base: str, ident: str, title: str) -> bool:
"""An empty start `claim` made for another title: a branch with work stays the deliverable's,
whatever its title has become since."""
subject = _git(root, "log", "-1", "--format=%s", ref).stdout.strip()
start = f"Start {ident} — "
return subject.startswith(start) and subject != start + title and _empty(root, ref, base)


def _work_branch(root: Path, name: str | None, ident: str, title: str, session: str | None,
renew: bool = False) -> tuple[str, str | None]:
"""The deliverable's first work branch, from the default branch, unless it exists: what is
said, and the branch when it was made and pushed."""
said, and the branch when it was made and pushed. A renewed hold says nothing of a branch that
exists, and starts none once the cycle's default branch has merged the deliverable."""
branch = f"{ident.lower()}-{slug(title)}"
# Any d<n>-… branch is the deliverable's work, as the hold's freshness reads it: the one checked
# out first, then the remote's, then this checkout's — never a second one beside it (#232).
# An empty branch `claim` started for another title of the same id is an earlier cycle's, never this one's.
current = _git(root, "branch", "--show-current").stdout.strip()
pattern = f"{ident.lower()}-*"
shared = _git(root, "for-each-ref", "--format=%(refname:lstrip=3)", f"{MIRROR}/work/{pattern}").stdout.split() if name else []
local = _git(root, "for-each-ref", "--format=%(refname:short)", f"refs/heads/{pattern}").stdout.split()
base = f"{MIRROR}/base" if name else "refs/heads/main"
shared = [one for one in (_git(root, "for-each-ref", "--format=%(refname:lstrip=3)", f"{MIRROR}/work/{pattern}").stdout.split()
if name else []) if not _started_for_another(root, f"{MIRROR}/work/{one}", base, ident, title)]
local = [one for one in _git(root, "for-each-ref", "--format=%(refname:short)", f"refs/heads/{pattern}").stdout.split()
if not _started_for_another(root, f"refs/heads/{one}", base, ident, title)]
existing = [current] if current in local else shared + [one for one in local if one not in shared]
if existing and renew:
return "", None
if existing:
found = existing[0]
if name and found not in shared:
return (f"work continues on {found}, which exists here only: no other branch is made.\n"
f" Next: git push -u {name} {found} — the hold stays fresh with the remote's work"), None
return f"work continues on {found}, which exists: no other branch is made", None
base = f"{MIRROR}/base" if name else "refs/heads/main"
if renew and ident in _read_merges(root, name, plan.accepted_cycle(root)[1]).live:
return "", None
if _git(root, "rev-parse", "--verify", "--quiet", base).returncode != 0:
return "no work branch created: the default branch cannot be read.\n Next: create d<n>-… yourself.", None
tree = _git(root, "rev-parse", f"{base}^{{tree}}", check=True).stdout.strip()
Expand Down Expand Up @@ -471,6 +494,12 @@ def claim(root: Path, ident: str, session: str | None = None, person: str | None
made, branch = _work_branch(root, name, ident, title, session)
print(made)
print(_show(root, name, ident, title, person, branch, previous) + ".")
else:
made, branch = _work_branch(root, name, ident, title, session, renew=True)
if made:
print(made)
if branch:
print(_show(root, name, ident, title, person, branch, previous) + ".")
return 0


Expand Down Expand Up @@ -634,8 +663,7 @@ def _empty_starts(root: Path, name: str | None, items: list) -> list[str]:
branch, ref, subject = (line.split("\t", 2) + ["", ""])[:3]
ident = branch.split("-")[0]
if (ident not in known or not subject.startswith(f"Start {ident.upper()} — ")
or _git(root, "rev-list", "--count", f"{base}..{ref}").stdout.strip() != "1"
or _git(root, "rev-parse", f"{ref}^{{tree}}").stdout != _git(root, "rev-parse", f"{ref}~1^{{tree}}").stdout):
or not _empty(root, ref, base)):
continue
if ident in states and plan.one_of(states[ident], TAKEABLE) and not _landed(root, ident, ref, base):
continue
Expand Down
Loading