create-cmp-cli 0.10.1 → 0.12.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.
Files changed (52) hide show
  1. package/README.md +11 -9
  2. package/package.json +1 -1
  3. package/src/lib/package-name.mjs +72 -0
  4. package/src/lib/tabs.mjs +26 -0
  5. package/src/scaffold.mjs +7 -2
  6. package/template/.claude/settings.json +30 -0
  7. package/template/.claude/skills/add-feature/SKILL.md +20 -0
  8. package/template/.claude/skills/add-repository/SKILL.md +6 -0
  9. package/template/.claude/skills/add-screen/SKILL.md +6 -0
  10. package/template/CLAUDE.md +129 -8
  11. package/template/composeApp/build.gradle.kts +69 -0
  12. package/template/composeApp/proguard-rules.pro +12 -0
  13. package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
  14. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
  15. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
  16. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
  17. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
  18. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
  19. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
  20. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
  21. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
  22. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
  23. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
  24. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
  25. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +16 -1
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +2 -5
  32. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
  33. package/template/docs/ARCHITECTURE.md +41 -2
  34. package/template/docs/TESTING.md +165 -0
  35. package/template/gradle/libs.versions.toml +15 -0
  36. package/template/manifest.json +1 -0
  37. package/template/qa/approve.mjs +119 -11
  38. package/template/qa/evidence/schema.json +20 -2
  39. package/template/qa/lib/affected-tests.mjs +147 -0
  40. package/template/qa/lib/approvals.mjs +602 -21
  41. package/template/qa/lib/device-lease.mjs +249 -0
  42. package/template/qa/lib/evidence-level.mjs +117 -0
  43. package/template/qa/lib/feature-brief.mjs +324 -0
  44. package/template/qa/lib/inputs-hash.mjs +43 -6
  45. package/template/qa/lib/reachability.mjs +211 -0
  46. package/template/qa/lib/spec-coverage.mjs +131 -0
  47. package/template/qa/lib/step-cache.mjs +221 -0
  48. package/template/qa/receipt-check.mjs +22 -2
  49. package/template/qa/scaffold-feature.mjs +20 -1
  50. package/template/qa/verify.mjs +776 -95
  51. package/template/qa/watch.mjs +622 -0
  52. package/template/specs/app-base.spec.md +11 -0
@@ -10,20 +10,22 @@
10
10
  //
11
11
  // Three concerns, kept separable:
12
12
  // 1. The REGISTRY (`listGovernedArtifacts`) — artifact id -> resolved file list, in
13
- // GENESIS-FLOW-DESIGN.md §1 order: intent(0), architecture(1), exemplar-spec(2),
14
- // exemplar-feature(3), design-system(4), components(5), then one
15
- // `feature-spec:<name>` (6+) per non-base, non-exemplar spec file present in
16
- // specs/ right now. (Spec-first: the exemplar's clauses are confirmed before the
17
- // slice is built. UI-first: design system + components are distilled from the
18
- // real screens, so they lock after the exemplar.) The exemplar (2/3) is
13
+ // definition order (GENESIS-FLOW-DESIGN.md §1 + CHANGE-FLOW-DESIGN.md §6):
14
+ // intent(0), feature-brief:<name> per docs/features/*.md (the decide layer,
15
+ // directly after intent), architecture, exemplar-spec, exemplar-feature,
16
+ // design-system, components, then one `feature-spec:<name>` per non-base,
17
+ // non-exemplar spec file present in specs/ right now. (Decide-first: a brief
18
+ // speaks intent's vocabulary. Spec-first: the exemplar's clauses are confirmed
19
+ // before the slice is built. UI-first: design system + components are distilled
20
+ // from the real screens, so they lock after the exemplar.) The exemplar is
19
21
  // CONFIGURABLE — see
20
22
  // `getExemplarFeature`/`resolveExemplarNames` below — defaulting to `home` so
21
23
  // every ledger written before this config key existed keeps meaning what it
22
24
  // meant. The registry is recomputed on every call — it reflects the tree as it
23
25
  // stands, never a stale snapshot.
24
26
  // 2. STATE (`loadApprovals`/`saveApprovals`) — qa/approvals.json, the human's
25
- // decisions: { artifact, status, hash, approvedAt, mode?, reopenedAt? } plus the
26
- // top-level `exemplarFeature` config key. Absent or corrupt is TOLERATED
27
+ // decisions: { artifact, status, hash, approvedAt, mode?, reopenedAt?, via?,
28
+ // reason? } plus the top-level `exemplarFeature` config key. Absent or corrupt is TOLERATED
27
29
  // (treated as empty / all-unreviewed / default exemplar) — this ledger must
28
30
  // never crash the verify lane or the stamper.
29
31
  //
@@ -57,9 +59,20 @@ import fs from "node:fs";
57
59
  import path from "node:path";
58
60
 
59
61
  import { ARCH_DOC_REL_PATH, stripGeneratedSections } from "./arch-doc.mjs";
62
+ import { deriveAllFeatures, deriveFeatureStatus, listFeatureBriefs, parseFeatureBlock, stripFeatureBlock } from "./feature-brief.mjs";
60
63
 
61
64
  export const APPROVALS_REL_PATH = "qa/approvals.json";
62
65
  export const APPROVALS_SCHEMA = "cmp-approvals/1";
66
+ // The JOURNAL (2026-07-28 flow audit, fix 1): qa/approvals.json is a mutable
67
+ // snapshot — every transition overwrites the row, so the ledger cannot answer
68
+ // "what happened while I was away?". The journal is the append-only memory
69
+ // beside it: one JSON line per human-meaningful transition (approve / reopen /
70
+ // accept), each carrying {at, verb, artifact, via, reason?}. State stays
71
+ // DERIVED from the snapshot exactly as before; the journal gates nothing and
72
+ // no lane step reads it — which is why it sits in inputs-hash.mjs's
73
+ // EXCLUDED_PREFIXES (like qa/comments.json): appending history must never
74
+ // invalidate the receipt for a tree whose code did not change.
75
+ export const APPROVALS_JOURNAL_REL_PATH = "qa/approvals.log.jsonl";
63
76
 
