create-cmp-cli 0.10.1 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cmp-cli",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Create production mobile apps (Android + iOS, one Kotlin codebase) with AI — the delivery harness for Compose Multiplatform, the current generation of cross-platform (Google-backed KMP, iOS stable since May 2025). A deterministic, non-interactive generator that scaffolds a green-building app in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Every app carries a device-free UI preview loop (real screens rendered headlessly on save; changed-screen attribution and compile-error surfacing for coding agents, a live gallery for humans) plus agent-first docs (CLAUDE.md + AGENTS.md). Installs the `create-cmp` command.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/lib/tabs.mjs CHANGED
@@ -527,6 +527,8 @@ const PLACEHOLDER_REL =
527
527
  const PREVIEW_REGISTRY_REL =
528
528
  "composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt";
529
529
  const SMOKE_REL = "qa/e2e/smoke.yaml";
530
+ const PROFILE_FEATURE_REL =
531
+ "composeApp/src/commonMain/kotlin/com/example/app/presentation/profile";
530
532
 
531
533
  /**
532
534
  * Rewrite every tab-driven surface present in the copied project dir. Runs
@@ -576,4 +578,28 @@ export function rewriteTabSurfaces(projectDir, tabs, log = () => {}) {
576
578
  fs.writeFileSync(smokePath, renderSmokeYaml(infos));
577
579
  log(` tabs → ${SMOKE_REL}`);
578
580
  }
581
+
582
+ // A shipped default feature the config did NOT ask for must not be left on
583
+ // disk: rewriteNavHost drops its import and its appTabs() entry, and
584
+ // renderPreviewRegistryKt drops its preview, so the files would sit there
585
+ // wired to nothing — dead code the reachability gate (qa/lib/reachability.mjs)
586
+ // correctly FAILs at `--verify` time. Don't ship what you don't wire.
587
+ //
588
+ // `profile` is the only one: it is a self-contained stub screen (one file, no
589
+ // ViewModel, no spec clauses, no DI entry, no tests), so removing it leaves
590
+ // nothing dangling.
591
+ //
592
+ // `home` is deliberately NEVER stripped, even when it is not a configured tab.
593
+ // It is the governed EXEMPLAR (`qa/approvals.json`'s exemplarFeature — the file
594
+ // set `qa/scaffold-feature.mjs` clones and the genesis walk approves), and it
595
+ // owns `DetailScreen`, which AppNavHost registers as a destination
596
+ // unconditionally. So it stays both governed and reachable with no tab of its
597
+ // own — removing it would break the genesis walk and dangle the Detail route.
598
+ if (!infos.some((t) => t.slug === "profile")) {
599
+ const profilePath = path.join(projectDir, PROFILE_FEATURE_REL);
600
+ if (fs.existsSync(profilePath)) {
601
+ fs.rmSync(profilePath, { recursive: true, force: true });
602
+ log(` tabs → removed ${PROFILE_FEATURE_REL} (no profile tab configured)`);
603
+ }
604
+ }
579
605
  }
@@ -19,6 +19,26 @@ description: >-
19
19
  > feature to its real shape. You are not done until `node qa/verify.mjs` PASSes and the receipt
20
20
  > is committed — see this project's `CLAUDE.md`.
21
21
 
22
+ ## Step 0 — name the lane, out loud, before anything else
23
+
24
+ Every post-genesis change enters through one of two lanes (`CLAUDE.md` §"After genesis"),
25
+ and **the human must be told which one this request is taking, in your first reply** — one
26
+ or two plain sentences before any tool runs: what you understood the change to be, which
27
+ lane, and why. Never route silently; the human can overrule the triage in a word.
28
+
29
+ - **Brief lane** — the request carries decisions a future contributor could plausibly
30
+ "simplify" away, or blast radius into other governed artifacts. Say so, e.g.: *"This
31
+ carries real decisions (day-boundary rules, scheduling semantics) — I'll draft a feature
32
+ brief at `docs/features/<name>.md` with the open decisions for you to close and sign
33
+ BEFORE I stamp anything."* Only after the brief is signed does this skill's stamping
34
+ start — and if the feature has a UI surface, the **design gate comes next, before the
35
+ spec**: draft the screens on stub data, render them, and stop for the human's signature
36
+ on `feature-design:<name>` (brief → design → spec → build). A human signs rendered
37
+ screens, never a description of screens.
38
+ - **Direct lane** — an ordinary feature with no decisions worth recording. Say so, e.g.:
39
+ *"Straightforward slice, no decisions worth a brief — direct lane: spec clauses for your
40
+ confirmation, then I stamp and prove."* Then continue with Step 1 below.
41
+
22
42
  ## The clone source is configurable
23
43
 
24
44
  The stamper clones from the project's **configured exemplar** — `qa/approvals.json`'s
@@ -24,6 +24,12 @@ to just the 5 data/domain files. Use it when you want the data layer to exist be
24
24
  consumes it (e.g. you're modeling several entities up front, or a screen for this entity will
25
25
  come later via `add-screen`).
26
26
 
27
+ **Name the lane first, out loud.** Before any tool runs, tell the human in a sentence which
28
+ lane this request takes (`CLAUDE.md` §"After genesis"): an entity carrying decisions worth
29
+ recording (data-model semantics, boundary rules) goes through a feature brief
30
+ (`docs/features/<name>.md`, signed before code); an ordinary entity is direct lane — say so,
31
+ then continue here. Never route silently.
32
+
27
33
  ## Why a stamper and not hand-written files
28
34
 
29
35
  Every hand-written file is a drift chance in this project's architecture. `qa/scaffold-feature.mjs
@@ -25,6 +25,12 @@ the 6 presentation+tests+spec files, **composed on top of an entity that already
25
25
  `add-repository` or a prior `add-feature` run). If the entity doesn't exist yet, this will refuse
26
26
  to stamp anything — see step 2.
27
27
 
28
+ **Name the lane first, out loud.** Before any tool runs, tell the human in a sentence which
29
+ lane this request takes (`CLAUDE.md` §"After genesis"): a screen carrying decisions worth
30
+ recording or blast into signed artifacts goes through a feature brief
31
+ (`docs/features/<name>.md`, signed before code); an ordinary screen is direct lane — say
32
+ so, then continue here. Never route silently.
33
+
28
34
  ## Why a stamper and not hand-written files
29
35
 
30
36
  Every hand-written file is a drift chance in this project's architecture. `qa/scaffold-feature.mjs
@@ -111,6 +111,12 @@ a provisional palette carries the build until then).
111
111
  the `cmp-new` interview; the seed's placeholder prose is marked unfilled. Its
112
112
  `## Glossary` section is lifted verbatim into `docs/ARCHITECTURE.md` §8 — write it there
113
113
  in the exact form you want published.
114
+ 0.5. **First feature brief** — `docs/features/<exemplar>.md`, drafted from the interview's
115
+ "first screens" answer the moment intent is signed: the first feature's decisions and
116
+ their why, signed BEFORE its spec is written. Genesis runs the same decide → contract →
117
+ build loop as every later change; the app's first feature gets the same decide step
118
+ every later feature gets. (Express lane: skipped honestly, never fabricated;
119
+ placeholders earn a brief when they become real.)
114
120
  1. **Architecture + structure** — `specs/app-base.spec.md` **and**
115
121
  [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) (`cmp:generated` sections stripped
116
122
  before hashing, so a mechanical regeneration never invalidates the approval — only an
@@ -131,16 +137,88 @@ a provisional palette carries the build until then).
131
137
  6. **Per-feature spec** — `specs/<feature>.spec.md`, one governed artifact per feature,
132
138
  added as features land.
133
139
 
140
+ ### After genesis — every change is the same loop
141
+
142
+ Genesis governs the app's birth. Everything after runs the SAME loop — decide →
143
+ contract → build → prove → sign — over a subset (`docs/CHANGE-FLOW-DESIGN.md` in the
144
+ create-cmp repo is the doc of record). Two lanes, one triage rule — and **the triage is
145
+ always visible**: your FIRST reply to any change request (new feature, edit, bug fix, copy
146
+ tweak, redesign — every entry point) states in one or two plain sentences what you
147
+ understood the change to be, which lane it takes, and why, before any tool runs. The human
148
+ can overrule the lane in a word; a silent route is a routing error even when the lane was
149
+ right.
150
+
151
+ **Brief lane** — when the change carries **decisions a future contributor could plausibly
152
+ "simplify" away** ("the day boundary is configurable, default 04:00 — not midnight") OR
153
+ **blast radius into other governed artifacts**. After naming the lane:
154
+
155
+ | Step | What |
156
+ |---|---|
157
+ | 1 | **Feature brief** — `docs/features/<name>.md`: the decisions with their why, research, rejected options, an **Open decisions** section until the human closes each. Signed BEFORE code. |
158
+ | 2 | **Design** — iff the feature has a UI surface (`"screens": true`, or screen files exist): draft the screens on STUB data, register them in the PreviewRegistry, render, and STOP. The human judges the rendered screens and signs `feature-design:<name>`. Never ask a human to approve a described UI. |
159
+ | 3 | **Contract** — reopen any signed spec the brief amends (`--reopen feature-spec:<surface> --reason "…"`); write the clauses where the behavior lives — about the form that now exists; the human signs |
160
+ | 4 | Build the slice (`add-feature` / preview loop). Declared blast lands "as declared"; re-approve touched visual artifacts on rendered output (wiring the signed screens from stub to real state drifts `feature-design:<name>` — its re-approval is that pass) |
161
+ | 5 | Prove — nothing to do: the lane's gates + receipt ARE the proof |
162
+ | 6 | The human's `--accept` — enabled only at provenDone AND a signed design |
163
+
164
+ **Direct lane** — everything else (bug fix, copy edit, tweak): confirm in chat, reopen →
165
+ amend clause → re-approve if a signed contract is touched, build, lane once at done.
166
+ Legacy features never get retro-briefs; spikes are ungoverned until they become real.
167
+
168
+ **A brief's LOCATION is the governance opt-in**: every `docs/features/*.md` is a governed
169
+ `feature-brief:<name>` artifact (hash-bound at signing; `<name>` pairs with
170
+ `specs/<name>.spec.md`). `docs/proposals/` stays ungoverned. The brief carries at most one
171
+ machine-read block, and it **declares — it never gates**:
172
+
173
+ ```json cmp:feature
174
+ { "touches": ["components", "design-system"], "screens": true }
175
+ ```
176
+
177
+ `touches` is the declared blast radius — the artifact hashes already enforce; declaring
178
+ lets the console show "components re-approval, as planned" instead of an unexplained
179
+ failure, and surface **undeclared blast** when something drifted that no open brief
180
+ accounted for. `screens: true` declares a UI surface: it holds the design gate
181
+ (`feature-design:<name>` — the feature's own `presentation/<name>/*Screen.kt`, signed on
182
+ rendered output) before any screen file exists; once files exist, disk is ground truth
183
+ regardless. Both declare — neither gates.
184
+
185
+ **Doneness is DERIVED, never claimed.** There is no `--deliver` and no checks block —
186
+ deliberately (they existed and were removed as a weaker parallel truth). A feature is
187
+ `provenDone` when, mechanically: its spec has live clauses **and** every one is cited by a
188
+ test **and** the latest receipt is PASS **and** the receipt's `inputs.hash` attests the
189
+ tree as it stands. `--status` and the console print the same one-line `doneReason` either
190
+ way. What remains for humans is judgment: signing the brief (before code) and accepting
191
+ the feature (after proof — "the proven thing is what I wanted"); `--accept` is refused
192
+ until the derivation holds. Acceptance lives on the ledger row, never in the doc (the
193
+ signed bytes must not move when the human accepts).
194
+
195
+ | Command | What |
196
+ |---|---|
197
+ | `node qa/approve.mjs feature-brief:<name>` | the human signs the brief (before code) |
198
+ | `node qa/approve.mjs --accept <name>` | the human's bookend; refused until provenDone |
199
+
200
+ Editing a feature is the same brief **reopened** — `--reopen-feature <name> --reason "…"`
201
+ reopens the brief + its spec + its design + the set it declared, as ONE recorded change;
202
+ re-approval is one walk back. Every reopen **requires a `--reason`** (it walks back a
203
+ signature; the signer reads why from the ledger itself) and is journaled with `via` — the
204
+ append-only journal (`qa/approvals.log.jsonl`, printed by `--log`, shown in the console
205
+ strip's History) is how "what happened while I was away" stays answerable.
206
+
134
207
  | Command | What |
135
208
  |---|---|
136
209
  | `node qa/approve.mjs --status` | Every governed artifact with live state (`unreviewed` / `approved` / `changed-since-approval` / `reopened`), short hash, mode badge |
137
210
  | `node qa/approve.mjs <artifact>` | Record approval — hashes the artifact's files now, stamps the time; also clears a `defaults-accepted` mode |
138
211
  | `node qa/approve.mjs --accept-defaults` | **Express lane**: approve every currently-resolvable artifact in one visible act, each stamped `"mode": "defaults-accepted"` — build now, walk the definition later. Unresolvable artifacts are skipped with the standard refusal printed. The ledger never pretends the defaults were designed. |
139
- | `node qa/approve.mjs --reopen <artifact>` | Move an *approved* artifact (shaped or defaults-accepted) back to `reopened` for deliberate redesign recorded (`reopenedAt`). Refuses unknown ids and anything not currently approved. |
212
+ | `node qa/approve.mjs --reopen <artifact> --reason "…"` | Move an *approved* artifact (shaped or defaults-accepted) back to `reopened` for deliberate redesign. `--reason` is REQUIRED and recorded (`reopenedAt`, `via`, `reason` — on the row and in the journal). Refuses unknown ids and anything not currently approved. |
213
+ | `node qa/approve.mjs --reopen-feature <name> --reason "…"` | ONE recorded change: reopens the brief + `feature-spec:<name>` + `feature-design:<name>` + every declared `touches` artifact (each only if currently approved), all under one reason. |
214
+ | `node qa/approve.mjs --log` | The governance journal — every approve/reopen/accept with when, which surface (`via`), and why. |
140
215
 
141
216
  With the create-cmp plugin, the same decisions can be made from the preview console
142
217
  (`preview {projectDir}`'s URL) — it calls the same library, so the CLI and the console
143
- never disagree. An agent blocks on a pending decision with
218
+ never disagree. The console's rail is the human's work queue (colour = something waits
219
+ on them), and a drifted artifact shows, in its own section, exactly **what changed
220
+ against the signed bytes and which files are still exactly as signed**, with re-approval
221
+ in place. An agent blocks on a pending decision with
144
222
  `approval_status {waitForDecision:true}`.
145
223
 
146
224
  The verify lane's `approvals` gate (a step like any other, in every profile) resolves each
@@ -267,4 +345,5 @@ conventions) · [`CONTRIBUTING.md`](./CONTRIBUTING.md) (workflow, Conventional C
267
345
  | `./gradlew :composeApp:desktopTest` | Unit tests only (fast inner loop) |
268
346
  | `node qa/setup-hooks.mjs` | Enable the pre-push receipt gate (one-time, after `git init`) |
269
347
  | `./gradlew :composeApp:assembleDebug` | Android debug build |
348
+ | `./gradlew :composeApp:assembleRelease` | Android release build — R8 + `lintVital`, the variant the lane's `releaseBuild` step proves. Produces an **unsigned** APK; signing needs a keystore, which is yours to create and keep out of the repo. |
270
349
  | `./gradlew :composeApp:hotRunDesktop --auto` | Desktop dev-client with hot reload |
@@ -202,7 +202,19 @@ android {
202
202
  getByName("release") {
203
203
  isMinifyEnabled = true
204
204
  isShrinkResources = true
205
+ // Every field debug declares, release must declare too. BuildConfig is generated
206
+ // PER BUILD TYPE, so a field only debug carries simply does not exist in release —
207
+ // and `if (!USE_FIREBASE_EMULATORS) return` is a RUNTIME guard that does nothing
208
+ // for a compile-time symbol. Declaring the flag alone made release the one build
209
+ // nobody could produce: the code reading the host and ports failed to resolve them.
210
+ // The values below are never used (the flag is false); they exist so the shape of
211
+ // BuildConfig is the same in both build types.
205
212
  buildConfigField("boolean", "USE_FIREBASE_EMULATORS", "false")
213
+ buildConfigField("String", "FIREBASE_EMULATOR_HOST", "\"\"")
214
+ buildConfigField("int", "FIREBASE_AUTH_PORT", "0")
215
+ buildConfigField("int", "FIREBASE_FIRESTORE_PORT", "0")
216
+ buildConfigField("int", "FIREBASE_FUNCTIONS_PORT", "0")
217
+ buildConfigField("int", "FIREBASE_STORAGE_PORT", "0")
206
218
  manifestPlaceholders["usesCleartextTraffic"] = "false"
207
219
  proguardFiles(
208
220
  getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -226,6 +238,19 @@ android {
226
238
  // <<< cmp:feature !firebase
227
239
  }
228
240
 
241
+ lint {
242
+ // ONE disabled check, not the gate. `lintVitalRelease` runs on every release build and
243
+ // it is worth keeping; what is not worth keeping is a detector that CRASHES on it.
244
+ // NullSafeMutableLiveData's detector throws IncompatibleClassChangeError against this
245
+ // Kotlin version (AGP's bug, not ours) and takes the whole release build with it.
246
+ //
247
+ // Disabling it costs nothing beyond the crash: an app on this template has no LiveData —
248
+ // state is Compose + Flow throughout — so the check has nothing to inspect. The blunt
249
+ // alternatives, `checkReleaseBuilds = false` or `abortOnError = false`, would switch off
250
+ // every real lint finding to dodge one broken one.
251
+ disable += "NullSafeMutableLiveData"
252
+ }
253
+
229
254
  compileOptions {
230
255
  sourceCompatibility = JavaVersion.VERSION_17
231
256
  targetCompatibility = JavaVersion.VERSION_17
@@ -20,6 +20,18 @@
20
20
  -keep class dev.gitlive.firebase.** { *; }
21
21
  -dontwarn com.google.firebase.**
22
22
 
23
+ # GitLive's RemoteConfig module is compiled against kotlinx-datetime's OWN Instant, which
24
+ # this version set no longer has: on Kotlin 2.2 Instant moved into the stdlib as
25
+ # kotlin.time.Instant. R8 hits an unresolvable reference and FAILS the release build.
26
+ #
27
+ # Suppressed rather than resolved, deliberately. The reference lives in
28
+ # FirebaseRemoteConfigInfo, and RemoteConfig is not a service this template wires up, so no
29
+ # code path reaches it. Adding kotlinx-datetime back purely to satisfy a class nobody calls
30
+ # would put two Instant types in the graph — the more expensive mistake. When GitLive ships
31
+ # a build against kotlin.time, delete these two lines; the release build will say if it is time.
32
+ -dontwarn kotlinx.datetime.Instant$Companion
33
+ -dontwarn kotlinx.datetime.Instant
34
+
23
35
  # Coroutines
24
36
  -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
25
37
  -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
@@ -2,6 +2,7 @@ package __PACKAGE__.presentation.navigation
2
2
 
3
3
  import androidx.compose.runtime.Composable
4
4
  import androidx.compose.runtime.LaunchedEffect
5
+ import androidx.compose.ui.Modifier
5
6
  import androidx.navigation.NavType
6
7
  import androidx.navigation.compose.NavHost
7
8
  import androidx.navigation.compose.composable
@@ -10,6 +11,9 @@ import androidx.navigation.navArgument
10
11
  // Nav 2.9 (multiplatform): backStackEntry.arguments is a SavedState, not an Android Bundle.
11
12
  // Read it via the androidx.savedstate.read extension, NOT Bundle.getString().
12
13
  import androidx.savedstate.read
14
+ // Kept OUTSIDE the screen-imports block below: the tab rewriter (src/lib/tabs.mjs) replaces
15
+ // exactly those two lines with the configured tabs' screen imports.
16
+ import __PACKAGE__.presentation.components.exposeTestTagsForAutomation
13
17
  import __PACKAGE__.presentation.home.HomeScreen
14
18
  import __PACKAGE__.presentation.profile.ProfileScreen
15
19
 
@@ -38,7 +42,18 @@ fun AppNavHost() {
38
42
  }
39
43
  }
40
44
 
41
- NavHost(navController = navController, startDestination = Screen.Shell.route) {
45
+ // Expose Compose testTags to the platform automation layer (Android resource-ids / iOS
46
+ // accessibilityIdentifiers) for the WHOLE graph. The property is inherited by descendants,
47
+ // so it belongs on the graph root, not on a destination: applied inside AppShell it would
48
+ // cover the tabs and nothing else, leaving every destination registered directly here
49
+ // (detail screens, trays) with testTags no id-selector can see — e2e flows could not
50
+ // assert arrival on them at all. Here, a destination added later inherits it without
51
+ // anyone remembering to. Desktop: no-op.
52
+ NavHost(
53
+ navController = navController,
54
+ startDestination = Screen.Shell.route,
55
+ modifier = Modifier.exposeTestTagsForAutomation(),
56
+ ) {
42
57
  composable(Screen.Shell.route) {
43
58
  val tabs = appTabs(
44
59
  home = {
@@ -10,7 +10,6 @@ import androidx.compose.runtime.setValue
10
10
  import androidx.compose.ui.Modifier
11
11
  import __PACKAGE__.presentation.components.AppBottomBar
12
12
  import __PACKAGE__.presentation.components.BaseScreen
13
- import __PACKAGE__.presentation.components.exposeTestTagsForAutomation
14
13
 
15
14
  /**
16
15
  * Generic bottom-nav shell. Parameterized by a [tabs] list — NOT role-hardcoded.
@@ -27,10 +26,8 @@ fun AppShell(tabs: List<AppTab>) {
27
26
  var selected by rememberSaveable { mutableIntStateOf(0) }
28
27
 
29
28
  BaseScreen(
30
- // Expose Compose testTags to the platform automation layer (Android resource-ids /
31
- // iOS accessibilityIdentifiers) so Appium/uiautomator id-selectors and the
32
- // cmp-test-generated suites find them; covers the whole subtree. Desktop: no-op.
33
- modifier = Modifier.exposeTestTagsForAutomation(),
29
+ // testTag exposure for automation is applied once on the NavHost in AppNavHost.kt —
30
+ // the graph root, so every destination inherits it, not just these tabs.
34
31
  // The bottom bar owns the navigation-bar inset; the body must not also pad it.
35
32
  applyNavBarPadding = false,
36
33
  bottomBar = {
@@ -9,8 +9,25 @@
9
9
  // node qa/approve.mjs --accept-defaults express lane (GENESIS-FLOW-DESIGN.md §2): approves
10
10
  // every currently-resolvable, not-yet-approved
11
11
  // artifact, each stamped "defaults-accepted"
12
- // node qa/approve.mjs --reopen <artifact> moves an approved artifact back to "reopened" for
13
- // redesign (refuses anything not currently approved)
12
+ // node qa/approve.mjs --reopen <artifact> --reason ""
13
+ // moves an approved artifact back to "reopened" for
14
+ // redesign (refuses anything not currently approved).
15
+ // --reason is REQUIRED — a reopen walks back a
16
+ // signature, and the signer must be able to read why
17
+ // from the ledger itself (2026-07-28 flow audit)
18
+ // node qa/approve.mjs --reopen-feature <name> --reason "…"
19
+ // one recorded change, not N reopen commands: reopens
20
+ // the brief + its spec + its design + every artifact
21
+ // the brief declares in `touches` (each only if
22
+ // currently approved), all under one reason
23
+ // node qa/approve.mjs --log the journal — every approve/reopen/accept with
24
+ // when, which surface, and why (newest last)
25
+ // node qa/approve.mjs --accept <name> the HUMAN's bookend on a feature brief
26
+ // (feature-brief:<name>) — refused until the feature
27
+ // is provenDone (every live clause cited + receipt
28
+ // PASS + receipt attests this tree). There is no
29
+ // --deliver: doneness is DERIVED, never claimed
30
+ // (CHANGE-FLOW-DESIGN.md §2).
14
31
  //
15
32
  // This file has NO logic of its own — every decision (the registry, hashing,
16
33
  // state, the transitions) lives in qa/lib/approvals.mjs. That's deliberate: the
@@ -22,12 +39,16 @@ import path from "node:path";
22
39
  import { fileURLToPath } from "node:url";
23
40
 
24
41
  import {
42
+ acceptFeature,
25
43
  approveAllDefaults,
26
44
  approveArtifact,
27
45
  getApprovalStatuses,
46
+ getFeatureBoard,
28
47
  isPackageResolvable,
29
48
  listGovernedArtifacts,
49
+ readJournal,
30
50
  reopenArtifact,
51
+ reopenFeature,
31
52
  } from "./lib/approvals.mjs";
32
53
 
33
54
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -52,22 +73,44 @@ function printStatus() {
52
73
  // approvable — approval over an unresolved file set is refused.
53
74
  const hashInfo =
54
75
  s.status === "reopened"
55
- ? `reopened at ${s.reopenedAt} (was approved ${shortHash(s.storedHash)})`
76
+ ? `reopened at ${s.reopenedAt} (was approved ${shortHash(s.storedHash)})${s.reason ? ` — ${s.reason}` : ""}`
56
77
  : s.status === "changed-since-approval"
57
78
  ? s.resolvable
58
79
  ? `approved ${shortHash(s.storedHash)} -> now ${shortHash(s.hash)}`
59
80
  : `approved ${shortHash(s.storedHash)} -> unresolvable (${s.fileCount} of expected files resolved)`
60
81
  : s.status === "approved"
61
- ? shortHash(s.hash)
82
+ ? // storedHash is the signature; s.hash is the live recompute. They
83
+ // agree except on a legacy raw-bytes row, where printing the live
84
+ // value would name a hash nobody signed.
85
+ `${shortHash(s.storedHash) || shortHash(s.hash)}${
86
+ s.hashBasis === "raw-bytes" ? ", signed pre-strip — bytes unchanged since" : ""
87
+ }`
62
88
  : s.resolvable
63
89
  ? `would approve at ${shortHash(s.hash)}`
64
90
  : `unresolvable (${s.fileCount} of expected files resolved) — not approvable`;
65
91
  const modeInfo = s.mode ? ` [${s.mode}]` : "";
66
- console.log(`${mark} ${s.id}: ${s.status} (${hashInfo})${modeInfo} ${s.label}`);
92
+ // A feature brief's acceptance lives on the ledger row, never in the doc
93
+ // (the signed bytes must not move when the human accepts).
94
+ const lifecycle = s.accepted ? ` · accepted ${s.acceptedAt}` : "";
95
+ console.log(`${mark} ${s.id}: ${s.status} (${hashInfo})${modeInfo}${lifecycle} — ${s.label}`);
67
96
  if (s.missing.length > 0) {
68
97
  console.log(` missing: ${s.missing.join(", ")}`);
69
98
  }
70
99
  }
100
+
101
+ // Per-feature DERIVED doneness + next step — the same getFeatureBoard the
102
+ // console renders, so --status and the console never tell different
103
+ // stories. The next step names its owner: a signature HANDS OFF, it never
104
+ // commands — the agent drafts/builds/proves, the human signs/accepts.
105
+ const { features } = getFeatureBoard(ROOT);
106
+ if (features.length > 0) {
107
+ console.log("\nFeatures (doneness is derived, never claimed):");
108
+ for (const f of features) {
109
+ const mark = f.provenDone ? "✓" : "…";
110
+ console.log(` ${mark} ${f.name}: ${f.doneReason}${f.blockError ? ` — BLOCK ERROR: ${f.blockError}` : ""}`);
111
+ console.log(` next → ${f.nextStep.label}${f.nextStep.owner ? ` · ${f.nextStep.owner}` : ""}`);
112
+ }
113
+ }
71
114
  }
72
115
 
73
116
  if (args.includes("--status")) {
@@ -102,27 +145,92 @@ if (args.includes("--accept-defaults")) {
102
145
  process.exit(0);
103
146
  }
104
147
 
148
+ // --reason is shared by --reopen and --reopen-feature: one plain sentence for
149
+ // the human who signed, recorded on the ledger row AND the journal. The library
150
+ // refuses without it; parsing it here just makes the usage line honest.
151
+ const reasonFlagIdx = args.indexOf("--reason");
152
+ const reason = reasonFlagIdx !== -1 ? args[reasonFlagIdx + 1] : undefined;
153
+
154
+ if (args.includes("--log")) {
155
+ const events = readJournal(ROOT);
156
+ if (events.length === 0) {
157
+ console.log("No journal yet (qa/approvals.log.jsonl) — it starts recording with the next approve/reopen/accept.");
158
+ process.exit(0);
159
+ }
160
+ console.log("Governance journal (oldest first):\n");
161
+ for (const e of events) {
162
+ const mark = e.verb === "approve" ? "✓" : e.verb === "reopen" ? "↺" : e.verb === "accept" ? "◆" : "·";
163
+ const bits = [e.via ? `via ${e.via}` : null, e.mode ? `[${e.mode}]` : null, e.feature ? `feature ${e.feature}` : null]
164
+ .filter(Boolean)
165
+ .join(" · ");
166
+ console.log(`${mark} ${e.at} ${e.verb} ${e.artifact}${bits ? ` (${bits})` : ""}${e.reason ? ` — ${e.reason}` : ""}`);
167
+ }
168
+ process.exit(0);
169
+ }
170
+
105
171
  const reopenFlagIdx = args.indexOf("--reopen");
106
172
  if (reopenFlagIdx !== -1) {
107
173
  refuseIfUnresolvable();
108
174
  const artifactId = args[reopenFlagIdx + 1];
109
- if (!artifactId) {
110
- console.error("usage: node qa/approve.mjs --reopen <artifact>");
175
+ if (!artifactId || artifactId === "--reason") {
176
+ console.error('usage: node qa/approve.mjs --reopen <artifact> --reason "why, in one sentence"');
177
+ process.exit(1);
178
+ }
179
+ const result = reopenArtifact(ROOT, artifactId, { reason, via: "cli" });
180
+ if (!result.ok) {
181
+ console.error(`error: ${result.reason}`);
182
+ process.exit(1);
183
+ }
184
+ console.log(`↺ reopened ${result.artifact} for redesign — at ${result.reopenedAt}\n reason: ${reason.trim()}`);
185
+ process.exit(0);
186
+ }
187
+
188
+ // One recorded change, not N reopen commands (2026-07-28 flow audit, fix 4):
189
+ // the brief is the container the human thinks in — reopen its whole declared
190
+ // set under one reason, each journal event grouped by the feature's name.
191
+ const reopenFeatureFlagIdx = args.indexOf("--reopen-feature");
192
+ if (reopenFeatureFlagIdx !== -1) {
193
+ refuseIfUnresolvable();
194
+ const name = args[reopenFeatureFlagIdx + 1];
195
+ if (!name || name === "--reason") {
196
+ console.error('usage: node qa/approve.mjs --reopen-feature <name> --reason "why, in one sentence"');
197
+ process.exit(1);
198
+ }
199
+ const result = reopenFeature(ROOT, name, { reason, via: "cli" });
200
+ if (!result.ok) {
201
+ console.error(`error: ${result.reason}`);
202
+ process.exit(1);
203
+ }
204
+ console.log(`↺ reopened feature "${result.feature}" as one change — reason: ${reason.trim()}`);
205
+ for (const id of result.reopened) console.log(` ↺ ${id}`);
206
+ for (const s of result.skipped) console.log(` → skipped ${s.id} (${s.status})`);
207
+ process.exit(0);
208
+ }
209
+
210
+ // The human's bookend: --accept takes the brief NAME, not the full artifact
211
+ // id — the docs/features/<name>.md filename is what a human knows. The library
212
+ // refuses until the feature is provenDone; there is no agent claim in between.
213
+ const acceptFlagIdx = args.indexOf("--accept");
214
+ if (acceptFlagIdx !== -1) {
215
+ refuseIfUnresolvable();
216
+ const name = args[acceptFlagIdx + 1];
217
+ if (!name) {
218
+ console.error("usage: node qa/approve.mjs --accept <name> (the brief's name — docs/features/<name>.md)");
111
219
  process.exit(1);
112
220
  }
113
- const result = reopenArtifact(ROOT, artifactId);
221
+ const result = acceptFeature(ROOT, name, { via: "cli" });
114
222
  if (!result.ok) {
115
223
  console.error(`error: ${result.reason}`);
116
224
  process.exit(1);
117
225
  }
118
- console.log(`↺ reopened ${result.artifact} for redesign — at ${result.reopenedAt}`);
226
+ console.log(`✓ accepted ${result.artifact} at ${result.acceptedAt} — the feature's card closes; the brief is its doc-of-record.`);
119
227
  process.exit(0);
120
228
  }
121
229
 
122
230
  if (args.length === 0) {
123
231
  const ids = listGovernedArtifacts(ROOT).map((a) => a.id);
124
232
  console.error(
125
- "usage: node qa/approve.mjs <artifact> | --status | --accept-defaults | --reopen <artifact>\n" +
233
+ 'usage: node qa/approve.mjs <artifact> | --status | --log | --accept-defaults | --reopen <artifact> --reason "…" | --reopen-feature <name> --reason "…" | --accept <name>\n' +
126
234
  ` valid artifacts: ${ids.length > 0 ? ids.join(", ") : "(none resolved in this project)"}`,
127
235
  );
128
236
  process.exit(1);
@@ -131,7 +239,7 @@ if (args.length === 0) {
131
239
  refuseIfUnresolvable();
132
240
 
133
241
  const artifactId = args[0];
134
- const result = approveArtifact(ROOT, artifactId);
242
+ const result = approveArtifact(ROOT, artifactId, { via: "cli" });
135
243
  if (!result.ok) {
136
244
  console.error(`error: ${result.reason}`);
137
245
  process.exit(1);