@pilotspace/add 1.5.0 → 1.6.0
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.
- package/CHANGELOG.md +36 -0
- package/docs/16-releasing.md +182 -0
- package/docs/README.md +3 -0
- package/docs/appendix-c-glossary.md +11 -1
- package/package.json +1 -1
- package/skill/add/SKILL.md +7 -0
- package/skill/add/release.md +114 -0
- package/tooling/add.py +326 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,42 @@ All notable changes to the ADD method (`@pilotspace/add` on npm,
|
|
|
4
4
|
`pilotspace-add` on PyPI) are documented here. The format follows
|
|
5
5
|
[Keep a Changelog](https://keepachangelog.com/); versions follow semver.
|
|
6
6
|
|
|
7
|
+
## [1.6.0] — 2026-06-16
|
|
8
|
+
|
|
9
|
+
The releasing release: shipping a versioned cut is now a first-class **5th ADD
|
|
10
|
+
scope level**, not an ad-hoc ritual. The AI gathers the inventory and drafts
|
|
11
|
+
evidence-backed notes, the engine records the cut behind a security-hard-stop
|
|
12
|
+
readiness floor, and the human owns the tag and publish. All additive; no
|
|
13
|
+
breaking changes (SemVer MINOR).
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **The RELEASE scope level (`release.md`)** — the on-demand guide for the 7-step
|
|
17
|
+
flow `cue → gather → draft notes → readiness floor → human confirms → cut → watch`,
|
|
18
|
+
orthogonal to stage: bundle one or more closed milestones into a versioned,
|
|
19
|
+
watched cut. Cross-referenced from `SKILL.md`.
|
|
20
|
+
- **`add.py release-report`** — a read-only gather of the five record-sets (closed
|
|
21
|
+
milestones · their consolidated deltas · riding `RISK-ACCEPTED` waivers · open
|
|
22
|
+
security `HARD-STOP` · scenarios → monitors), with `--json`, plus the
|
|
23
|
+
`→ releasable: N milestone(s)` status cue.
|
|
24
|
+
- **`add.py release <version>`** — a guarded, record-only cut: it prepends the
|
|
25
|
+
`CHANGELOG.md` block, appends an append-only `RELEASES.md` ledger row
|
|
26
|
+
(newest-first), and attributes the bundled milestones — behind a four-code
|
|
27
|
+
readiness floor (`release_security_open` · `release_tests_red` ·
|
|
28
|
+
`release_no_closed_milestone` · `release_undisclosed_waiver`). The security stop
|
|
29
|
+
is **un-forceable**: `--force` can override the other three, never that one.
|
|
30
|
+
- **`RELEASES.md`** — the append-only release ledger (date · version · milestones ·
|
|
31
|
+
waivers · evidence); membership is the attribution source, so the cue never has
|
|
32
|
+
to read a compacted milestone file.
|
|
33
|
+
- **Book chapter 16 (`16-releasing.md`)** + five glossary entries (Release · Release
|
|
34
|
+
scope level · Readiness floor · RELEASES.md ledger · Hotfix release) + the
|
|
35
|
+
`test_release_docs_accord` guard that keeps the book in accord with `release.md`.
|
|
36
|
+
|
|
37
|
+
### Notes
|
|
38
|
+
- **The engine records; the human ships.** `add.py release` writes the changelog +
|
|
39
|
+
ledger + attribution; it never bumps a version source, tags, publishes, or
|
|
40
|
+
deploys. The outward act stays human-owned and tool-agnostic — exactly the
|
|
41
|
+
human-gated `git tag` that cut this very release.
|
|
42
|
+
|
|
7
43
|
## [1.5.0] — 2026-06-16
|
|
8
44
|
|
|
9
45
|
The UDD design-loop release: defining the design *before* the code is now a
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# 16 · Releasing
|
|
2
|
+
|
|
3
|
+
[← 15 Foundations & Lineage](./15-foundations-and-lineage.md) · [Contents](./README.md) · Next: [Appendix A Templates →](./appendix-a-templates.md)
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
The flow chapters ([03](./03-step-1-specify.md)–[08](./08-step-6-verify.md)) take one feature from
|
|
8
|
+
spec to verified. The loop chapter ([09](./09-the-loop.md)) keeps a milestone going until its goal
|
|
9
|
+
is met. The stages chapter ([10](./10-setup-and-stages.md)) graduates the project's rigor. None of
|
|
10
|
+
them *ship*. This chapter names the act every project eventually performs and that the method, until
|
|
11
|
+
now, never formalized: bundling closed milestones into a versioned, user-facing release whose notes
|
|
12
|
+
are evidence-backed, whose risk is disclosed, and whose behaviour is then watched.
|
|
13
|
+
|
|
14
|
+
Releasing is the **fifth scope level** — after the task, the milestone, the foundation/setup level,
|
|
15
|
+
and stage graduation. Like every scope level it runs the same shape: **gather → propose → the human
|
|
16
|
+
confirms → the engine records and enforces a floor.** And like graduation, it ends with an outward
|
|
17
|
+
act the human owns. The operational recipe lives in the `release.md` skill guide; this chapter is the
|
|
18
|
+
*why* behind it.
|
|
19
|
+
|
|
20
|
+
## 16.1 · Why release is its own scope level
|
|
21
|
+
|
|
22
|
+
A number bump is not a release. A release is the moment one or more **closed milestones** become a
|
|
23
|
+
versioned cut that real users can run. Three distinctions make it its own scope level:
|
|
24
|
+
|
|
25
|
+
- **Milestone ≠ release.** A milestone is *feature-complete and consolidated* — its goal is met and
|
|
26
|
+
its lessons are gathered into the foundation (see [14 · The foundation](./14-foundation.md) and the
|
|
27
|
+
`fold.md` retrospective consolidation). A release is *shipped and watched*. The first is an internal
|
|
28
|
+
state; the second faces outward.
|
|
29
|
+
- **Graduation ≠ release.** Stage graduation moves the project's *rigor* (mvp → production); a release
|
|
30
|
+
ships a *version*. The two axes are orthogonal: you cut a prototype preview, an mvp beta, and a
|
|
31
|
+
production GA, each at its own stage. You release at every stage, not only at the end.
|
|
32
|
+
- **A release bundles; it does not equal.** One version may attribute several milestones — "we shipped
|
|
33
|
+
after a couple of milestones closed" is the normal case, not the exception. Forcing one release per
|
|
34
|
+
milestone is the anti-pattern; the decoupling is the whole point.
|
|
35
|
+
|
|
36
|
+
So release sits beside the other scope levels rather than inside any of them. The granularity ladder
|
|
37
|
+
is now complete: intake → milestone → task, with stage graduation and release as the two cross-cutting
|
|
38
|
+
levels that change *rigor* and ship *versions* respectively (see the **Scope level** entry in
|
|
39
|
+
[Appendix C](./appendix-c-glossary.md)).
|
|
40
|
+
|
|
41
|
+
## 16.2 · The cue and the inventory — gather, never judge
|
|
42
|
+
|
|
43
|
+
The trigger is a status line. When at least one milestone is `done`, archived, and not yet attributed
|
|
44
|
+
to any release, `add.py status` prints:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
→ releasable: N milestone(s) closed since last release
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
That line is a **tally**, never a verdict. It counts archived-but-unreleased milestones; it is silent
|
|
51
|
+
for a project that has never released or has already shipped everything (grandfathered — zero change).
|
|
52
|
+
It says *there is something to consider*, not *you are ready*.
|
|
53
|
+
|
|
54
|
+
To gather the cut's evidence, run `add.py release-report`. It clusters five labeled record-sets: the
|
|
55
|
+
closed milestones since the last release · their **consolidated deltas** (the "what changed" record) ·
|
|
56
|
+
the open `RISK-ACCEPTED` waivers riding into the release · any open security `HARD-STOP` (a blocker) ·
|
|
57
|
+
the §2 scenarios to take live as monitors. The report **gathers; it does not judge** — there is no
|
|
58
|
+
readiness score to read off, because a tally that pretended to be a verdict would invite reading the
|
|
59
|
+
number instead of the evidence.
|
|
60
|
+
|
|
61
|
+
The reuse claim, stated plainly: **the consolidated deltas are the changelog source.** You do not write
|
|
62
|
+
release notes from memory. The foundation already recorded what changed when each milestone was
|
|
63
|
+
consolidated (`fold.md`); the release surfaces it. This is why release runs *after* consolidation, not
|
|
64
|
+
before. The lifecycle order is one line:
|
|
65
|
+
`milestone-done → fold → compact → archive → (repeat ≥1×) → release → watch`.
|
|
66
|
+
|
|
67
|
+
## 16.3 · Drafting the notes and the version — the proposal
|
|
68
|
+
|
|
69
|
+
From those record-sets you draft a [Keep a Changelog](https://keepachangelog.com/) entry: group the
|
|
70
|
+
changes under Added / Changed / Fixed and name the headline capabilities concretely, in the user's
|
|
71
|
+
language, not the commit's. Each bundled milestone's goal anchors one or more entries.
|
|
72
|
+
|
|
73
|
+
Then propose the version. Semver is a decision the evidence informs but does not make for you: a
|
|
74
|
+
breaking change is a MAJOR, a new capability a MINOR, a fix-only cut a PATCH. You propose the bump; the
|
|
75
|
+
**human confirms it** — the version is a judgement, not a default the tool fills in.
|
|
76
|
+
|
|
77
|
+
Both the notes and the version are *shown before they are asked about*. Present the drafted entry, the
|
|
78
|
+
proposed version, and the waivers shipping in this cut via the report template — opening with the ARC
|
|
79
|
+
(goal · done · plan) — and let the human approve once. The question is a summary to decide on; the
|
|
80
|
+
artifact itself is rendered first, never pre-stamped.
|
|
81
|
+
|
|
82
|
+
## 16.4 · The floor — what the engine enforces
|
|
83
|
+
|
|
84
|
+
`add.py release <version>` is **guarded**. Before it records anything it enforces a readiness floor,
|
|
85
|
+
refusing with a non-zero exit and leaving every file byte-unchanged on any of four conditions:
|
|
86
|
+
|
|
87
|
+
- `release_security_open` — an open security `HARD-STOP` exists. This is the non-negotiable one: a
|
|
88
|
+
security finding is never shipped. Resolve it first, as a change request back to Specify.
|
|
89
|
+
- `release_tests_red` — the suite is not green. A release ships on evidence, not on a plausible diff.
|
|
90
|
+
- `release_no_closed_milestone` — nothing new since the last release. The cut would be a no-op; do not
|
|
91
|
+
bump a version to mark time.
|
|
92
|
+
- `release_undisclosed_waiver` — a `RISK-ACCEPTED` waiver rides into the release but is missing from the
|
|
93
|
+
notes. Disclosure *is* the floor: a shipped risk the user cannot read about is a hidden risk.
|
|
94
|
+
|
|
95
|
+
The security stop is **un-forceable.** `--force` exists for grandfathered and brownfield first-cuts —
|
|
96
|
+
the same authority valve as `stage --force` ([10 · Setup and stages](./10-setup-and-stages.md)) — and
|
|
97
|
+
it can override the other three rejects, but it can never override `release_security_open`. This mirrors
|
|
98
|
+
the verify gate exactly ([08 · Verify](./08-step-6-verify.md)) and the governance ceiling that no
|
|
99
|
+
autonomy level may lift ([11 · Governance](./11-governance.md)): a security `HARD-STOP` is the one
|
|
100
|
+
outcome the method refuses to auto-pass, at verify and again at the cut.
|
|
101
|
+
|
|
102
|
+
## 16.5 · The cut versus the ship — the engine records, the human ships
|
|
103
|
+
|
|
104
|
+
Only after the human confirms do you run the cut. And here is the line that keeps releasing honest:
|
|
105
|
+
**the engine records; the human ships.**
|
|
106
|
+
|
|
107
|
+
`add.py release <version>` **records** the marker. It prepends the entry to `CHANGELOG.md`, stamps one
|
|
108
|
+
append-only row (newest-first, like the §Key Decisions log) into `RELEASES.md` — date · version ·
|
|
109
|
+
milestones · waivers shipped · evidence — and attributes the bundled milestones to this version, so the
|
|
110
|
+
cue stops firing for them. The ledger is the attribution source: a milestone is "released" because a
|
|
111
|
+
`RELEASES.md` row says so, never because a file was edited to claim it.
|
|
112
|
+
|
|
113
|
+
What the engine does **not** do is act outward. It **never tags, publishes, or deploys.** The outward
|
|
114
|
+
act — `git tag`, `npm publish`, the deploy pipeline — is the human's, tool-agnostic, exactly as the
|
|
115
|
+
method tool "never renders" in design and "never spawns" a subagent. Design-for-failure — timeouts,
|
|
116
|
+
retries, rollback, a tested revert path — belongs in the pipeline the human owns, not in a method tool
|
|
117
|
+
that has no business holding deploy credentials. The tag is the human-gated trigger; the record is the
|
|
118
|
+
engine's receipt that the floor was met.
|
|
119
|
+
|
|
120
|
+
> **A caveat worth one paragraph.** `add.py release` writes `CHANGELOG.md` at the **project root**. That
|
|
121
|
+
> is the right default for most repositories. But a repo with a different changelog convention — for
|
|
122
|
+
> instance a **nested-package** layout whose root `CHANGELOG.md` is a deliberate pointer to a package's
|
|
123
|
+
> own changelog — will get release blocks *prepended above* its existing content rather than replacing
|
|
124
|
+
> it (the writer preserves what is there; it does not clobber). Reconcile per repo: either let the root
|
|
125
|
+
> file carry the canonical log, or point the human's publish step at the package changelog the team
|
|
126
|
+
> treats as the source of truth.
|
|
127
|
+
|
|
128
|
+
## 16.6 · Watch and the hotfix path — re-entering observe
|
|
129
|
+
|
|
130
|
+
A release is not the finish line; it is where the most reliable information finally appears. The §2
|
|
131
|
+
scenarios that were pass/fail cases at build time become **live monitors** for the released version, and
|
|
132
|
+
error-budget burn feeds the next loop. Live-registry and deploy confirmation are post-cut *evidence*,
|
|
133
|
+
gathered after the tag — not unit tests pretending to be one.
|
|
134
|
+
|
|
135
|
+
The unhappy path is first-class. A regression found in the wild re-enters at Specify as a **change
|
|
136
|
+
request**, which narrows to a **hotfix release** — the same seven-step flow, scoped to the fix, cut as a
|
|
137
|
+
PATCH. Releasing does not have a separate emergency mode; it has the ordinary flow at a tighter scope.
|
|
138
|
+
|
|
139
|
+
Depth follows the stage, as everywhere in the method:
|
|
140
|
+
|
|
141
|
+
- **prototype / poc** — a one-line preview note and a tag; no deploy ceremony. The point is feedback.
|
|
142
|
+
- **mvp** — full notes, a tag, a guarded publish; watch the headline scenarios.
|
|
143
|
+
- **production** — every step at full rigor: notes, tag, a deploy behind a rollback-tested pipeline,
|
|
144
|
+
live scenario monitors, and error-budget watch. The hotfix path is a routine capability here, not a
|
|
145
|
+
fire drill.
|
|
146
|
+
|
|
147
|
+
## 16.7 · The flow, in one arc
|
|
148
|
+
|
|
149
|
+
One arc, seven steps:
|
|
150
|
+
|
|
151
|
+
**cue → gather → draft notes → readiness floor → human confirms → cut → watch**
|
|
152
|
+
|
|
153
|
+
1. **cue** — `add.py status` prints `→ releasable: N` when an archived milestone is unreleased.
|
|
154
|
+
2. **gather** — `add.py release-report` clusters the five record-sets (gather, never judge).
|
|
155
|
+
3. **draft notes** — a Keep-a-Changelog entry drawn from the consolidated deltas; propose the version.
|
|
156
|
+
4. **readiness floor** — the four guarded rejects; the security stop is un-forceable.
|
|
157
|
+
5. **human confirms** — the notes, version, and waivers are shown, then approved once.
|
|
158
|
+
6. **cut** — `add.py release <version>` records the CHANGELOG block + the `RELEASES.md` row + attribution.
|
|
159
|
+
7. **watch** — the scenarios become monitors; a wild regression becomes a PATCH hotfix release.
|
|
160
|
+
|
|
161
|
+
The recipe for each step — flags, the report's `--json` shape, the exact ledger row — lives in the
|
|
162
|
+
`release.md` skill guide. This chapter is the reasoning; the guide is the procedure.
|
|
163
|
+
|
|
164
|
+
## 16.8 · Worked example — this method's own 1.5.0
|
|
165
|
+
|
|
166
|
+
The repository already runs this by hand, which is the best evidence the flow is real. The
|
|
167
|
+
`udd-design-loop` milestone closed (4/4) and consolidated into `foundation-version 33`. From those
|
|
168
|
+
deltas the human drafted the `## [1.5.0]` changelog entry, bumped the three version sources in
|
|
169
|
+
lockstep, and a forward-pinned `test_release_1_5_0.py` asserted in-repo readiness: the versions agree,
|
|
170
|
+
the changelog lineage survives, the feature anchors are named, and the engine is untouched by the
|
|
171
|
+
release. The cut itself — the `git tag` that triggers the npm and PyPI publish — stayed human-gated,
|
|
172
|
+
and the live-registry confirmation was gathered *after* the tag as verify evidence, never as a unit
|
|
173
|
+
test.
|
|
174
|
+
|
|
175
|
+
That ritual is what this chapter formalizes. `release-report` gathers the inventory, the floor enforces
|
|
176
|
+
the security stop and the disclosures, `add.py release` records the cut, and the human still owns the
|
|
177
|
+
tag. The method releases itself the way it asks every project to release: gather the evidence, disclose
|
|
178
|
+
the risk, record the marker, and let a person make the outward call.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
[← 15 Foundations & Lineage](./15-foundations-and-lineage.md) · [Contents](./README.md) · Next: [Appendix A Templates →](./appendix-a-templates.md)
|
package/docs/README.md
CHANGED
|
@@ -54,6 +54,9 @@ For every feature, before AI writes any code, you write four short artifacts in
|
|
|
54
54
|
**Lineage**
|
|
55
55
|
- [15 · Foundations & Lineage](./15-foundations-and-lineage.md)
|
|
56
56
|
|
|
57
|
+
**Releasing**
|
|
58
|
+
- [16 · Releasing](./16-releasing.md)
|
|
59
|
+
|
|
57
60
|
**Part IV — Reference**
|
|
58
61
|
- [Appendix A · Templates](./appendix-a-templates.md)
|
|
59
62
|
- [Appendix B · Prompt library](./appendix-b-prompts.md)
|
|
@@ -72,10 +72,20 @@
|
|
|
72
72
|
|
|
73
73
|
**Baseline approval** (formerly "the lock-down") — the single human gate ending autonomous setup: an explicit yes that freezes the foundation, first scope, and first contract together; runs as `add.py lock --by <name>`.
|
|
74
74
|
|
|
75
|
-
**Scope level** (formerly "altitude") — the granularity a decision lives at: intake level (request → versioned scope) · milestone level · setup/foundation level · task level. (A cross-stage decision lives one level out, at the **stage-graduation** loop — which `graduate.md` also numbers as a scope level; see **Stage graduation**.) One ⚠-assumption notation is shared across every scope level.
|
|
75
|
+
**Scope level** (formerly "altitude") — the granularity a decision lives at: intake level (request → versioned scope) · milestone level · setup/foundation level · task level · release level (≥1 closed milestone → a versioned, watched cut; see **Release scope level**). (A cross-stage decision lives one level out, at the **stage-graduation** loop — which `graduate.md` also numbers as a scope level; see **Stage graduation**.) One ⚠-assumption notation is shared across every scope level.
|
|
76
76
|
|
|
77
77
|
**Autonomy level** (formerly "autonomy dial") — the explicit per-task setting (`autonomy: manual | conservative | auto`, an ordered ladder manual < conservative < auto) choosing who resolves Verify: `auto` auto-PASSes on complete evidence, `conservative` keeps a human at the gate, `manual` is the strict floor (the human owns the gate; nothing auto-resolves). A high-risk scope refuses an unguarded `auto` — it must be lowered to `manual` or `conservative`. New tasks seed a visible, overridable `autonomy: auto`; a live task with no level warns (`implicit_autonomy`), a token outside the set is rejected (`unknown_autonomy_level`).
|
|
78
78
|
|
|
79
|
+
**Release** — a versioned, user-facing cut that bundles one or more closed milestones into something real users can run; its notes are evidence-backed, its risk is disclosed, and its behaviour is then watched. Recorded with `add.py release <version>`, which writes the changelog block and the ledger row but never tags, publishes, or deploys — the outward act stays human-owned. See [16 · Releasing](./16-releasing.md).
|
|
80
|
+
|
|
81
|
+
**Release scope level** — the fifth scope level: releasing as its own granularity, orthogonal to the stage. A release bundles ≥1 closed milestone (never forced one-per-milestone) and may be cut at any stage — prototype preview, mvp beta, production GA. Distinct from milestone-close (feature-complete + consolidated) and from stage graduation (which changes rigor, not version). See **Scope level** and [16 · Releasing](./16-releasing.md).
|
|
82
|
+
|
|
83
|
+
**Readiness floor** — the engine-enforced pre-cut gate `add.py release` applies before it records anything: a green suite, zero open security `HARD-STOP`, a closed-and-unreleased milestone to bundle, and every riding `RISK-ACCEPTED` waiver disclosed in the notes. Its four rejects are `release_security_open` (un-forceable) · `release_tests_red` · `release_no_closed_milestone` · `release_undisclosed_waiver`; `--force` may override every reject except the security stop.
|
|
84
|
+
|
|
85
|
+
**RELEASES.md ledger** — the append-only, newest-first trail of release rows at the project root (date · version · milestones · waivers shipped · evidence). Like the §Key Decisions log it is never rewritten; a superseded or yanked version is recorded as a new row. The ledger is the attribution source — a milestone is "released" because a row says so — which is why the `→ releasable` cue never has to read a milestone file.
|
|
86
|
+
|
|
87
|
+
**Hotfix release** — a narrowed PATCH cut that re-enters at Specify as a change request when a regression is found in a released version. It runs the same seven-step release flow scoped to the fix; releasing has no separate emergency mode, only the ordinary flow at a tighter scope.
|
|
88
|
+
|
|
79
89
|
**Auto-ready goal** — a milestone goal whose every exit criterion **cites a verifier** (`(verify: <test|command|metric>)`), so the engine can self-verify the result against the goal without human judgment. It is the prerequisite by which **autonomy is earned by goal-clarity**: the **autonomy level** governs *who* resolves Verify, but a clarified, machine-checkable goal is what makes a self-verifying run meaningful. `add.py check` raises a `goal_not_auto_ready` **WARN** (never red) for the active milestone until it has an auto-ready goal (≥1 exit criterion and every one cited), and `status` surfaces it (`goal-ready: auto-ready ✓` / cited-of-total); a zero-criteria goal reads not-auto-ready and is milestone-shaping's nudge, not this warning's. The lint forces a citation *slot* per criterion — it raises the floor but **cannot prove the citation is real** (a human can write `(verify: it works)`): citation-theater is the accepted irreducible floor, and the freeze gate and autonomy behavior are unchanged by it.
|
|
80
90
|
|
|
81
91
|
**Automated quality gate** (formerly "evidence auto-gate") — the Verify resolver under `autonomy: auto`: a run may auto-PASS on complete evidence, recorded as *auto-resolved*; a security finding always escalates (`HARD-STOP`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pilotspace/add",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "ADD (AI-Driven Development) — a minimal, state-tracked Claude Code skill that drives every feature through Specify → Scenarios → Contract → Tests → Build → Verify → Observe. Ships the AIDD book as its trust layer.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"add": "bin/cli.js"
|
package/skill/add/SKILL.md
CHANGED
|
@@ -121,6 +121,13 @@ orchestration: gather `graduation-report` analytics → co-specify interview →
|
|
|
121
121
|
milestone → human confirm → then (and only then) `stage production`. The flip is guarded
|
|
122
122
|
(`stage_no_roadmap`) and is the FINAL step — never a bare label change.
|
|
123
123
|
|
|
124
|
+
When one or more milestones have closed since the last release, `add.py status` prints
|
|
125
|
+
**`→ releasable: N milestone(s) closed since last release`** — read `release.md` for the 5th scope
|
|
126
|
+
level: gather `release-report` → draft notes from the consolidated deltas → meet the readiness floor
|
|
127
|
+
(security HARD-STOP is un-forceable) → human confirms → `add.py release <version>` records the cut
|
|
128
|
+
(CHANGELOG + `RELEASES.md` ledger + milestone attribution) → watch. The engine records; the human runs
|
|
129
|
+
the tag / publish / deploy. A release bundles ≥1 milestone and is orthogonal to stage.
|
|
130
|
+
|
|
124
131
|
## Non-negotiable rules (from the method)
|
|
125
132
|
|
|
126
133
|
<constraints>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Release — cut a versioned ship, never an unwatched flip
|
|
2
|
+
|
|
3
|
+
A project does not "release" because someone bumped a number. It releases when one or more
|
|
4
|
+
**closed milestones** are bundled into a versioned, user-facing cut whose notes are evidence-backed,
|
|
5
|
+
whose risk is disclosed, and whose behaviour is then watched. This guide is the **5th scope level** —
|
|
6
|
+
after setup (`phases/0-setup.md`), intake (`intake.md` / `scope.md`), the milestone loop (`loop.md`),
|
|
7
|
+
and stage graduation (`graduate.md`). It is a different verb from each of them:
|
|
8
|
+
|
|
9
|
+
- a **milestone** is *feature-complete and consolidated*; a **release** is *shipped + watched*.
|
|
10
|
+
- **graduation** changes the project's *rigor* (mvp→production); a **release** ships a *version*.
|
|
11
|
+
The axes are orthogonal — you cut releases at every stage (prototype preview · mvp beta · prod GA).
|
|
12
|
+
|
|
13
|
+
So a release is its own scope level: it bundles **≥1** closed milestone, and it may bundle several.
|
|
14
|
+
You (the AI) **gather and propose**; the **human confirms and judges**; the engine records the cut
|
|
15
|
+
and enforces a floor — it **never tags, publishes, or deploys** (the same stance as "the engine never
|
|
16
|
+
renders" in `design.md` and "never spawns" in `advisor.md`). The outward act is the human's.
|
|
17
|
+
|
|
18
|
+
## The cue (what starts this)
|
|
19
|
+
|
|
20
|
+
When ≥1 milestone is `done` AND archived AND not yet attributed to a release, `add.py status` prints:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
→ releasable: N milestone(s) closed since last release
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
That line is the trigger. It is a **tally** over unreleased-but-archived milestones — never a
|
|
27
|
+
readiness judgment. It is silent until the first such milestone exists (a project that has never
|
|
28
|
+
released, or has released everything, sees nothing here — grandfathered, zero change).
|
|
29
|
+
|
|
30
|
+
## The flow
|
|
31
|
+
|
|
32
|
+
One arc, seven steps: **cue → gather → draft notes → readiness floor → human confirms → cut → watch.**
|
|
33
|
+
|
|
34
|
+
1. **Gather the release inventory** — run `add.py release-report` (add `--json` to branch on it). It
|
|
35
|
+
clusters the cut's evidence into labeled record-sets: the closed milestones since the last release ·
|
|
36
|
+
their **consolidated deltas** (the "what changed" record) · open RISK-ACCEPTED waivers riding into
|
|
37
|
+
the release · any open **security HARD-STOP** (a blocker) · the §2 scenarios to take live as monitors.
|
|
38
|
+
It **gathers, never judges** — there is no readiness verdict to read; the records are what you reason from.
|
|
39
|
+
2. **Draft the release notes** — write a [Keep a Changelog](https://keepachangelog.com/) entry **from the
|
|
40
|
+
consolidated deltas + each milestone's goal** (reuse — those consolidated deltas ARE the changelog source;
|
|
41
|
+
you are not inventing the story, you are surfacing what the foundation already recorded). Group Added / Changed /
|
|
42
|
+
Fixed; name the headline capabilities concretely. Propose the **semver bump** — breaking→MAJOR,
|
|
43
|
+
feature→MINOR, fix-only→PATCH — and let the human confirm it (the version is a decision, not a default).
|
|
44
|
+
3. **Meet the readiness floor** — before the cut the engine enforces a floor (see below): the suite is
|
|
45
|
+
green, **zero** open security HARD-STOP, and every RISK-ACCEPTED waiver shipping in this release is
|
|
46
|
+
signed AND disclosed in the notes. A security finding is a HARD-STOP here exactly as in verify —
|
|
47
|
+
never auto-passed, never shipped silent.
|
|
48
|
+
4. **Human confirms the cut** — present via `report-template.md`, opening with the ARC (goal · done · plan):
|
|
49
|
+
the version you are shipping, the milestones + evidence that earn it, and the rollout + watch plan that
|
|
50
|
+
follows. Show the drafted notes, the version, and the waivers being shipped. The human approves once
|
|
51
|
+
(the decision point) — never pre-stamped; you surface a summary to decide on, not the artifact itself.
|
|
52
|
+
5. **Cut — record the marker** — only now run `add.py release <version> --notes <file>`. The engine
|
|
53
|
+
**records**: it prepends the CHANGELOG entry, stamps one append-only row (newest-first) in
|
|
54
|
+
`RELEASES.md` (date · version · milestones · waivers shipped · evidence), and attributes the bundled
|
|
55
|
+
milestones to this version (so the cue stops firing for them).
|
|
56
|
+
6. **Ship — the human's outward act** — the engine has recorded the cut; the **human runs the tag /
|
|
57
|
+
publish / deploy** (`git tag`, `npm publish`, the deploy pipeline — tool-agnostic, whatever this project
|
|
58
|
+
ships through). The tag is the human-gated trigger, exactly the dogfooded recipe today. The engine never
|
|
59
|
+
performs it: design-for-failure lives in the pipeline the human owns, not in the method tool.
|
|
60
|
+
7. **Watch — re-enter observe at the release scope level** — the §2 scenarios become live monitors for the
|
|
61
|
+
*released* version; live-registry / deploy confirmation is post-cut **evidence**, not a unit test. A
|
|
62
|
+
regression found in the wild re-enters at Specify as a **change request** → a narrowed **PATCH hotfix
|
|
63
|
+
release** (this same flow, scoped to the fix). Release is not the finish line — it is where the most
|
|
64
|
+
reliable information appears (`phases/7-observe.md`).
|
|
65
|
+
|
|
66
|
+
## The floor (what the engine enforces)
|
|
67
|
+
|
|
68
|
+
`add.py release <version>` is **guarded** — it refuses (non-zero exit, state byte-unchanged) on:
|
|
69
|
+
|
|
70
|
+
<reject_codes>
|
|
71
|
+
- `release_security_open` — an open security HARD-STOP exists. The non-negotiable; a security finding is
|
|
72
|
+
never shipped. Resolve it (a change request back to Specify) before the cut. `--force` does NOT override this.
|
|
73
|
+
- `release_tests_red` — the suite is not green. Evidence, not a plausible diff, is what a release ships on.
|
|
74
|
+
- `release_no_closed_milestone` — nothing new since the last release. The cut is a no-op; do **not** bump.
|
|
75
|
+
- `release_undisclosed_waiver` — a RISK-ACCEPTED waiver rides into the release but is absent from the notes.
|
|
76
|
+
Disclosure is the floor: a shipped risk the user can't read about is a hidden risk. Add it to the notes.
|
|
77
|
+
</reject_codes>
|
|
78
|
+
|
|
79
|
+
`--force` preserves human authority for grandfathered / edge cases (e.g. a first release of a brownfield
|
|
80
|
+
adopt), mirroring `stage --force` — but it never overrides `release_security_open`. Use it deliberately,
|
|
81
|
+
not as the normal path.
|
|
82
|
+
|
|
83
|
+
## Invariants (never break these)
|
|
84
|
+
|
|
85
|
+
- **The engine records; the human ships.** `add.py release` writes the CHANGELOG + ledger + attribution;
|
|
86
|
+
it never tags, publishes, or deploys. The outward act stays human-owned and tool-agnostic.
|
|
87
|
+
- **Security is a HARD-STOP at the cut**, not just at verify. No `--force`, no waiver, no exception.
|
|
88
|
+
- **Notes draw from consolidated deltas** — release after `fold.md` has run, so the changelog surfaces
|
|
89
|
+
consolidated learnings, not raw open lessons. The lifecycle order is one line:
|
|
90
|
+
`milestone-done → fold → compact → archive → (repeat ≥1×) → release → watch`.
|
|
91
|
+
- **The ledger is append-only (newest-first)** — like §Key Decisions, a release row is never rewritten;
|
|
92
|
+
a superseded or yanked version is recorded with a new row, never edited away.
|
|
93
|
+
- **A release bundles, it does not equal.** One version may attribute several milestones; never force a
|
|
94
|
+
release per milestone.
|
|
95
|
+
|
|
96
|
+
## Depth and reuse
|
|
97
|
+
|
|
98
|
+
The shape is constant; the depth follows the stage (read it from `add.py status`):
|
|
99
|
+
|
|
100
|
+
- **prototype / poc** — a one-line preview note + a tag; no deploy ceremony. The point is feedback, not GA.
|
|
101
|
+
- **mvp** — full notes + tag + a guarded publish; watch the headline scenarios.
|
|
102
|
+
- **production** — every step at full rigor: notes + tag + deploy behind a rollback-tested pipeline +
|
|
103
|
+
live scenario monitors + error-budget watch. The hotfix path (step 7) is first-class here.
|
|
104
|
+
|
|
105
|
+
## Worked example (this method's own 1.5.0)
|
|
106
|
+
|
|
107
|
+
The repo already runs this by hand. The `udd-design-loop` milestone closed (4/4) and consolidated into
|
|
108
|
+
`foundation-version 33`; the human then drafted the `## [1.5.0]` CHANGELOG entry from those deltas,
|
|
109
|
+
bumped the three version sources in lockstep, and the forward-pinned `test_release_1_5_0.py` asserted
|
|
110
|
+
in-repo readiness (versions agree · changelog lineage survives · feature anchors named · engine
|
|
111
|
+
untouched). The cut itself — the `git tag` that triggers the npm/PyPI publish — stayed human-gated, and
|
|
112
|
+
the live-registry confirmation was gathered *after* the tag as verify evidence, never a unit test. This
|
|
113
|
+
guide makes that ritual first-class: `release-report` gathers it, the floor enforces it, `add.py release`
|
|
114
|
+
records it, and the human still owns the tag.
|
package/tooling/add.py
CHANGED
|
@@ -36,6 +36,14 @@ STAGES = ("prototype", "poc", "mvp", "production")
|
|
|
36
36
|
# v22 stage-graduation: the read-only cue `status` shows when the MVP is covered.
|
|
37
37
|
# Worded as the ACTION (never a file) so it stands before graduate.md exists.
|
|
38
38
|
GRADUATION_CUE = "MVP covered → propose graduation"
|
|
39
|
+
# release-altitude: the read-only cue `status` shows when ≥1 closed milestone is
|
|
40
|
+
# unreleased. The 5th scope level (release.md). `{n}` is filled at print time; the
|
|
41
|
+
# wording matches SKILL.md's "Beyond the bundle" cross-ref byte-for-byte.
|
|
42
|
+
RELEASABLE_CUE = "releasable: {n} milestone(s) closed since last release"
|
|
43
|
+
# the append-only release ledger lives at the PROJECT ROOT (the dir containing .add/),
|
|
44
|
+
# a sibling of CHANGELOG.md — NOT inside .add/. The ledger IS the attribution source:
|
|
45
|
+
# a milestone is "released" iff its slug appears on a `milestones:` row.
|
|
46
|
+
RELEASES_FILE = "RELEASES.md"
|
|
39
47
|
PHASES = ("ground", "specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done")
|
|
40
48
|
GATES = ("none", "PASS", "RISK-ACCEPTED", "HARD-STOP")
|
|
41
49
|
# heal-then-escalate (verify-integrity): the bounded self-heal loop cap. A CONFIRMED cheat
|
|
@@ -1055,6 +1063,14 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1055
1063
|
print(f"archived: {n} milestone{'s' if n != 1 else ''} "
|
|
1056
1064
|
f"({m_tasks} task{'s' if m_tasks != 1 else ''})")
|
|
1057
1065
|
|
|
1066
|
+
# release cue (release-altitude): project-global + read-only. Fires when ≥1 CLOSED
|
|
1067
|
+
# milestone (live-done OR archived) is not yet attributed to a RELEASES.md row — so it
|
|
1068
|
+
# stands even with no live milestones. Additive: a line solely when releasable; the
|
|
1069
|
+
# ledger read is fail-open (a vanished ledger never silences the cue). See release.md.
|
|
1070
|
+
_rel = _releasable(root, state)
|
|
1071
|
+
if _rel:
|
|
1072
|
+
print(f" → {RELEASABLE_CUE.format(n=len(_rel))}")
|
|
1073
|
+
|
|
1058
1074
|
print(f"active : {active or '(none)'}")
|
|
1059
1075
|
# surface the active task's autonomy level (task explicit-autonomy-dial) so the human
|
|
1060
1076
|
# reads the throttle every session; "unset" when no explicit `autonomy:` line is present.
|
|
@@ -4113,6 +4129,296 @@ def cmd_graduation_report(args: argparse.Namespace) -> None:
|
|
|
4113
4129
|
print("\n".join(L))
|
|
4114
4130
|
|
|
4115
4131
|
|
|
4132
|
+
def _releases_path(root: Path) -> Path:
|
|
4133
|
+
"""The append-only release ledger — at the PROJECT ROOT (root IS the .add dir, so its
|
|
4134
|
+
parent), a sibling of CHANGELOG.md. NOT inside .add/."""
|
|
4135
|
+
return root.parent / RELEASES_FILE
|
|
4136
|
+
|
|
4137
|
+
|
|
4138
|
+
def _released_milestones(root: Path) -> set[str]:
|
|
4139
|
+
"""Slugs already attributed to a release — the union of every `milestones:` row in
|
|
4140
|
+
RELEASES.md. Fail-OPEN: a missing/unreadable/malformed ledger yields the empty set, so
|
|
4141
|
+
every closed milestone reads as still-releasable (a vanished ledger never hides work).
|
|
4142
|
+
READ-ONLY."""
|
|
4143
|
+
try:
|
|
4144
|
+
text = _releases_path(root).read_text(encoding="utf-8")
|
|
4145
|
+
except OSError:
|
|
4146
|
+
return set() # no ledger (or a dir at the path) -> nothing released yet
|
|
4147
|
+
out: set[str] = set()
|
|
4148
|
+
for line in text.splitlines():
|
|
4149
|
+
st = line.strip()
|
|
4150
|
+
if st.lower().startswith("milestones:"):
|
|
4151
|
+
for tok in re.split(r"[,\s]+", st.split(":", 1)[1]):
|
|
4152
|
+
tok = tok.strip()
|
|
4153
|
+
if tok and tok.lower() != "none":
|
|
4154
|
+
out.add(tok)
|
|
4155
|
+
return out
|
|
4156
|
+
|
|
4157
|
+
|
|
4158
|
+
def _closed_milestones(state: dict) -> list[dict]:
|
|
4159
|
+
"""Every CLOSED milestone (its milestone-done gate passed): LIVE done milestones
|
|
4160
|
+
(status == 'done', still in state) + ARCHIVED milestones (all were PASS-done before
|
|
4161
|
+
archive — see _archived_task_slugs). Each: {slug, title, tier}."""
|
|
4162
|
+
out: list[dict] = []
|
|
4163
|
+
for slug, m in (state.get("milestones") or {}).items():
|
|
4164
|
+
if m.get("status") == "done":
|
|
4165
|
+
out.append({"slug": slug, "title": m.get("title", slug), "tier": "live"})
|
|
4166
|
+
for rec in state.get("archived") or []:
|
|
4167
|
+
if rec.get("slug"):
|
|
4168
|
+
out.append({"slug": rec["slug"], "title": rec.get("title", rec["slug"]),
|
|
4169
|
+
"tier": "archived"})
|
|
4170
|
+
return out
|
|
4171
|
+
|
|
4172
|
+
|
|
4173
|
+
def _releasable(root: Path, state: dict) -> list[dict]:
|
|
4174
|
+
"""Closed milestones NOT yet attributed to any RELEASES.md row — the cut's candidate
|
|
4175
|
+
bundle. Drives BOTH the `→ releasable: N` status cue and release-report. READ-ONLY."""
|
|
4176
|
+
released = _released_milestones(root)
|
|
4177
|
+
return [m for m in _closed_milestones(state) if m["slug"] not in released]
|
|
4178
|
+
|
|
4179
|
+
|
|
4180
|
+
def _key_decisions_for(root: Path, slug: str) -> list[str]:
|
|
4181
|
+
"""Best-effort §Key-Decisions rows from PROJECT.md that NAME this milestone slug — the
|
|
4182
|
+
consolidated decisions the changelog can cite. Fail-open: a missing section / unreadable
|
|
4183
|
+
foundation / no slug match -> [] (a gather never raises). READ-ONLY."""
|
|
4184
|
+
try:
|
|
4185
|
+
text = (root / "PROJECT.md").read_text(encoding="utf-8")
|
|
4186
|
+
except OSError:
|
|
4187
|
+
return []
|
|
4188
|
+
m = re.search(r"^#{1,6}[^\n]*key decision[^\n]*$(.*?)(?=^#{1,6}\s|\Z)", text, re.S | re.M | re.I)
|
|
4189
|
+
if not m:
|
|
4190
|
+
return []
|
|
4191
|
+
return [st.lstrip("-* ").strip() for st in (ln.strip() for ln in m.group(1).splitlines())
|
|
4192
|
+
if st.startswith(("-", "*")) and slug in st]
|
|
4193
|
+
|
|
4194
|
+
|
|
4195
|
+
def release_data(root: Path, state: dict) -> dict:
|
|
4196
|
+
"""The single source of FACTS for a release cut — PURE, NO writes (mirrors graduation_data).
|
|
4197
|
+
Both the `release-report` text dashboard and `--json` render from this one dict, so the human
|
|
4198
|
+
view and the machine view can never disagree.
|
|
4199
|
+
|
|
4200
|
+
GATHER, never JUDGE: every value is a RECORD the human verifies by looking; there is no
|
|
4201
|
+
readiness/score/ranking field by construction. Five record-sets feed the release.md flow:
|
|
4202
|
+
releasable — closed-but-unreleased milestones (the bundle candidate; the cue's count)
|
|
4203
|
+
changed — per releasable milestone: RETRO path + carried-delta count + §Key-Decisions rows
|
|
4204
|
+
waivers — open RISK-ACCEPTED riding into the cut (soonest expiry first)
|
|
4205
|
+
blockers — open HARD-STOP gate records (the security stop the floor will refuse on)
|
|
4206
|
+
monitors — declared §7 Watch lines to carry into the post-cut watch step
|
|
4207
|
+
A source is read fail-closed (skip on error); the ledger is read fail-OPEN (see _releasable)."""
|
|
4208
|
+
tasks = state.get("tasks") or {}
|
|
4209
|
+
releasable = _releasable(root, state)
|
|
4210
|
+
|
|
4211
|
+
# changed — the consolidated learning trail per releasable milestone (the changelog source)
|
|
4212
|
+
changed = []
|
|
4213
|
+
for m in releasable:
|
|
4214
|
+
slug = m["slug"]
|
|
4215
|
+
retro = None
|
|
4216
|
+
for sub in ("milestones", "archive"):
|
|
4217
|
+
cand = root / sub / slug / "RETRO.md"
|
|
4218
|
+
if cand.is_file(): # a directory at the path is not a ledger (fail-closed)
|
|
4219
|
+
retro = str(cand.relative_to(root))
|
|
4220
|
+
break
|
|
4221
|
+
changed.append({"milestone": slug, "key_decisions": _key_decisions_for(root, slug),
|
|
4222
|
+
"retro": retro,
|
|
4223
|
+
"carried_deltas": _retro_carried(root / retro) if retro else 0})
|
|
4224
|
+
|
|
4225
|
+
# waivers — open RISK-ACCEPTED riding into the cut, soonest expiry first (mirrors graduation_data)
|
|
4226
|
+
waivers = []
|
|
4227
|
+
for slug, t in tasks.items():
|
|
4228
|
+
if t.get("gate") == "RISK-ACCEPTED" and t.get("waiver"):
|
|
4229
|
+
w = t["waiver"]
|
|
4230
|
+
waivers.append({"slug": slug, "owner": w.get("owner", "?"),
|
|
4231
|
+
"ticket": w.get("ticket", "?"), "expires": w.get("expires", "?")})
|
|
4232
|
+
|
|
4233
|
+
def _exp_key(wv):
|
|
4234
|
+
try:
|
|
4235
|
+
return (0, date.fromisoformat(wv["expires"]).isoformat())
|
|
4236
|
+
except (ValueError, TypeError):
|
|
4237
|
+
return (1, "") # unparseable/missing -> after every real date
|
|
4238
|
+
waivers.sort(key=_exp_key)
|
|
4239
|
+
|
|
4240
|
+
# blockers — open HARD-STOP gate records (the un-forceable security stop the floor enforces)
|
|
4241
|
+
blockers = [{"slug": s, "gate": t.get("gate")} for s, t in tasks.items()
|
|
4242
|
+
if t.get("gate") == "HARD-STOP"]
|
|
4243
|
+
|
|
4244
|
+
# monitors — declared §7 Watch lines (filled, not the `<…>` template) for the watch step
|
|
4245
|
+
monitors = []
|
|
4246
|
+
for slug in tasks:
|
|
4247
|
+
try:
|
|
4248
|
+
text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8")
|
|
4249
|
+
except OSError:
|
|
4250
|
+
continue # unreadable TASK.md -> skip this task's monitor record
|
|
4251
|
+
for line in text.splitlines():
|
|
4252
|
+
st = line.strip()
|
|
4253
|
+
if st.startswith("Watch") and "<" not in st and st != "Watch":
|
|
4254
|
+
monitors.append({"slug": slug, "watch": st})
|
|
4255
|
+
break
|
|
4256
|
+
|
|
4257
|
+
return {
|
|
4258
|
+
"releasable": releasable,
|
|
4259
|
+
"changed": changed,
|
|
4260
|
+
"waivers": waivers,
|
|
4261
|
+
"blockers": blockers,
|
|
4262
|
+
"monitors": monitors,
|
|
4263
|
+
"summary": {
|
|
4264
|
+
"releasable": len(releasable), "changed": len(changed), "waivers": len(waivers),
|
|
4265
|
+
"blockers": len(blockers), "monitors": len(monitors),
|
|
4266
|
+
},
|
|
4267
|
+
}
|
|
4268
|
+
|
|
4269
|
+
|
|
4270
|
+
def cmd_release_report(args: argparse.Namespace) -> None:
|
|
4271
|
+
"""Read-only: GATHER the release inventory into five labeled record-sets for the release.md
|
|
4272
|
+
flow. text (default) or --json (the frozen JSON facts interface). Exit 0 ALWAYS — a gather,
|
|
4273
|
+
not a gate; the ONLY non-zero exit is no_project. Judges nothing. NO writes."""
|
|
4274
|
+
root = find_root()
|
|
4275
|
+
if root is None: # frozen contract: fail-closed with a no_project signal
|
|
4276
|
+
_die("no_project: no .add/ project found. Run `add.py init` first.")
|
|
4277
|
+
state = load_state(root)
|
|
4278
|
+
d = release_data(root, state)
|
|
4279
|
+
|
|
4280
|
+
if getattr(args, "json", False):
|
|
4281
|
+
print(json.dumps(d, ensure_ascii=False, indent=2))
|
|
4282
|
+
return
|
|
4283
|
+
|
|
4284
|
+
s = d["summary"]
|
|
4285
|
+
L = ["RELEASE REPORT — release inventory (gather, not judge)", ""]
|
|
4286
|
+
L.append(f"Releasable ({s['releasable']}) — closed milestones not yet in {RELEASES_FILE}:")
|
|
4287
|
+
for m in d["releasable"]:
|
|
4288
|
+
L.append(f" - {m['slug']} [{m['tier']}]: {m['title']}")
|
|
4289
|
+
L.append("")
|
|
4290
|
+
L.append(f"Changed ({s['changed']}) — the consolidated learning trail per milestone:")
|
|
4291
|
+
for c in d["changed"]:
|
|
4292
|
+
L.append(f" - {c['milestone']}: {c['retro'] or '(no RETRO record)'} "
|
|
4293
|
+
f"({c['carried_deltas']} carried · {len(c['key_decisions'])} key decision(s))")
|
|
4294
|
+
L.append("")
|
|
4295
|
+
L.append(f"Waivers ({s['waivers']}) — open RISK-ACCEPTED riding into the cut, soonest expiry first:")
|
|
4296
|
+
for w in d["waivers"]:
|
|
4297
|
+
L.append(f" - {w['slug']}: {w['owner']} · {w['ticket']} · expires {w['expires']}")
|
|
4298
|
+
L.append("")
|
|
4299
|
+
L.append(f"Blockers ({s['blockers']}) — open HARD-STOP (the un-forceable security stop):")
|
|
4300
|
+
for b in d["blockers"]:
|
|
4301
|
+
L.append(f" - {b['slug']}: {b['gate']}")
|
|
4302
|
+
L.append("")
|
|
4303
|
+
L.append(f"Monitors ({s['monitors']}) — declared §7 Watch lines to carry into the watch step:")
|
|
4304
|
+
for mo in d["monitors"]:
|
|
4305
|
+
L.append(f" - {mo['slug']}: {mo['watch']}")
|
|
4306
|
+
print("\n".join(L))
|
|
4307
|
+
|
|
4308
|
+
|
|
4309
|
+
def _build_in_flight(state: dict) -> bool:
|
|
4310
|
+
"""release_tests_red proxy (PURE): is any ACTIVE task mid-build without a recorded green gate
|
|
4311
|
+
— phase ∈ {build, verify} AND gate == 'none'? The tool-agnostic engine never runs the suite,
|
|
4312
|
+
so an entered-but-ungated build is the recorded-evidence stand-in for 'the suite is red'."""
|
|
4313
|
+
return any(t.get("phase") in ("build", "verify") and t.get("gate") == "none"
|
|
4314
|
+
for t in (state.get("tasks") or {}).values())
|
|
4315
|
+
|
|
4316
|
+
|
|
4317
|
+
def _prepend_block(existing: str, header: str, block: str) -> str:
|
|
4318
|
+
"""Newest-first prepend: insert `block` directly under the top H1 `header`, creating the
|
|
4319
|
+
header when `existing` is empty / headerless. Existing content is preserved VERBATIM
|
|
4320
|
+
(append-only). `block` is expected to end in a blank-line separator."""
|
|
4321
|
+
if not existing.strip():
|
|
4322
|
+
return f"{header}\n\n{block}"
|
|
4323
|
+
if existing.lstrip().startswith(header):
|
|
4324
|
+
after = existing.split(header, 1)[1].lstrip("\n")
|
|
4325
|
+
return f"{header}\n\n{block}{after}"
|
|
4326
|
+
return f"{block}{existing}" # no recognized header -> block goes on top, verbatim tail
|
|
4327
|
+
|
|
4328
|
+
|
|
4329
|
+
def _render_changelog_block(version: str, day: str, bundle: list[dict],
|
|
4330
|
+
changed_by_slug: dict) -> str:
|
|
4331
|
+
"""A CHANGELOG block: `## <version> — <date>` + one bullet per bundled milestone (title +
|
|
4332
|
+
carried-delta / key-decision counts from release_data['changed'])."""
|
|
4333
|
+
lines = [f"## {version} — {day}", ""]
|
|
4334
|
+
if bundle:
|
|
4335
|
+
for m in bundle:
|
|
4336
|
+
c = changed_by_slug.get(m["slug"], {})
|
|
4337
|
+
lines.append(f"- {m['title']} — {c.get('carried_deltas', 0)} carried · "
|
|
4338
|
+
f"{len(c.get('key_decisions', []))} key decision(s)")
|
|
4339
|
+
else:
|
|
4340
|
+
lines.append("- (no milestone bundled)")
|
|
4341
|
+
return "\n".join(lines) + "\n\n"
|
|
4342
|
+
|
|
4343
|
+
|
|
4344
|
+
def _render_releases_row(version: str, day: str, bundle: list[dict],
|
|
4345
|
+
waiver_slugs: list[str], evidence: str | None) -> str:
|
|
4346
|
+
"""One append-only RELEASES.md row — the attribution source (`milestones:` membership)."""
|
|
4347
|
+
ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
|
|
4348
|
+
wv = ", ".join(waiver_slugs) if waiver_slugs else "none"
|
|
4349
|
+
return (f"## {version} — {day}\n"
|
|
4350
|
+
f"milestones: {ms}\n"
|
|
4351
|
+
f"waivers: {wv}\n"
|
|
4352
|
+
f"evidence: {evidence or 'recorded by add.py release'}\n\n")
|
|
4353
|
+
|
|
4354
|
+
|
|
4355
|
+
def cmd_release(args: argparse.Namespace) -> None:
|
|
4356
|
+
"""GUARDED, record-only: cut a version. Enforce the 4-code readiness floor, then RECORD by
|
|
4357
|
+
prepending CHANGELOG.md + an append-only RELEASES.md row (whose `milestones:` line attributes
|
|
4358
|
+
the bundle). The engine RECORDS; it NEVER tags / publishes / deploys / bumps a version source /
|
|
4359
|
+
writes state.json. Validate-before-write: a reject leaves both files + state.json byte-unchanged.
|
|
4360
|
+
A failed second write rolls back the first (release_write_failed)."""
|
|
4361
|
+
root = find_root()
|
|
4362
|
+
if root is None: # frozen contract: fail-closed with a no_project signal
|
|
4363
|
+
_die("no_project: no .add/ project found. Run `add.py init` first.")
|
|
4364
|
+
state = load_state(root)
|
|
4365
|
+
d = release_data(root, state)
|
|
4366
|
+
forced = getattr(args, "force", False)
|
|
4367
|
+
disclosed = getattr(args, "with_waivers", False)
|
|
4368
|
+
|
|
4369
|
+
# ── FLOOR — all checks BEFORE any write (validate-before-write) ──────────────────────────
|
|
4370
|
+
if d["blockers"]: # the UN-FORCEABLE reject — security is never shipped
|
|
4371
|
+
_die("release_security_open: an open HARD-STOP blocks the cut — a security finding is "
|
|
4372
|
+
"never shipped. Resolve it (a change request back to Specify) before releasing. "
|
|
4373
|
+
"--force does NOT override this.")
|
|
4374
|
+
if not forced and _build_in_flight(state):
|
|
4375
|
+
_die("release_tests_red: a build is in flight without a recorded green gate — finish and "
|
|
4376
|
+
"gate it first, or pass --force to override.")
|
|
4377
|
+
bundle = _releasable(root, state)
|
|
4378
|
+
if not forced and not bundle:
|
|
4379
|
+
_die("release_no_closed_milestone: nothing closed-and-unreleased to bundle — the cut "
|
|
4380
|
+
"would be a no-op. Close a milestone first, or pass --force to override.")
|
|
4381
|
+
if not forced and d["waivers"] and not disclosed:
|
|
4382
|
+
_die("release_undisclosed_waiver: a RISK-ACCEPTED waiver rides into this release — pass "
|
|
4383
|
+
"--with-waivers to disclose it in the notes, or --force to override.")
|
|
4384
|
+
|
|
4385
|
+
# ── RECORD — build both contents in memory, then write CHANGELOG, then RELEASES (commit) ──
|
|
4386
|
+
day = date.today().isoformat()
|
|
4387
|
+
changed_by_slug = {c["milestone"]: c for c in d["changed"]}
|
|
4388
|
+
waiver_slugs = [w["slug"] for w in d["waivers"]] if disclosed else []
|
|
4389
|
+
changelog_path = root.parent / "CHANGELOG.md"
|
|
4390
|
+
releases_path = _releases_path(root)
|
|
4391
|
+
cl_before = changelog_path.read_text(encoding="utf-8") if changelog_path.exists() else None
|
|
4392
|
+
rel_before = releases_path.read_text(encoding="utf-8") if releases_path.exists() else ""
|
|
4393
|
+
new_cl = _prepend_block(cl_before or "", "# Changelog",
|
|
4394
|
+
_render_changelog_block(args.version, day, bundle, changed_by_slug))
|
|
4395
|
+
new_rel = _prepend_block(rel_before, "# Releases",
|
|
4396
|
+
_render_releases_row(args.version, day, bundle, waiver_slugs,
|
|
4397
|
+
getattr(args, "evidence", None)))
|
|
4398
|
+
_atomic_write(changelog_path, new_cl)
|
|
4399
|
+
try:
|
|
4400
|
+
_atomic_write(releases_path, new_rel) # the attribution commit point
|
|
4401
|
+
except OSError as e:
|
|
4402
|
+
if cl_before is not None: # ROLLBACK (design-for-failure)
|
|
4403
|
+
_atomic_write(changelog_path, cl_before)
|
|
4404
|
+
else:
|
|
4405
|
+
try:
|
|
4406
|
+
changelog_path.unlink()
|
|
4407
|
+
except OSError:
|
|
4408
|
+
pass
|
|
4409
|
+
_die(f"release_write_failed: the ledger write failed ({e}); CHANGELOG was rolled back — "
|
|
4410
|
+
"nothing was recorded. Retry the release.")
|
|
4411
|
+
|
|
4412
|
+
# NO save_state — attribution lives in RELEASES.md (the cue re-reads it), never state.json
|
|
4413
|
+
ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
|
|
4414
|
+
print(f"released {args.version} — recorded {len(bundle)} milestone(s): {ms}")
|
|
4415
|
+
print(" CHANGELOG.md + RELEASES.md updated (project root). The engine records; "
|
|
4416
|
+
"you run the tag / publish / deploy.")
|
|
4417
|
+
if forced:
|
|
4418
|
+
print(" (--force: forceable floor rejects were bypassed — release_security_open is never bypassable)")
|
|
4419
|
+
print(_next_footer(root, state))
|
|
4420
|
+
|
|
4421
|
+
|
|
4116
4422
|
def cmd_deltas(args: argparse.Namespace) -> None:
|
|
4117
4423
|
"""Read-only: report all open lessons learned grouped by competency.
|
|
4118
4424
|
|
|
@@ -4425,6 +4731,26 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
4425
4731
|
pgr.add_argument("--plain", action="store_true", help="ASCII/pipe-safe text (output is plain by default)")
|
|
4426
4732
|
pgr.set_defaults(func=cmd_graduation_report)
|
|
4427
4733
|
|
|
4734
|
+
prr = sub.add_parser("release-report",
|
|
4735
|
+
help="read-only: gather the release inventory (releasable milestones · "
|
|
4736
|
+
"changed/RETROs · waivers · HARD-STOP blockers · monitors) for a "
|
|
4737
|
+
"release cut — gathers, never judges")
|
|
4738
|
+
prr.add_argument("--json", action="store_true", help="emit the frozen JSON facts interface")
|
|
4739
|
+
prr.add_argument("--plain", action="store_true", help="ASCII/pipe-safe text (output is plain by default)")
|
|
4740
|
+
prr.set_defaults(func=cmd_release_report)
|
|
4741
|
+
|
|
4742
|
+
prl = sub.add_parser("release",
|
|
4743
|
+
help="guarded, record-only: cut a version — enforce the readiness floor, "
|
|
4744
|
+
"then prepend CHANGELOG.md + an append-only RELEASES.md row (the "
|
|
4745
|
+
"engine records; you tag/publish). Security HARD-STOP is un-forceable")
|
|
4746
|
+
prl.add_argument("version", help="the version string to cut (free-form: semver / calver / any)")
|
|
4747
|
+
prl.add_argument("--force", action="store_true",
|
|
4748
|
+
help="override the forceable floor rejects (NEVER release_security_open)")
|
|
4749
|
+
prl.add_argument("--with-waivers", action="store_true", dest="with_waivers",
|
|
4750
|
+
help="disclose riding RISK-ACCEPTED waivers (records them on the ledger row)")
|
|
4751
|
+
prl.add_argument("--evidence", default=None, help="the RELEASES.md row's evidence line")
|
|
4752
|
+
prl.set_defaults(func=cmd_release)
|
|
4753
|
+
|
|
4428
4754
|
pau = sub.add_parser("audit",
|
|
4429
4755
|
help="read-only: verify recorded human decision points left well-formed records "
|
|
4430
4756
|
"(exit 1 on findings — the CI enforcement gate)")
|