64
77
  // Kotlin source-set roots, relative to project root — mirrors qa/scaffold-feature.mjs's
65
78
  // SRC() helper (composeApp/src/<sourceSet>/kotlin/<packageDir>).
@@ -234,6 +247,41 @@ function listComponentFiles(root) {
234
247
  .sort((a, b) => a.localeCompare(b));
235
248
  }
236
249
 
250
+ // ── Feature screens glob ────────────────────────────────────────────────────
251
+
252
+ /** The `feature-design:` id prefix — one place, so the CLI/console/gate never drift on it. */
253
+ export const FEATURE_DESIGN_PREFIX = "feature-design:";
254
+
255
+ /**
256
+ * The screen files of one feature — `presentation/<name>/**\/*Screen.kt`,
257
+ * recursive, sorted. DELIBERATELY only `*Screen.kt`: the design signature
258
+ * covers the FORM (what renders), so binding the whole presentation dir would
259
+ * make every ViewModel edit during a legitimate build read as design drift.
260
+ * @param {string} root
261
+ * @param {string} name the feature name (presentation/<name>/)
262
+ * @returns {string[]} repo-relative posix paths
263
+ */
264
+ function listFeatureScreenFiles(root, name) {
265
+ const dirRel = kotlinFile(root, "commonMain", `presentation/${name}`);
266
+ if (!dirRel) return [];
267
+ const out = [];
268
+ const walk = (rel) => {
269
+ let entries;
270
+ try {
271
+ entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
272
+ } catch {
273
+ return;
274
+ }
275
+ for (const e of entries) {
276
+ const childRel = path.posix.join(rel, e.name);
277
+ if (e.isDirectory()) walk(childRel);
278
+ else if (e.isFile() && e.name.endsWith("Screen.kt")) out.push(childRel);
279
+ }
280
+ };
281
+ walk(dirRel);
282
+ return out.sort((a, b) => a.localeCompare(b));
283
+ }
284
+
237
285
  // ── Registry ─────────────────────────────────────────────────────────────────
238
286
 
239
287
  /**
@@ -246,9 +294,11 @@ function listComponentFiles(root) {
246
294
  * VISUAL artifacts are UI-FIRST — the design system and component vocabulary
247
295
  * are distilled FROM the real screens, so they lock AFTER the exemplar
248
296
  * exists (a provisional palette carries the build until then).
249
- * Order: intent(0), architecture(1), exemplar-spec(2), exemplar-feature(3),
250
- * design-system(4), components(5), then one feature-spec:<name> (6+) per
251
- * non-base, non-CONFIGURED-exemplar spec present.
297
+ * Order: intent(0), then feature-brief:<name> per docs/features/*.md — the
298
+ * DECIDE layer sits directly after intent (a brief speaks intent's
299
+ * vocabulary; only the SPEC needs architecture's) — then architecture,
300
+ * exemplar-spec, exemplar-feature, design-system, components, and one
301
+ * feature-spec:<name> per non-base, non-CONFIGURED-exemplar spec present.
252
302
  *
253
303
  * `complete: false` marks an artifact whose kotlin-rooted files could NOT be
254
304
  * resolved (unresolvable package — raw template / pre-stamp tree). Such an
@@ -269,6 +319,24 @@ export function listGovernedArtifacts(root) {
269
319
  complete: true,
270
320
  });
271
321
 
322
+ // Feature briefs (feature-brief.mjs): one `feature-brief:<name>` per doc
323
+ // under docs/features/ — LOCATION is the governance opt-in (CHANGE-FLOW-
324
+ // DESIGN.md §2); docs/proposals/ stays ungoverned harness-standards prose.
325
+ // They sit DIRECTLY after intent: the decide layer. At genesis the first
326
+ // feature's brief is drafted from the intent interview before architecture
327
+ // is even walked; post-genesis every decision-carrying change enters here.
328
+ // Approving one hashes the doc's bytes, so a signed brief cannot be quietly
329
+ // rewritten; acceptance is a LEDGER field on the same row (acceptFeature
330
+ // below), so the human's bookend never touches the signed bytes.
331
+ for (const brief of listFeatureBriefs(root)) {
332
+ artifacts.push({
333
+ id: `feature-brief:${brief.name}`,
334
+ label: `Feature brief (${brief.rel})`,
335
+ files: [brief.rel],
336
+ complete: true,
337
+ });
338
+ }
339
+
272
340
  artifacts.push({
273
341
  id: "architecture",
274
342
  label: `Architecture + structure (${ARCHITECTURE_SPEC_REL} + ${ARCH_DOC_REL_PATH}, generated sections stripped)`,
@@ -323,6 +391,32 @@ export function listGovernedArtifacts(root) {
323
391
  complete: packageResolved,
324
392
  });
325
393
 
394
+ // Feature designs (brief → design → spec → build, decided 2026-07-25): one
395
+ // `feature-design:<name>` per BRIEF with a UI surface — declared
396
+ // (`"screens": true` in the cmp:feature block, so the gate exists before any
397
+ // file does) or evident (presentation/<name>/*Screen.kt on disk). Signed on
398
+ // RENDERED output, never descriptions, BEFORE the behavior contract pins the
399
+ // form down. Briefs only, deliberately: legacy features (governed by
400
+ // exemplar-feature or nothing) never sprout retro-governance. With no screen
401
+ // files yet, `complete: false` — approveArtifact refuses, exactly right: you
402
+ // cannot sign a design that has nothing rendered.
403
+ for (const brief of listFeatureBriefs(root)) {
404
+ let declaresScreens = false;
405
+ try {
406
+ declaresScreens = parseFeatureBlock(fs.readFileSync(path.join(root, brief.rel), "utf8")).screens;
407
+ } catch {
408
+ /* an unreadable brief declares nothing */
409
+ }
410
+ const screenFiles = listFeatureScreenFiles(root, brief.name);
411
+ if (!declaresScreens && screenFiles.length === 0) continue;
412
+ artifacts.push({
413
+ id: `${FEATURE_DESIGN_PREFIX}${brief.name}`,
414
+ label: `Feature design (${brief.name} — presentation/${brief.name}/*Screen.kt, signed on rendered output)`,
415
+ files: screenFiles,
416
+ complete: packageResolved && screenFiles.length > 0,
417
+ });
418
+ }
419
+
326
420
  const specsDir = path.join(root, "specs");
