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
59 changes: 59 additions & 0 deletions .github/workflows/release-tag.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Release tag

# Cuts v0.<milestone>.<patch> when every requirement of a milestone reads IMPLEMENTED
# in docs/spec/implementation_status.csv. No model runs: the ledger already records
# the Issue, pull request and merge commit behind each requirement, so nothing is left
# to judge. See scripts/release_tag.py.
#
# Idempotent by construction. Each tag's message carries a coverage-digest of the
# (requirement, merge commit) pairs behind it, and a milestone whose newest tag
# carries the same digest is not re-cut. A push that changed no coverage does nothing.

on:
push:
branches: [master]
paths:
- 'docs/spec/implementation_status.csv'
- 'docs/zendev/milestones.json'
workflow_dispatch:

permissions: {}

concurrency:
# Two pushes landing together must not both decide the same patch number.
group: release-tag
cancel-in-progress: false

jobs:
tag:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# Tags are the state this job reads, so it needs all of them, and it needs
# history to push one.
- uses: actions/checkout@v7
with:
fetch-depth: 0

- uses: actions/setup-python@v7
with:
python-version: '3.12'

- name: Act as the MACHINE identity
id: identity
uses: actions/create-github-app-token@v3
with:
client-id: ${{ vars.ZENDEV_MACHINE_APP_CLIENT_ID }}
private-key: ${{ secrets.ZENDEV_MACHINE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}

- name: Cut the tag if a milestone is newly complete
env:
GH_TOKEN: ${{ steps.identity.outputs.token }}
run: |
git config user.name "zendev-machine[bot]"
git config user.email "${{ steps.identity.outputs.app-slug }}[bot]@users.noreply.github.com"
git remote set-url origin \
"https://x-access-token:${{ steps.identity.outputs.token }}@github.com/${{ github.repository }}.git"
python scripts/release_tag.py
4 changes: 4 additions & 0 deletions .github/workflows/zendev-acceptor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ jobs:
run: |
cp scripts/record_usage.py "${RUNNER_TEMP}/record_usage.py"
cp scripts/subscription_usage.py "${RUNNER_TEMP}/subscription_usage.py"
# The scheme the record will name must travel with the recorder: read from
# the working tree it would be whatever the branch under review claims.
cp scripts/schemes.py "${RUNNER_TEMP}/schemes.py"
cp docs/zendev/schemes.json "${RUNNER_TEMP}/schemes.json"
# The whole control plane too: the rework bound below imports its neighbours.
cp -r scripts "${RUNNER_TEMP}/control-plane"

Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/zendev-author.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ jobs:
run: |
cp scripts/record_usage.py "${RUNNER_TEMP}/record_usage.py"
cp scripts/subscription_usage.py "${RUNNER_TEMP}/subscription_usage.py"
# The scheme the record will name must travel with the recorder: read from
# the working tree it would be whatever the branch under review claims.
cp scripts/schemes.py "${RUNNER_TEMP}/schemes.py"
cp docs/zendev/schemes.json "${RUNNER_TEMP}/schemes.json"

# The subscription is metered in rolling windows, not dollars. One reading before
# the model and one after; the recorder writes the difference to the private
Expand Down
24 changes: 24 additions & 0 deletions docs/spec/FEEDBACK_TO_RESEARCHER.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,30 @@ If you cannot create Issues from where you run, say so once in `ANSWERS_TO_IMPLE
and keep posting the findings; the operator will convert them, with a delay of hours
rather than minutes.

## 2026-09-07 — REQUIREMENTS_REGISTRY — milestone membership is not machine-readable

Observed: `EXECUTION_ORDER.md` states milestone membership in prose. M0 and M3 name
their requirements explicitly; M1 and M2 say only "M1 rows" and "M2 rows", and
`REQUIREMENTS_REGISTRY.csv` has no column saying which milestone a row gates.

Problem: the repository now cuts a release tag when every requirement of a milestone
reads IMPLEMENTED in the ledger. That needs the mapping as data. Read from prose it has
to be transcribed by hand, and a hand copy of your data goes stale the first time you
add a requirement — silently, since a requirement belonging to no milestone would
simply never appear in any gate.

Proposal: add a `MILESTONE` column to `REQUIREMENTS_REGISTRY.csv`, one value per row
(`M0`…`M12`), left empty for the cross-cutting rows — REQ-SCOPE-001, REQ-SCOPE-002,
REQ-VISUALIZATION-001, REQ-VISUALIZATION-002 — which gate no single milestone. No other
file needs to change.

Impact: until then M1 and M2 membership is a transcription in
`docs/zendev/milestones.json` marked `prose`, every release note repeats that
provenance, and a test fails the build if the transcription and the registry diverge.
The tags are correct, but their membership is our reading of your text rather than your
statement of it. When the column lands, `milestones.json` is deleted and the tagger
reads the registry directly.

### How your reviews count

Since 2026-09-06 (Issue #152):
Expand Down
44 changes: 44 additions & 0 deletions docs/zendev/milestones.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"_comment": [
"Which requirements each milestone gate covers, so a release tag can be cut from",
"the ledger without a person deciding what 'M1 is done' means.",
"",
"PROVISIONAL for M1 and M2. The mapping is the researcher's data and belongs in",
"REQUIREMENTS_REGISTRY.csv as a MILESTONE column; there is none, and",
"EXECUTION_ORDER.md carries the mapping only in prose. M0 and M3 are quoted there",
"explicitly and are marked `explicit` below; M1 and M2 are read off the milestone",
"descriptions and the named Milestone Preview, and are marked `prose`.",
"",
"The request to the researcher is in FEEDBACK_TO_RESEARCHER.md. When the column",
"arrives, this file is deleted and the tagger reads the registry instead.",
"",
"test_release_tag.py asserts every executable requirement in the registry appears",
"in exactly one milestone here, so a requirement arriving from the researcher",
"fails the build until it is mapped rather than silently escaping every gate.",
"",
"`unassigned` holds rows that gate no single milestone: scope statements and the",
"cross-cutting visibility rule."
],
"source": {"M0": "explicit", "M1": "prose", "M2": "prose", "M3": "explicit"},
"milestones": {
"M0": [
"REQ-MIGRATION-001", "REQ-MIGRATION-002", "REQ-MIGRATION-003",
"REQ-MIGRATION-004", "REQ-VISUALIZATION-003"
],
"M1": [
"REQ-CORE-001", "REQ-CORE-002", "REQ-CORE-003",
"REQ-CONFIG-001", "REQ-CONFIG-002", "REQ-CONFIG-003", "REQ-CONFIG-004",
"REQ-CONFIG-005", "REQ-VISUALIZATION-004"
],
"M2": [
"REQ-CORE-004", "REQ-CORE-005", "REQ-CORE-006",
"REQ-ACCEPTANCE-001", "REQ-ACCEPTANCE-002", "REQ-ACCEPTANCE-003",
"REQ-VISUALIZATION-005"
],
"M3": [
"REQ-MARKET-001", "REQ-MARKET-002", "REQ-MARKET-003", "REQ-MARKET-004",
"REQ-MARKET-005", "REQ-ACCEPTANCE-004", "REQ-VISUALIZATION-006"
]
},
"unassigned": ["REQ-SCOPE-001", "REQ-SCOPE-002", "REQ-VISUALIZATION-001", "REQ-VISUALIZATION-002"]
}
90 changes: 90 additions & 0 deletions docs/zendev/schemes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
{
"_comment": [
"A scheme is the setup the loop runs under: which roles exist, which identity owns",
"the formal verdict, which model each role uses, which gates are mandatory, what",
"bounds a run, and who else has a voice. Change any of it and the numbers from",
"before are numbers about a different system.",
"",
"The rule for cutting a new scheme is mechanical on purpose: any field below",
"changes, the scheme number goes up. Arguing case by case about whether a change",
"is 'significant' is how a comparison quietly stops being one.",
"",
"Each scheme is tagged `scheme/N` on the commit where it took effect, with this",
"descriptor in the tag message, and every telemetry record carries {id, digest} so",
"a measurement window can be sliced by scheme without knowing any dates."
],
"active": "scheme/3",
"schemes": [
{
"id": "scheme/1",
"in_force_from": "2026-09-05T18:40:00Z",
"commit": "",
"summary": "Three GitHub Apps live; author and acceptor on Haiku; the formal verdict is read from any account.",
"roles": {
"author": {"identity": "zendev-author[bot]", "model": "claude-haiku-4-5", "max_budget_usd": 3.0},
"acceptor": {"identity": "zendev-acceptor[bot]", "model": "claude-haiku-4-5", "max_budget_usd": 2.0},
"machine": {"identity": "zendev-machine[bot]", "model": null}
},
"verdict_owner": "any account",
"voices": ["EndlessZen as drevendev, formal reviews on code"],
"required_checks": ["build-and-test", "typescript", "policy-guard", "mergeability"],
"rework_limit": 3,
"cadence_minutes": 25,
"note": "Retired by the budget change in #213."
},
{
"id": "scheme/2",
"in_force_from": "2026-09-06T11:43:00Z",
"commit": "5c9f9ec",
"summary": "As scheme/1 with the per-run ceilings raised. The measured day of 2026-09-06/07 lies entirely inside this scheme.",
"roles": {
"author": {"identity": "zendev-author[bot]", "model": "claude-haiku-4-5", "max_budget_usd": 5.0},
"acceptor": {"identity": "zendev-acceptor[bot]", "model": "claude-haiku-4-5", "max_budget_usd": 3.0},
"machine": {"identity": "zendev-machine[bot]", "model": null}
},
"verdict_owner": "any account",
"voices": ["EndlessZen as drevendev, formal reviews on code"],
"required_checks": ["build-and-test", "typescript", "policy-guard", "mergeability"],
"rework_limit": 3,
"cadence_minutes": 25,
"note": "Measured: 125 runs, $41.15, 8 requirements landed, and 65% of spend in pull requests that could no longer be accepted. Retired for that reason."
},
{
"id": "scheme/3",
"in_force_from": "",
"commit": "",
"summary": "The ACCEPTOR identity alone owns the formal verdict; every other account's review is evidence.",
"roles": {
"author": {"identity": "zendev-author[bot]", "model": "claude-haiku-4-5", "max_budget_usd": 5.0},
"acceptor": {"identity": "zendev-acceptor[bot]", "model": "claude-haiku-4-5", "max_budget_usd": 3.0},
"machine": {"identity": "zendev-machine[bot]", "model": null}
},
"verdict_owner": "zendev-acceptor",
"voices": ["EndlessZen as drevendev, formal reviews on code (read as evidence)"],
"required_checks": ["build-and-test", "typescript", "policy-guard", "mergeability"],
"rework_limit": 3,
"cadence_minutes": 25,
"note": "In force from the merge of the verdict-ownership pull request. `in_force_from` and `commit` are filled by the operator at that merge."
},
{
"id": "scheme/4",
"in_force_from": "",
"commit": "",
"summary": "SLOPSTER joins as an external QA voice with read access only; its findings reach the loop through the intake job.",
"roles": {
"author": {"identity": "zendev-author[bot]", "model": "claude-haiku-4-5", "max_budget_usd": 5.0},
"acceptor": {"identity": "zendev-acceptor[bot]", "model": "claude-haiku-4-5", "max_budget_usd": 3.0},
"machine": {"identity": "zendev-machine[bot]", "model": null}
},
"verdict_owner": "zendev-acceptor",
"voices": [
"EndlessZen as drevendev, owns the specification",
"SLOPSTER as AndyDev, read-only: comments and Issues, never a formal review"
],
"required_checks": ["build-and-test", "typescript", "policy-guard", "mergeability"],
"rework_limit": 3,
"cadence_minutes": 25,
"note": "Not in force. Activated when the QA voice's hourly cadence starts."
}
]
}
19 changes: 19 additions & 0 deletions scripts/record_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
import urllib.error
import urllib.request

# Staged next to this module by the workflow, for the reason given in its own header.
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import schemes # noqa: E402

TOKEN_FIELDS = (
"input_tokens",
"output_tokens",
Expand Down Expand Up @@ -356,6 +360,20 @@ def main() -> int:
if before is None or after is None:
warn("a subscription reading is missing; the subscription block carries nulls")

# Which setup produced this run. Without it a window of records cannot be sliced
# by scheme, and comparing two days means trusting that nothing changed between
# them — which, over the three days this loop has run, was never true.
scheme_stamp = schemes.stamp()
if scheme_stamp is None:
warn("no active scheme could be read; the record carries no scheme block")
else:
drift = schemes.check_observed(schemes.active(schemes.load()), args.role, model)
if drift:
# Loud, and not fatal. The record is still worth writing — it is the
# evidence of the drift.
warn(f"scheme drift: {drift}")
scheme_stamp = dict(scheme_stamp, matches_observed=False)

now = dt.datetime.now(dt.timezone.utc)
record = {
"recorded_at": now.isoformat(timespec="seconds"),
Expand All @@ -372,6 +390,7 @@ def main() -> int:
"duration_ms": extra.get("duration_ms"),
"num_turns": extra.get("num_turns"),
"session_id": extra.get("session_id"),
"scheme": scheme_stamp,
"subscription": subscription_block(before, after),
"run_id": args.run_id,
"run_url": args.run_url,
Expand Down
Loading
Loading