327
421
  if (fs.existsSync(specsDir)) {
328
422
  const featureSpecs = fs
@@ -455,7 +549,49 @@ export function hashArchitectureArtifact(root) {
455
549
  * @returns {{ hash: string, fileCount: number, missing: string[] }}
456
550
  */
457
551
  function computeArtifactHash(root, artifact) {
458
- return artifact.id === "architecture" ? hashArchitectureArtifact(root) : hashArtifactFiles(root, artifact.files);
552
+ if (artifact.id === "architecture") return hashArchitectureArtifact(root);
553
+ if (artifact.id.startsWith(FEATURE_BRIEF_PREFIX)) return hashFeatureBriefArtifact(root, artifact);
554
+ return hashArtifactFiles(root, artifact.files);
555
+ }
556
+
557
+ /**
558
+ * A feature brief's hash basis: the doc's content, EOL-normalized, with the
559
+ * cmp:feature declaration block stripped (`stripFeatureBlock` — the ONE
560
+ * definition of the block grammar, shared with parseFeatureBlock so the
561
+ * stripper and the parser can never disagree about what a block is).
562
+ *
563
+ * Same rationale as `hashArchitectureArtifact`'s cmp:generated stripping: the
564
+ * human signs the brief's reasoning; `touches`/`screens` are declarations the
565
+ * harness independently enforces (artifact hashes enforce blast radius; disk
566
+ * presence enforces the design gate) and can authorise nothing — so adding
567
+ * mandatory machine-read metadata to a signed brief must never manufacture a
568
+ * human re-approval. Normalized `\r\n` -> `\n` first, because the fence
569
+ * grammar matches a literal `\n` and a checkout-induced EOL flip in prose must
570
+ * never read as authored drift.
571
+ * @param {string} root
572
+ * @param {{id: string, files: string[]}} artifact
573
+ * @returns {{ hash: string, fileCount: number, missing: string[] }}
574
+ */
575
+ function hashFeatureBriefArtifact(root, artifact) {
576
+ const files = [...new Set(artifact.files)].sort();
577
+ const rows = [];
578
+ const missing = [];
579
+ for (const relPath of files) {
580
+ let raw;
581
+ try {
582
+ raw = fs.readFileSync(path.join(root, relPath), "utf8");
583
+ } catch {
584
+ missing.push(relPath);
585
+ continue;
586
+ }
587
+ const stripped = stripFeatureBlock(raw.replace(/\r\n/g, "\n"));
588
+ rows.push([relPath, createHash("sha256").update(stripped, "utf8").digest("hex")]);
589
+ }
590
+ const overall = createHash("sha256");
591
+ for (const [relPath, fileSha] of rows) {
592
+ overall.update(`${relPath}\0${fileSha}\n`);
593
+ }
594
+ return { hash: overall.digest("hex"), fileCount: rows.length, missing };
459
595
  }
460
596
 
461
597
  // ── State (qa/approvals.json) ─────────────────────────────────────────────────
@@ -512,6 +648,56 @@ export function saveApprovals(root, state) {
512
648
  fs.writeFileSync(p, `${JSON.stringify(out, null, 2)}\n`);
513
649
  }
514
650
 
651
+ // ── The journal (append-only; the snapshot above stays the derived state) ────
652
+
653
+ /**
654
+ * Append one transition to qa/approvals.log.jsonl. TOLERANT — a failed append
655
+ * (read-only fs, weird mount) must never block the transition it records, so
656
+ * this returns {ok:false} rather than throwing; the snapshot write (the state
657
+ * that gates) has already happened or is about to, and history-keeping is
658
+ * strictly subordinate to it.
659
+ * @param {string} root
660
+ * @param {{verb: string, artifact: string, via?: string, reason?: string, [k: string]: unknown}} event
661
+ * @returns {{ok: boolean}}
662
+ */
663
+ export function appendJournal(root, event) {
664
+ const p = path.join(root, APPROVALS_JOURNAL_REL_PATH);
665
+ try {
666
+ fs.mkdirSync(path.dirname(p), { recursive: true });
667
+ fs.appendFileSync(p, `${JSON.stringify({ at: new Date().toISOString(), ...event })}\n`);
668
+ return { ok: true };
669
+ } catch {
670
+ return { ok: false };
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Every journal event, oldest first. Absent file -> []. A corrupt LINE is
676
+ * skipped, never fatal (same tolerance stance as loadApprovals) — one mangled
677
+ * append must not blind the console to the rest of the history.
678
+ * @param {string} root
679
+ * @returns {Array<{at: string, verb: string, artifact: string, via?: string, reason?: string}>}
680
+ */
681
+ export function readJournal(root) {
682
+ let raw;
683
+ try {
684
+ raw = fs.readFileSync(path.join(root, APPROVALS_JOURNAL_REL_PATH), "utf8");
685
+ } catch {
686
+ return [];
687
+ }
688
+ const events = [];
689
+ for (const line of raw.split("\n")) {
690
+ if (line.trim() === "") continue;
691
+ try {
692
+ const parsed = JSON.parse(line);
693
+ if (parsed && typeof parsed === "object" && typeof parsed.verb === "string") events.push(parsed);
694
+ } catch {
695
+ /* skip the mangled line, keep the history */
696
+ }
697
+ }
698
+ return events;
699
+ }
700
+
515
701
  /**
516
702
  * The configured exemplar feature's lowercase name (the package-segment form,
517
703
  * e.g. `"home"`, `"favorites"`) — `qa/approvals.json`'s top-level
@@ -573,7 +759,15 @@ function shortHash(hash) {
573
759
  * stored record actually carries them — never as an explicit `undefined` key, so
574
760
  * structural equality checks against a plain unreviewed/approved status shape
575
761
  * still hold.
576
- * @returns {{id: string, label: string, status: string, hash: string, storedHash: (string|null), approvedAt: (string|null), fileCount: number, missing: string[], resolvable: boolean, mode?: string, reopenedAt?: string}}
762
+ *
763
+ * `hash` is always the LIVE recompute and `storedHash` always what was actually
764
+ * signed. They are equal for every artifact signed on the current basis, which is
765
+ * why they are easy to conflate — but a display that means "the signature" must
766
+ * read `storedHash`. The one case where they legitimately differ on an `approved`
767
+ * row is `hashBasis: "raw-bytes"` (below): the stored hash is on a superseded
768
+ * basis, so the live recompute is a number NOBODY EVER SIGNED and must never be
769
+ * labelled as one.
770
+ * @returns {{id: string, label: string, status: string, hash: string, storedHash: (string|null), approvedAt: (string|null), fileCount: number, missing: string[], resolvable: boolean, mode?: string, reopenedAt?: string, hashBasis?: string}}
577
771
  */
578
772
  export function resolveArtifactStatus(root, artifact, storedRecord) {
579
773
  const recomputed = computeArtifactHash(root, artifact);
@@ -591,6 +785,11 @@ export function resolveArtifactStatus(root, artifact, storedRecord) {
591
785
  missing: recomputed.missing,
592
786
  resolvable,
593
787
  reopenedAt: storedRecord.reopenedAt,
788
+ // Attribution (2026-07-28 flow audit): who walked the signature back and
789
+ // why, read straight off the row — surfaced only when actually recorded
790
+ // (pre-audit rows carry neither; absence is the honest answer there).
791
+ ...(storedRecord.via ? { via: storedRecord.via } : {}),
792
+ ...(storedRecord.reason ? { reason: storedRecord.reason } : {}),
594
793
  };
595
794
  }
596
795
 
@@ -607,7 +806,27 @@ export function resolveArtifactStatus(root, artifact, storedRecord) {
607
806
  resolvable,
608
807
  };
609
808
  }
610
- const changed = !resolvable || storedRecord.hash !== recomputed.hash;
809
+ let changed = !resolvable || storedRecord.hash !== recomputed.hash;
810
+ // Legacy feature-brief approvals (pre block-stripping) stored the RAW-bytes
811
+ // hash. If the stored hash still matches the raw bytes on disk, the file is
812
+ // byte-identical to what the human signed — strictly stronger than a
813
+ // stripped-basis match — so the signature stands. Permanent and safe: this
814
+ // path can only accept content that has not changed at all since signing.
815
+ let hashBasis = null;
816
+ if (
817
+ changed &&
818
+ resolvable &&
819
+ artifact.id.startsWith(FEATURE_BRIEF_PREFIX) &&
820
+ storedRecord.hash === hashArtifactFiles(root, artifact.files).hash
821
+ ) {
822
+ changed = false;
823
+ // Say so. This is the ONE approved state where storedHash !== hash
824
+ // legitimately, and a row that shows the disagreement without naming its
825
+ // cause is indistinguishable from tolerated drift — the exact ambiguity the
826
+ // governance surface exists to remove. Emitted only when the path actually
827
+ // fires; absence means "signed on the current basis", the normal case.
828
+ hashBasis = "raw-bytes";
829
+ }
611
830
  return {
612
831
  id: artifact.id,
613
832
  label: artifact.label,
@@ -618,7 +837,13 @@ export function resolveArtifactStatus(root, artifact, storedRecord) {
618
837
  fileCount: recomputed.fileCount,
619
838
  missing: recomputed.missing,
620
839
  resolvable,
840
+ ...(hashBasis ? { hashBasis } : {}),
621
841
  ...(storedRecord.mode ? { mode: storedRecord.mode } : {}),
842
+ // Feature-brief acceptance (acceptFeature) — surfaced only when the row
843
+ // actually carries the fields, same stance as `mode`: never an explicit
844
+ // `undefined` key, so plain-status structural equality still holds.
845
+ ...(storedRecord.via ? { via: storedRecord.via } : {}),
846
+ ...(storedRecord.accepted ? { accepted: true, acceptedAt: storedRecord.acceptedAt ?? null } : {}),
622
847
  };
623
848
  }
624
849
 
@@ -652,12 +877,18 @@ export function getApprovalStatuses(root) {
652
877
  * unresolvable (raw template / pre-stamp tree), the artifact's expected files
653
878
  * are all missing on disk, or (a dynamic artifact, e.g. `components`) nothing
654
879
  * currently matches its pattern.
880
+ * A fresh approval also clears a feature brief's `accepted` field: re-signing
881
+ * a brief whose bytes changed is a NEW signature over new content, and an
882
+ * acceptance given against the old content does not carry over — the human
883
+ * re-accepts once the new content is provenDone again. Same wholesale-replace
884
+ * semantics that already clear `mode`/`reopenedAt`.
655
885
  * @param {string} root
656
886
  * @param {string} artifactId
657
- * @param {{mode?: string}} [options] `mode` (e.g. `"defaults-accepted"`) is
658
- * stamped onto the record when the express lane approves a resolvable-but-
659
- * unshaped artifact (GENESIS-FLOW-DESIGN.md §2). Omitted for a normal/real
660
- * approval.
887
+ * @param {{mode?: string, via?: string}} [options] `mode` (e.g.
888
+ * `"defaults-accepted"`) is stamped onto the record when the express lane
889
+ * approves a resolvable-but-unshaped artifact (GENESIS-FLOW-DESIGN.md §2).
890
+ * Omitted for a normal/real approval. `via` records the surface the approval
891
+ * came through (`"console"` / `"cli"`) — an audit field, never behavior.
661
892
  * @returns {{ok: true, artifact: string, hash: string, approvedAt: string, mode?: string} | {ok: false, reason: string}}
662
893
  */
663
894
  export function approveArtifact(root, artifactId, options = {}) {
@@ -691,8 +922,16 @@ export function approveArtifact(root, artifactId, options = {}) {
691
922
  const approvedAt = new Date().toISOString();
692
923
  const record = { artifact: artifactId, status: "approved", hash: resolved.hash, approvedAt };
693
924
  if (options.mode) record.mode = options.mode;
925
+ if (options.via) record.via = options.via;
694
926
  others.push(record);
695
927
  saveApprovals(root, { artifacts: others, exemplarFeature: state.exemplarFeature });
928
+ appendJournal(root, {
929
+ verb: "approve",
930
+ artifact: artifactId,
931
+ hash: resolved.hash,
932
+ ...(options.via ? { via: options.via } : {}),
933
+ ...(options.mode ? { mode: options.mode } : {}),
934
+ });
696
935
  return { ok: true, artifact: artifactId, hash: resolved.hash, approvedAt, ...(options.mode ? { mode: options.mode } : {}) };
697
936
  }
698
937
 
@@ -730,11 +969,33 @@ export function approveAllDefaults(root) {
730
969
  * artifact whose LIVE status is not `"approved"` — reopening the unreviewed, the
731
970
  * already-reopened, or a changed-since-approval artifact is meaningless (there is
732
971
  * nothing sanctioned to walk back from).
972
+ *
973
+ * REFUSES a missing `reason` (2026-07-28 flow audit, fix 2): a reopen is a
974
+ * state change on a SIGNED document — the one act in this file that walks back
975
+ * a human's signature. The ledger used to record neither who did it nor why,
976
+ * so the signer came back to "reopened" with no way to learn what happened.
977
+ * ECO discipline: every change to a released document carries initiator and
978
+ * justification, mechanically required. `via`/`reason` land on the row (they
979
+ * are outside the inputs-hash projection, so no receipt is invalidated) and
980
+ * in the journal.
733
981
  * @param {string} root
734
982
  * @param {string} artifactId
983
+ * @param {{reason: string, via?: string, feature?: string}} options `reason` is
984
+ * REQUIRED — one plain sentence for the human who signed. `via` records the
985
+ * surface ("console"/"cli"). `feature` groups the reopens of one
986
+ * `reopenFeature` walk under the brief's name.
735
987
  * @returns {{ok: true, artifact: string, reopenedAt: string} | {ok: false, reason: string}}
736
988
  */
737
- export function reopenArtifact(root, artifactId) {
989
+ export function reopenArtifact(root, artifactId, options = {}) {
990
+ const why = typeof options.reason === "string" ? options.reason.trim() : "";
991
+ if (why === "") {
992
+ return {
993
+ ok: false,
994
+ reason:
995
+ `cannot reopen "${artifactId}" without a reason — a reopen walks back a signature, and the signer ` +
996
+ `must be able to read why from the ledger itself. Pass one plain sentence (CLI: --reason "…").`,
997
+ };
998
+ }
738
999
  const registry = listGovernedArtifacts(root);
739
1000
  const artifact = registry.find((a) => a.id === artifactId);
740
1001
  if (!artifact) {
@@ -752,14 +1013,82 @@ export function reopenArtifact(root, artifactId) {
752
1013
  }
753
1014
  const others = state.artifacts.filter((a) => a.artifact !== artifactId);
754
1015
  const reopenedAt = new Date().toISOString();
755
- const record = { artifact: artifactId, status: "reopened", hash: stored.hash, approvedAt: stored.approvedAt, reopenedAt };
1016
+ const record = { artifact: artifactId, status: "reopened", hash: stored.hash, approvedAt: stored.approvedAt, reopenedAt, reason: why };
1017
+ if (options.via) record.via = options.via;
756
1018
  others.push(record);
757
1019
  saveApprovals(root, { artifacts: others, exemplarFeature: state.exemplarFeature });
1020
+ appendJournal(root, {
1021
+ verb: "reopen",
1022
+ artifact: artifactId,
1023
+ reason: why,
1024
+ ...(options.via ? { via: options.via } : {}),
1025
+ ...(options.feature ? { feature: options.feature } : {}),
1026
+ });
758
1027
  // `artifact` is the ID STRING — the same convention approveArtifact returns
759
1028
  // (one library, one shape; the console bridge relies on the symmetry).
760
1029
  return { ok: true, artifact: artifactId, reopenedAt };
761
1030
  }
762
1031
 
1032
+ /**
1033
+ * Reopen one FEATURE as one recorded change (2026-07-28 flow audit, fix 4):
1034
+ * the brief is the change container — a human edits "the meal-plan feature",
1035
+ * not four artifact ids at four timestamps. This walks the brief's set —
1036
+ * `feature-brief:<name>`, `feature-spec:<name>`, `feature-design:<name>`, and
1037
+ * every artifact the brief DECLARES in `touches` — and reopens each one that
1038
+ * is currently `approved`, all under the same reason, each journal event
1039
+ * carrying `feature: <name>` so the history reads as one change.
1040
+ *
1041
+ * Artifacts in the set that are not currently approved are SKIPPED and
1042
+ * reported (already reopened, unreviewed, or drifted — each already tells its
1043
+ * own story; silently "fixing" their state here would erase it). Refuses only
1044
+ * when the set contains nothing approved at all — then there is no signature
1045
+ * to walk back and the caller's premise is wrong.
1046
+ * @param {string} root
1047
+ * @param {string} name the brief's name (docs/features/<name>.md)
1048
+ * @param {{reason: string, via?: string}} options same contract as reopenArtifact
1049
+ * @returns {{ok: true, feature: string, reopened: string[], skipped: Array<{id: string, status: string}>} | {ok: false, reason: string}}
1050
+ */
1051
+ export function reopenFeature(root, name, options = {}) {
1052
+ const why = typeof options.reason === "string" ? options.reason.trim() : "";
1053
+ if (why === "") {
1054
+ return {
1055
+ ok: false,
1056
+ reason: `cannot reopen feature "${name}" without a reason — pass one plain sentence (CLI: --reason "…").`,
1057
+ };
1058
+ }
1059
+ const briefId = `${FEATURE_BRIEF_PREFIX}${name}`;
1060
+ const registry = listGovernedArtifacts(root);
1061
+ if (!registry.some((a) => a.id === briefId)) {
1062
+ const briefs = registry.filter((a) => a.id.startsWith(FEATURE_BRIEF_PREFIX)).map((a) => a.id.slice(FEATURE_BRIEF_PREFIX.length));
1063
+ return { ok: false, reason: `unknown feature "${name}" — known briefs: ${briefs.join(", ") || "(none)"}` };
1064
+ }
1065
+ const derived = deriveAllFeatures(root).find((d) => d.name === name);
1066
+ const set = [briefId, `feature-spec:${name}`, `${FEATURE_DESIGN_PREFIX}${name}`, ...(derived ? derived.touches : [])];
1067
+ const byId = new Map(getApprovalStatuses(root).map((s) => [s.id, s]));
1068
+ const reopened = [];
1069
+ const skipped = [];
1070
+ for (const id of [...new Set(set)]) {
1071
+ const live = byId.get(id);
1072
+ if (!live) continue; // declared touch that resolves to no governed artifact — nothing to reopen
1073
+ if (live.status !== "approved") {
1074
+ skipped.push({ id, status: live.status });
1075
+ continue;
1076
+ }
1077
+ const result = reopenArtifact(root, id, { reason: why, via: options.via, feature: name });
1078
+ if (result.ok) reopened.push(id);
1079
+ else skipped.push({ id, status: `refused: ${result.reason}` });
1080
+ }
1081
+ if (reopened.length === 0) {
1082
+ return {
1083
+ ok: false,
1084
+ reason:
1085
+ `nothing in "${name}"'s set is currently approved — there is no signature to walk back. ` +
1086
+ `Set: ${[...new Set(set)].join(", ")}; states: ${skipped.map((s) => `${s.id}=${s.status}`).join(", ") || "(unresolved)"}`,
1087
+ };
1088
+ }
1089
+ return { ok: true, feature: name, reopened, skipped };
1090
+ }
1091
+
763
1092
  // ── The verify-lane gate ─────────────────────────────────────────────────────
764
1093
 
765
1094
  /**
@@ -807,7 +1136,7 @@ export function evaluateApprovalsGate(root) {
807
1136
  for (const s of pending) {
808
1137
  if (s.status === "reopened") {
809
1138
  lines.push(
810
- ` [${s.id}] ${s.label} — reopened for redesign at ${s.reopenedAt} (non-blocking until re-approved). Approve: node qa/approve.mjs ${s.id}`,
1139
+ ` [${s.id}] ${s.label} — reopened for redesign at ${s.reopenedAt}${s.reason ? ` (reason: ${s.reason})` : ""} (non-blocking until re-approved). Approve: node qa/approve.mjs ${s.id}`,
811
1140
  );
812
1141
  } else if (!s.resolvable) {
813
1142
  lines.push(` [${s.id}] ${s.label} — unreviewed, currently unresolvable (${s.fileCount} of expected files resolved) — not approvable in this tree.`);
@@ -820,3 +1149,255 @@ export function evaluateApprovalsGate(root) {
820
1149
 
821
1150
  return { verdict: "PASS", reason: undefined, statuses };
822
1151
  }
1152
+
1153
+ // ── Feature-brief lifecycle (feature-brief.mjs is the doc/doneness model; ────
1154
+ // ── this file owns the LEDGER side: `accepted` lives on the approval row) ────
1155
+
1156
+ /** The `feature-brief:` id prefix — one place, so the CLI/console/gate never drift on it. */
1157
+ export const FEATURE_BRIEF_PREFIX = "feature-brief:";
1158
+
1159
+ /**
1160
+ * The human's bookend: mark a feature brief `accepted` — "the proven thing is
1161
+ * what I wanted" (CHANGE-FLOW-DESIGN.md §1). There is no agent claim in
1162
+ * between: doneness is DERIVED (deriveFeatureStatus — clauses cited, receipt
1163
+ * PASS, receipt attests this tree), so acceptance refuses until the harness
1164
+ * can prove done, and needs nothing else.
1165
+ *
1166
+ * Refusals, each a real gap in the acceptance's standing:
1167
+ * - unknown brief (no docs/features/<name>.md)
1168
+ * - brief not approved (accepting an unsigned plan attests nothing — the
1169
+ * walk is sign the brief, build, then accept), and a drifted brief must be
1170
+ * re-approved first (the bytes being accepted must be the bytes signed)
1171
+ * - not provenDone (the refusal quotes the derived doneReason verbatim —
1172
+ * uncited clause, red receipt, or a receipt attesting an older tree)
1173
+ * Acceptance never gates the lane — it closes the card, on the human's schedule.
1174
+ * @param {string} root
1175
+ * @param {string} name the brief's name (docs/features/<name>.md)
1176
+ * @param {{via?: string}} [options] `via` records the surface ("console"/"cli") in the journal
1177
+ * @returns {{ok: true, artifact: string, acceptedAt: string} | {ok: false, reason: string}}
1178
+ */
1179
+ export function acceptFeature(root, name, options = {}) {
1180
+ const artifactId = `${FEATURE_BRIEF_PREFIX}${name}`;
1181
+ const registry = listGovernedArtifacts(root);
1182
+ const artifact = registry.find((a) => a.id === artifactId);
1183
+ if (!artifact) {
1184
+ const briefs = registry.filter((a) => a.id.startsWith(FEATURE_BRIEF_PREFIX)).map((a) => a.id.slice(FEATURE_BRIEF_PREFIX.length));
1185
+ return {
1186
+ ok: false,
1187
+ reason: `unknown feature brief "${name}" — known briefs: ${briefs.join(", ") || "(none — a brief is any docs/features/<name>.md; the location is the governance opt-in)"}`,
1188
+ };
1189
+ }
1190
+ const state = loadApprovals(root);
1191
+ const stored = state.artifacts.find((a) => a.artifact === artifactId);
1192
+ const live = resolveArtifactStatus(root, artifact, stored);
1193
+ if (live.status !== "approved") {
1194
+ return {
1195
+ ok: false,
1196
+ reason:
1197
+ `cannot accept "${name}" — the brief is "${live.status}", not "approved". ` +
1198
+ (live.status === "changed-since-approval"
1199
+ ? `It changed after sign-off; re-approve it first (node qa/approve.mjs ${artifactId}).`
1200
+ : "The walk is: sign the brief, build, then accept the proven result."),
1201
+ };
1202
+ }
1203
+ // The design signature is part of the contract (brief → design → spec →
1204
+ // build): a feature with a UI surface cannot be accepted past an unsigned
1205
+ // or drifted design — "the proven thing is what I wanted" includes its form.
1206
+ const designArtifact = registry.find((a) => a.id === `${FEATURE_DESIGN_PREFIX}${name}`);
1207
+ if (designArtifact) {
1208
+ const designStored = state.artifacts.find((a) => a.artifact === designArtifact.id);
1209
+ const designLive = resolveArtifactStatus(root, designArtifact, designStored);
1210
+ if (designLive.status !== "approved") {
1211
+ return {
1212
+ ok: false,
1213
+ reason:
1214
+ `cannot accept "${name}" — feature-design:${name} is "${designLive.status}", not "approved". ` +
1215
+ `The feature's screens are signed on rendered output before acceptance (node qa/approve.mjs feature-design:${name}).`,
1216
+ };
1217
+ }
1218
+ }
1219
+ const derived = deriveFeatureStatus(root, { name, rel: artifact.files[0] });
1220
+ if (!derived.provenDone) {
1221
+ return { ok: false, reason: `cannot accept "${name}" — not provenDone: ${derived.doneReason}` };
1222
+ }
1223
+ const acceptedAt = new Date().toISOString();
1224
+ // MERGE into the approved row (not wholesale-replace): the signature —
1225
+ // hash/approvedAt/mode/via — must survive the acceptance verbatim. Contrast
1226
+ // approveArtifact, where replacement is the point (a new signature clears
1227
+ // an old acceptance).
1228
+ const next = state.artifacts.map((a) => (a.artifact === artifactId ? { ...a, accepted: true, acceptedAt } : a));
1229
+ saveApprovals(root, { artifacts: next, exemplarFeature: state.exemplarFeature });
1230
+ appendJournal(root, { verb: "accept", artifact: artifactId, ...(options.via ? { via: options.via } : {}) });
1231
+ return { ok: true, artifact: artifactId, acceptedAt };
1232
+ }
1233
+
1234
+ /**
1235
+ * The console's per-feature view — every brief with its full live state, in
1236
+ * one call, so the section renders without composing (the console never
1237
+ * re-implements the model; VERIFICATION-LAYER-DESIGN.md §4).
1238
+ *
1239
+ * Per feature: the DERIVED doneness (deriveFeatureStatus — clauses with their
1240
+ * citation state, receipt attestation, provenDone, the one-line doneReason),
1241
+ * the brief's approval record, a phase for the card chip, and the DECLARED
1242
+ * blast radius resolved against each touched artifact's live status — so the
1243
+ * console can render "components: changed-since-approval (as declared)" as
1244
+ * expected work, not alarm.
1245
+ *
1246
+ * Phase vocabulary (CHANGE-FLOW-DESIGN.md §6): `proposed` (no signature yet) →
1247
+ * `approved` (signed, building) → `proven` (provenDone, awaiting the human) →
1248
+ * `accepted`; a drifted or reopened brief reads as itself.
1249
+ *
1250
+ * `undeclared`: governed artifacts currently `changed-since-approval` that NO
1251
+ * open brief (approved, not yet accepted) declared in `touches` — with the
1252
+ * feature-brief/feature-spec families excluded (a brief's own doc drifting is
1253
+ * its card's business; spec drift is the feature-spec artifact's own row). The
1254
+ * plan drifted from reality; the console shows it as exactly that.
1255
+ * @param {string} root
1256
+ * @returns {{features: Array<object>, undeclared: Array<{id: string, label: string}>}}
1257
+ */
1258
+ // How many recorded edge cases satisfy the `audit` rung. A count cannot judge an
1259
+ // audit's QUALITY — it can only insist the pass happened and left written output,
1260
+ // which is the whole point: findings then land in the same signing round rather
1261
+ // than reopening a signed artifact. One is too easy to satisfy accidentally;
1262
+ // three is the smallest number that requires actually looking.
1263
+ const MIN_AUDITED_EDGE_CASES = 3;
1264
+
1265
+ export function getFeatureBoard(root) {
1266
+ const statuses = getApprovalStatuses(root);
1267
+ const byId = new Map(statuses.map((s) => [s.id, s]));
1268
+
1269
+ // The DERIVED next step (CHANGE-FLOW-DESIGN.md §4): computed from live
1270
+ // state exactly like provenDone, never claimed. An approval HANDS OFF, it
1271
+ // never commands — so each step names its owner: the agent drafts/builds/
1272
+ // proves, the human signs/accepts. For a change to EXISTING features the
1273
+ // contract step includes the declared amendments: every touched
1274
+ // feature-spec:* that is still signed must be reopened and amended, and the
1275
+ // step says so by name — that is what the human's signature set in motion.
1276
+ const deriveNextStep = (d, phase) => {
1277
+ const specArtifact = byId.get(`feature-spec:${d.name}`);
1278
+ const designArtifact = byId.get(`${FEATURE_DESIGN_PREFIX}${d.name}`) ?? null;
1279
+ const declaredSpecAmendments = d.touches
1280
+ .filter((id) => id.startsWith("feature-spec:") && byId.get(id)?.status === "approved")
1281
+ .map((id) => id.slice("feature-spec:".length));
1282
+ const amendNote =
1283
+ declaredSpecAmendments.length > 0 ? ` + reopen & amend ${declaredSpecAmendments.map((n) => `specs/${n}.spec.md`).join(", ")} (declared)` : "";
1284
+ // PRE-SIGNATURE AGENT WORK (the anti-churn ordering). A feature with a UI
1285
+ // surface gets its design drafted AND adversarially audited before the human
1286
+ // is asked for a single signature. The old ladder asked for the brief first,
1287
+ // so the design — and the audit that attacks it — happened against an already
1288
+ // signed artifact, and every finding reopened it. Measured on meal-plan
1289
+ // (2026-07-27): three signing rounds, the third triggered by an audit that
1290
+ // found nine gaps including three defects in signed clauses. The work did not
1291
+ // change; only when it happens relative to the gate.
1292
+ const designPending = designArtifact !== null && designArtifact.status !== "approved";
1293
+ const designUndrafted = designPending && !designArtifact.resolvable;
1294
+ const auditMissing = designPending && d.edgeCases < MIN_AUDITED_EDGE_CASES;
1295
+
1296
+ if (phase === "accepted") return { key: "closed", owner: null, label: "closed — the brief is this feature's doc-of-record" };
1297
+ if (phase === "changed-since-approval")
1298
+ return { key: "re-approve", owner: "human", label: `re-approve the brief — it changed after signing (or revert the edit)` };
1299
+ // `reopened` is ONE stored state covering two OPPOSITE situations (2026-07-28
1300
+ // flow audit, fix 3): mid-redesign it waits on the WORK; once the redesign is
1301
+ // proven (provenDone — every live clause cited + receipt PASS + receipt
1302
+ // attests this tree) it waits on the SIGNATURE. The split is derived, never
1303
+ // claimed — the same derivation acceptance already trusts. Before this,
1304
+ // meal-plan sat reopened AND 23/23-proven simultaneously: the card said
1305
+ // "waiting on you" while the guided queue said "nothing waits on you".
1306
+ if (phase === "reopened") {
1307
+ return d.provenDone
1308
+ ? { key: "re-approve", owner: "human", label: "redesign proven — re-approve the brief" }
1309
+ : { key: "redesign", owner: "agent", label: "redesign in progress — finish and prove it; the brief then returns for your signature" };
1310
+ }
1311
+ if (designUndrafted)
1312
+ return {
1313
+ key: "design",
1314
+ owner: "agent drafts → human signs",
1315
+ label: `design: draft the ${d.name} screens on stub data and render them — you sign what renders, never a description`,
1316
+ };
1317
+ if (auditMissing)
1318
+ return {
1319
+ key: "audit",
1320
+ owner: "agent",
1321
+ label:
1322
+ `audit the ${d.name} design for edge cases — record each case and how it resolves under ` +
1323
+ `"## Edge cases" in ${d.rel}. Findings land BEFORE the signature, not after it`,
1324
+ };
1325
+ if (phase === "proposed")
1326
+ return { key: "sign-brief", owner: "human", label: "sign the brief — decisions close before code, and the design below is audited" };
1327
+ // Design before contract (brief → design → spec → build): the form is
1328
+ // signed on RENDERED output before behavior clauses pin it down — and
1329
+ // before acceptance, so these rungs outrank `proven`.
1330
+ if (designPending) {
1331
+ if (designArtifact.status === "reopened")
1332
+ return { key: "design", owner: "agent", label: `redesign in progress: finish the ${d.name} screens, then re-approve the design` };
1333
+ return {
1334
+ key: "sign-design",
1335
+ owner: "human",
1336
+ label:
1337
+ designArtifact.status === "changed-since-approval"
1338
+ ? `re-approve the design (feature-design:${d.name}) — the screens changed after signing (or revert)`
1339
+ : `sign the design (feature-design:${d.name}) — audited, judged on the rendered screens`,
1340
+ };
1341
+ }
1342
+ if (phase === "proven") return { key: "accept", owner: "human", label: "accept — the proven thing awaits your judgment" };
1343
+ // phase === "approved": building — which part of the loop is open?
1344
+ if (!d.specExists || d.total === 0)
1345
+ return { key: "contract", owner: "agent drafts → human signs", label: `contract: write the clauses in ${d.specRel}${amendNote}` };
1346
+ if (specArtifact && specArtifact.status !== "approved")
1347
+ return { key: "sign-spec", owner: "human", label: `sign the contract (feature-spec:${d.name})${amendNote}` };
1348
+ if (d.covered < d.total)
1349
+ return { key: "build", owner: "agent", label: `build & cite: ${d.total - d.covered} clause(s) have no citing test yet` };
1350
+ return { key: "prove", owner: "agent", label: "prove: run node qa/verify.mjs so the receipt attests this tree" };
1351
+ };
1352
+
1353
+ const features = deriveAllFeatures(root).map((d) => {
1354
+ const record = byId.get(`${FEATURE_BRIEF_PREFIX}${d.name}`) ?? null;
1355
+ // Drift outranks acceptance: a brief edited after sign-off reads as
1356
+ // changed-since-approval even if it was accepted — a sneaky post-
1357
+ // acceptance edit must surface, never hide behind the closed card.
1358
+ const phase =
1359
+ !record || record.status === "unreviewed"
1360
+ ? "proposed"
1361
+ : record.status !== "approved"
1362
+ ? record.status // changed-since-approval / reopened read as themselves
1363
+ : record.accepted
1364
+ ? "accepted"
1365
+ : d.provenDone
1366
+ ? "proven"
1367
+ : "approved";
1368
+ const designStatus = byId.get(`${FEATURE_DESIGN_PREFIX}${d.name}`) ?? null;
1369
+ return {
1370
+ ...d,
1371
+ record,
1372
+ phase,
1373
+ // The design gate's live state, for the card: null = no UI surface
1374
+ // (a pure-logic feature honestly has no design rung to show).
1375
+ design: designStatus
1376
+ ? { id: designStatus.id, status: designStatus.status, resolvable: designStatus.resolvable, fileCount: designStatus.fileCount }
1377
+ : null,
1378
+ nextStep: deriveNextStep(d, phase),
1379
+ touches: d.touches.map((id) => {
1380
+ const t = byId.get(id);
1381
+ return t ? { id, status: t.status, label: t.label } : { id, status: "unknown", label: `(no governed artifact "${id}")` };
1382
+ }),
1383
+ };
1384
+ });
1385
+
1386
+ const declaredByOpenBriefs = new Set();
1387
+ for (const f of features) {
1388
+ if (f.record && f.record.status === "approved" && !f.record.accepted) {
1389
+ for (const t of f.touches) declaredByOpenBriefs.add(t.id);
1390
+ }
1391
+ }
1392
+ const undeclared = statuses
1393
+ .filter(
1394
+ (s) =>
1395
+ s.status === "changed-since-approval" &&
1396
+ !s.id.startsWith(FEATURE_BRIEF_PREFIX) &&
1397
+ !s.id.startsWith("feature-spec:") &&
1398
+ !declaredByOpenBriefs.has(s.id),
1399
+ )
1400
+ .map((s) => ({ id: s.id, label: s.label }));
1401
+
1402
+ return { features, undeclared };
1403
+ }