create-cmp-cli 0.13.0 → 0.14.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 +6 -2
- package/packages/harness/package.json +38 -0
- package/packages/harness/src/approve.mjs +247 -0
- package/packages/harness/src/arch-doc.mjs +69 -0
- package/packages/harness/src/comment.mjs +76 -0
- package/packages/harness/src/lib/a11y.mjs +113 -0
- package/packages/harness/src/lib/affected-tests.mjs +147 -0
- package/packages/harness/src/lib/approvals.mjs +1403 -0
- package/packages/harness/src/lib/arch-doc.mjs +451 -0
- package/packages/harness/src/lib/audit-cadence.mjs +290 -0
- package/packages/harness/src/lib/comments.mjs +252 -0
- package/packages/harness/src/lib/component-stories.mjs +183 -0
- package/packages/harness/src/lib/determinism.mjs +179 -0
- package/packages/harness/src/lib/device-lease.mjs +249 -0
- package/packages/harness/src/lib/evidence-badge.mjs +158 -0
- package/packages/harness/src/lib/evidence-level.mjs +117 -0
- package/packages/harness/src/lib/feature-brief.mjs +324 -0
- package/packages/harness/src/lib/flight-recorder.mjs +332 -0
- package/packages/harness/src/lib/harness-lock.mjs +147 -0
- package/packages/harness/src/lib/harness-region.mjs +159 -0
- package/packages/harness/src/lib/inputs-hash.mjs +194 -0
- package/packages/harness/src/lib/reachability.mjs +211 -0
- package/packages/harness/src/lib/receipt-validate.mjs +234 -0
- package/packages/harness/src/lib/render.mjs +254 -0
- package/packages/harness/src/lib/spec-coverage.mjs +131 -0
- package/packages/harness/src/lib/step-cache.mjs +221 -0
- package/packages/harness/src/lib/token-drift.mjs +94 -0
- package/packages/harness/src/lib/tree.mjs +108 -0
- package/packages/harness/src/preview-gallery.mjs +122 -0
- package/packages/harness/src/receipt-check.mjs +96 -0
- package/packages/harness/src/record-audit.mjs +83 -0
- package/packages/harness/src/refusal-demo.mjs +498 -0
- package/packages/harness/src/retrospective.mjs +51 -0
- package/packages/harness/src/scaffold-feature.mjs +723 -0
- package/packages/harness/src/setup-hooks.mjs +33 -0
- package/packages/harness/src/verify.mjs +1709 -0
- package/packages/harness/src/walkthrough.mjs +499 -0
- package/packages/harness/src/watch.mjs +622 -0
- package/packages/receipts/package.json +36 -0
- package/packages/receipts/src/index.mjs +16 -0
- package/packages/receipts/src/inputs-hash.mjs +194 -0
- package/packages/receipts/src/receipt-validate.mjs +234 -0
- package/src/commands/upgrade.mjs +96 -0
- package/src/lib/harness-upgrade.mjs +159 -2
- package/src/scaffold.mjs +60 -1
- package/template/AGENTS.md +5 -0
- package/template/CLAUDE.md +30 -0
- package/template/gitignore +8 -0
- package/template/qa/lib/harness-lock.mjs +147 -0
- package/template/qa/lib/harness-region.mjs +159 -0
- package/template/qa/lib/inputs-hash.mjs +1 -1
- package/template/qa/lib/receipt-validate.mjs +1 -1
- package/template/qa/preview-gallery.mjs +17 -2
- package/template/qa/verify.mjs +95 -1
|
@@ -0,0 +1,1403 @@
|
|
|
1
|
+
// The hash-bound human-approval data model (VERIFICATION-LAYER-DESIGN.md §2,
|
|
2
|
+
// extended by GENESIS-FLOW-DESIGN.md §1/§2/§3 — the genesis flow's registry,
|
|
3
|
+
// express lane, and reopen mechanics).
|
|
4
|
+
//
|
|
5
|
+
// Reuses ADR-0005's philosophy exactly (docs/adr/0005-evidence-binding-by-inputs-hash.md
|
|
6
|
+
// in the create-cmp repo): an approval is valid iff a stored content hash matches a
|
|
7
|
+
// recompute of the SAME files, right now. No new hashing idea — just applied to a
|
|
8
|
+
// smaller, human-curated surface (one governed artifact) instead of the whole
|
|
9
|
+
// verified tree.
|
|
10
|
+
//
|
|
11
|
+
// Three concerns, kept separable:
|
|
12
|
+
// 1. The REGISTRY (`listGovernedArtifacts`) — artifact id -> resolved file list, in
|
|
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
|
|
21
|
+
// CONFIGURABLE — see
|
|
22
|
+
// `getExemplarFeature`/`resolveExemplarNames` below — defaulting to `home` so
|
|
23
|
+
// every ledger written before this config key existed keeps meaning what it
|
|
24
|
+
// meant. The registry is recomputed on every call — it reflects the tree as it
|
|
25
|
+
// stands, never a stale snapshot.
|
|
26
|
+
// 2. STATE (`loadApprovals`/`saveApprovals`) — qa/approvals.json, the human's
|
|
27
|
+
// decisions: { artifact, status, hash, approvedAt, mode?, reopenedAt?, via?,
|
|
28
|
+
// reason? } plus the top-level `exemplarFeature` config key. Absent or corrupt is TOLERATED
|
|
29
|
+
// (treated as empty / all-unreviewed / default exemplar) — this ledger must
|
|
30
|
+
// never crash the verify lane or the stamper.
|
|
31
|
+
//
|
|
32
|
+
// Ledger migration note (architecture-document-standard.md §4.4): there is no
|
|
33
|
+
// schema-version bump or migration step anywhere in this file today (schema
|
|
34
|
+
// stays `cmp-approvals/1`, additive-only — see GENESIS-FLOW-DESIGN.md §2's
|
|
35
|
+
// express-lane note) — a widened hash BASIS (e.g. the `architecture` artifact
|
|
36
|
+
// growing from spec-only to spec+stripped-doc) is handled the same honest way
|
|
37
|
+
// every other content change is: `resolveArtifactStatus` recomputes on every
|
|
38
|
+
// read and compares against the STORED hash. An approval recorded under the
|
|
39
|
+
// old (narrower) basis simply stops matching the new recompute the first time
|
|
40
|
+
// it's read after this change ships, and correctly reports
|
|
41
|
+
// "changed-since-approval" — never a silent, un-re-earned "approved". This is
|
|
42
|
+
// not a special case: it is the SAME mechanism that already invalidates an
|
|
43
|
+
// approval when the governed files themselves change; widening what counts as
|
|
44
|
+
// "the governed files" for one artifact is just another such change. No
|
|
45
|
+
// separate migration code path exists or is needed.
|
|
46
|
+
// 3. The GATE (`evaluateApprovalsGate`) — combines registry + state into one
|
|
47
|
+
// per-artifact status (unreviewed / approved / changed-since-approval /
|
|
48
|
+
// reopened) and one aggregate verdict (PASS/FAIL/SKIP) for the verify-lane step
|
|
49
|
+
// to report. `reopened` behaves like `unreviewed` for the gate (SKIP-warn,
|
|
50
|
+
// non-blocking) — sanctioned redesign is never drift.
|
|
51
|
+
//
|
|
52
|
+
// Consumers: qa/approve.mjs (the CLI — thin shell over this file), qa/verify.mjs
|
|
53
|
+
// (the `approvals` gate), qa/scaffold-feature.mjs (seeds a new feature's spec as
|
|
54
|
+
// unreviewed, and resolves its clone-FROM exemplar through `resolveExemplarNames`).
|
|
55
|
+
// The console (inspector/mcp/src/lib/approvals-bridge.mjs) calls this same library.
|
|
56
|
+
|
|
57
|
+
import { createHash } from "node:crypto";
|
|
58
|
+
import fs from "node:fs";
|
|
59
|
+
import path from "node:path";
|
|
60
|
+
|
|
61
|
+
import { ARCH_DOC_REL_PATH, stripGeneratedSections } from "./arch-doc.mjs";
|
|
62
|
+
import { deriveAllFeatures, deriveFeatureStatus, listFeatureBriefs, parseFeatureBlock, stripFeatureBlock } from "./feature-brief.mjs";
|
|
63
|
+
|
|
64
|
+
export const APPROVALS_REL_PATH = "qa/approvals.json";
|
|
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";
|
|
76
|
+
|
|
77
|
+
// Kotlin source-set roots, relative to project root — mirrors qa/scaffold-feature.mjs's
|
|
78
|
+
// SRC() helper (composeApp/src/<sourceSet>/kotlin/<packageDir>).
|
|
79
|
+
const KOTLIN_SOURCE_SETS = {
|
|
80
|
+
commonMain: "composeApp/src/commonMain/kotlin",
|
|
81
|
+
commonTest: "composeApp/src/commonTest/kotlin",
|
|
82
|
+
desktopTest: "composeApp/src/desktopTest/kotlin",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// The canonical 11-file EXEMPLAR SHAPE (10 kotlin files + 1 spec), parametrized by
|
|
86
|
+
// the exemplar's own names — F (PascalCase feature, e.g. "Home"), f (lowercase
|
|
87
|
+
// package segment, e.g. "home"), E (PascalCase entity, e.g. "Item"). This is the
|
|
88
|
+
// SAME shape qa/scaffold-feature.mjs's ALL_FILES clones FROM (GENESIS-FLOW-DESIGN.md
|
|
89
|
+
// §1's "configurable exemplar") — the stamper imports this exact function so the
|
|
90
|
+
// clone-source list and the governed-artifact list can never drift from each other
|
|
91
|
+
// (single source of truth, not a parallel copy to keep in sync by hand).
|
|
92
|
+
// @param {string} F PascalCase feature name (e.g. "Home", "Favorites")
|
|
93
|
+
// @param {string} f lowercase package-segment name (e.g. "home", "favorites")
|
|
94
|
+
// @param {string} E PascalCase entity name (e.g. "Item", "Favorite")
|
|
95
|
+
// @returns {Array<{sourceSet: string, rel: string}>}
|
|
96
|
+
export function exemplarKotlinFileSet(F, f, E) {
|
|
97
|
+
return [
|
|
98
|
+
{ sourceSet: "commonMain", rel: `domain/model/${E}.kt` },
|
|
99
|
+
{ sourceSet: "commonMain", rel: `domain/repository/${E}Repository.kt` },
|
|
100
|
+
{ sourceSet: "commonMain", rel: `domain/usecase/Get${E}sUseCase.kt` },
|
|
101
|
+
{ sourceSet: "commonMain", rel: `data/remote/${E}RepositoryImpl.kt` },
|
|
102
|
+
{ sourceSet: "commonTest", rel: `testing/fakes/Fake${E}Repository.kt` },
|
|
103
|
+
{ sourceSet: "commonMain", rel: `presentation/${f}/${F}Screen.kt` },
|
|
104
|
+
{ sourceSet: "commonMain", rel: `presentation/${f}/${F}ViewModel.kt` },
|
|
105
|
+
{ sourceSet: "commonTest", rel: `presentation/${f}/${F}ViewModelTest.kt` },
|
|
106
|
+
{ sourceSet: "desktopTest", rel: `presentation/${f}/${F}ScreenTest.kt` },
|
|
107
|
+
{ sourceSet: "desktopTest", rel: `presentation/${f}/${F}GoldenTreeTest.kt` },
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Naive de-pluralization, shared verbatim with qa/scaffold-feature.mjs's own
|
|
112
|
+
// entity-name default (a feature stamped without `--entity` gets this exact
|
|
113
|
+
// guess). Exported so both the stamper (deriving a NEW feature's entity) and this
|
|
114
|
+
// registry (guessing a CONFIGURED exemplar's entity from its feature name alone —
|
|
115
|
+
// see resolveExemplarNames) apply the identical heuristic. Unreliable for
|
|
116
|
+
// irregular nouns by design (the skill surfaces the guess for human override at
|
|
117
|
+
// stamp time); a wrong guess here simply fails to resolve files, which is refused
|
|
118
|
+
// (never fabricated), not silently wrong.
|
|
119
|
+
export function defaultEntityName(feature) {
|
|
120
|
+
if (feature.endsWith("ies") && feature.length > 3) return `${feature.slice(0, -3)}y`;
|
|
121
|
+
if (feature.endsWith("s") && !feature.endsWith("ss")) return feature.slice(0, -1);
|
|
122
|
+
return feature;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function toPascalCase(f) {
|
|
126
|
+
return f.charAt(0).toUpperCase() + f.slice(1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function toUpperSnake(F) {
|
|
130
|
+
return F.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve the CONFIGURED exemplar's names — the ones the exemplar-feature/
|
|
135
|
+
* exemplar-spec governed artifacts (and qa/scaffold-feature.mjs's clone source)
|
|
136
|
+
* are built from.
|
|
137
|
+
*
|
|
138
|
+
* `home` (the default, and the only exemplar that predates configurability) is a
|
|
139
|
+
* hardcoded exception: its entity is `Item`, not derivable from `Home` by
|
|
140
|
+
* `defaultEntityName` (which would naively guess `Home`). Every OTHER exemplar is
|
|
141
|
+
* itself a feature that was stamped by qa/scaffold-feature.mjs, so its entity
|
|
142
|
+
* followed defaultEntityName(F) UNLESS it was stamped with an explicit `--entity`
|
|
143
|
+
* override — a choice this config key cannot see. In that mismatch case the guess
|
|
144
|
+
* is wrong and the file set simply fails to resolve (0 or partial files), which
|
|
145
|
+
* `resolveArtifactStatus`/`approveArtifact` already refuse rather than fabricate —
|
|
146
|
+
* the correct failure mode, not a special case to add here.
|
|
147
|
+
* @param {string} root
|
|
148
|
+
* @returns {{f: string, F: string, F_UPPER: string, E: string}}
|
|
149
|
+
*/
|
|
150
|
+
export function resolveExemplarNames(root) {
|
|
151
|
+
const f = getExemplarFeature(root);
|
|
152
|
+
const F = toPascalCase(f);
|
|
153
|
+
const F_UPPER = toUpperSnake(F);
|
|
154
|
+
const E = f === "home" ? "Item" : defaultEntityName(F);
|
|
155
|
+
return { f, F, F_UPPER, E };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Backward-compatible constants for the DEFAULT (`home`) exemplar — kept exported
|
|
159
|
+
// because they describe the shipped template's own exemplar shape independent of
|
|
160
|
+
// any project's configuration, and because they're the fixture the "stamping from
|
|
161
|
+
// home must be byte-identical" pin (test/genesis-flow.test.mjs) anchors to.
|
|
162
|
+
export const EXEMPLAR_FEATURE_KOTLIN_FILES = exemplarKotlinFileSet("Home", "home", "Item");
|
|
163
|
+
export const EXEMPLAR_SPEC_REL = "specs/home.spec.md";
|
|
164
|
+
export const ARCHITECTURE_SPEC_REL = "specs/app-base.spec.md";
|
|
165
|
+
export const INTENT_REL = "specs/intent.md";
|
|
166
|
+
|
|
167
|
+
// ── Package resolution ───────────────────────────────────────────────────────
|
|
168
|
+
// Mirrors qa/scaffold-feature.mjs's resolvePackage() primary path (the
|
|
169
|
+
// composeApp/build.gradle.kts namespace). Unlike the stamper, this NEVER dies —
|
|
170
|
+
// an unresolved package means the kotlin-rooted artifacts resolve to zero files.
|
|
171
|
+
// Zero resolution never CRASHES anything (the lane and the stamper stay up),
|
|
172
|
+
// but it is NOT benign for decisions: an approval over zero files would be the
|
|
173
|
+
// empty-input sha256 attesting nothing — a silent vacuous PASS, the exact
|
|
174
|
+
// failure mode this harness exists to kill (evidence must attest execution).
|
|
175
|
+
// So: approveArtifact REFUSES zero-file artifacts, and an already-approved
|
|
176
|
+
// artifact whose files stop resolving goes to changed-since-approval (FAIL),
|
|
177
|
+
// never PASS.
|
|
178
|
+
//
|
|
179
|
+
// IMPORTANT: detect "unresolved" by TOKEN SHAPE (`/^__[A-Z_]+__$/`), never by
|
|
180
|
+
// comparing against the literal string "__PACKAGE__". This file ships through
|
|
181
|
+
// the SAME scaffold pipeline that resolves that token — a literal comparison
|
|
182
|
+
// string is itself blindly text-substituted at stamp time (`replaceContents`
|
|
183
|
+
// does a global `"__PACKAGE__" -> config.package` replace over every template
|
|
184
|
+
// file's content, this one included), which would silently rewrite the
|
|
185
|
+
// sentinel into the real package and make the check always fail. A shape
|
|
186
|
+
// regex never spells the token out, so the pipeline has nothing to match.
|
|
187
|
+
const UNRESOLVED_TOKEN_RE = /^__[A-Z_]+__$/;
|
|
188
|
+
|
|
189
|
+
function resolvePackageDir(root) {
|
|
190
|
+
const gradleFile = path.join(root, "composeApp", "build.gradle.kts");
|
|
191
|
+
if (!fs.existsSync(gradleFile)) return null;
|
|
192
|
+
let contents;
|
|
193
|
+
try {
|
|
194
|
+
contents = fs.readFileSync(gradleFile, "utf8");
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const m = contents.match(/namespace\s*=\s*"([^"]+)"/);
|
|
199
|
+
if (!m || UNRESOLVED_TOKEN_RE.test(m[1])) return null;
|
|
200
|
+
return m[1].split(".").join("/");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function kotlinFile(root, sourceSet, rel) {
|
|
204
|
+
const packageDir = resolvePackageDir(root);
|
|
205
|
+
if (!packageDir) return null;
|
|
206
|
+
return path.posix.join(KOTLIN_SOURCE_SETS[sourceSet], packageDir, rel);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Is the project's package resolvable at all? False in the raw template (the
|
|
211
|
+
* namespace is still a placeholder token) and in any pre-stamp tree — the tell
|
|
212
|
+
* that this is not a generated project. The approve CLI refuses to WRITE
|
|
213
|
+
* approvals in such a tree (recording decisions against a template pollutes
|
|
214
|
+
* the template itself); read-only status remains available.
|
|
215
|
+
* @param {string} root
|
|
216
|
+
* @returns {boolean}
|
|
217
|
+
*/
|
|
218
|
+
export function isPackageResolvable(root) {
|
|
219
|
+
return resolvePackageDir(root) !== null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Components glob ─────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Sorted list of `presentation/components/*.kt` files under the resolved
|
|
226
|
+
* package, non-recursive (GENESIS-FLOW-DESIGN.md §1's `components` artifact — the
|
|
227
|
+
* component vocabulary conversation 3 approves). Package-unresolvable or a
|
|
228
|
+
* missing/empty directory both yield `[]` — resolveArtifactStatus/approveArtifact
|
|
229
|
+
* already treat a 0-file artifact as unresolvable ("a components glob matching
|
|
230
|
+
* zero files is unresolvable, not approvable-empty" — §1), so no special-casing
|
|
231
|
+
* is needed here beyond returning the honest (possibly empty) list.
|
|
232
|
+
* @param {string} root
|
|
233
|
+
* @returns {string[]} root-relative paths, sorted
|
|
234
|
+
*/
|
|
235
|
+
function listComponentFiles(root) {
|
|
236
|
+
const dirRel = kotlinFile(root, "commonMain", "presentation/components");
|
|
237
|
+
if (!dirRel) return [];
|
|
238
|
+
let entries;
|
|
239
|
+
try {
|
|
240
|
+
entries = fs.readdirSync(path.join(root, dirRel), { withFileTypes: true });
|
|
241
|
+
} catch {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
return entries
|
|
245
|
+
.filter((e) => e.isFile() && e.name.endsWith(".kt"))
|
|
246
|
+
.map((e) => path.posix.join(dirRel, e.name))
|
|
247
|
+
.sort((a, b) => a.localeCompare(b));
|
|
248
|
+
}
|
|
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
|
+
|
|
285
|
+
// ── Registry ─────────────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The governed-artifact registry, resolved against the project at `root` right
|
|
289
|
+
* now. GENESIS-FLOW-DESIGN.md §1 definition order — two ordering principles,
|
|
290
|
+
* one per artifact kind (the dogfooding-run correction):
|
|
291
|
+
* BEHAVIORAL artifacts are SPEC-FIRST — the exemplar's clauses are proposed
|
|
292
|
+
* and human-confirmed BEFORE the slice is built (exemplar-spec precedes
|
|
293
|
+
* exemplar-feature, matching add-feature's discipline).
|
|
294
|
+
* VISUAL artifacts are UI-FIRST — the design system and component vocabulary
|
|
295
|
+
* are distilled FROM the real screens, so they lock AFTER the exemplar
|
|
296
|
+
* exists (a provisional palette carries the build until then).
|
|
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.
|
|
302
|
+
*
|
|
303
|
+
* `complete: false` marks an artifact whose kotlin-rooted files could NOT be
|
|
304
|
+
* resolved (unresolvable package — raw template / pre-stamp tree). Such an
|
|
305
|
+
* artifact's `files` list is empty or partial (spec files only), so hashing it
|
|
306
|
+
* would attest nothing (or only a fraction) of what the artifact governs —
|
|
307
|
+
* approveArtifact refuses it, and the status surfaces treat it as unresolvable.
|
|
308
|
+
* @param {string} root absolute path to the project root
|
|
309
|
+
* @returns {Array<{id: string, label: string, files: string[], complete: boolean}>}
|
|
310
|
+
*/
|
|
311
|
+
export function listGovernedArtifacts(root) {
|
|
312
|
+
const artifacts = [];
|
|
313
|
+
const packageResolved = resolvePackageDir(root) !== null;
|
|
314
|
+
|
|
315
|
+
artifacts.push({
|
|
316
|
+
id: "intent",
|
|
317
|
+
label: `Intent brief (${INTENT_REL})`,
|
|
318
|
+
files: [INTENT_REL],
|
|
319
|
+
complete: true,
|
|
320
|
+
});
|
|
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
|
+
|
|
340
|
+
artifacts.push({
|
|
341
|
+
id: "architecture",
|
|
342
|
+
label: `Architecture + structure (${ARCHITECTURE_SPEC_REL} + ${ARCH_DOC_REL_PATH}, generated sections stripped)`,
|
|
343
|
+
// Hashed via hashArchitectureArtifact (spec bytes + stripped-doc content),
|
|
344
|
+
// NOT the generic hashArtifactFiles — this list is still the artifact's
|
|
345
|
+
// expected-files surface (missing-file refusal messages, "what governs
|
|
346
|
+
// this" bookkeeping), just not what gets hashed raw. See computeArtifactHash.
|
|
347
|
+
files: [ARCHITECTURE_SPEC_REL, ARCH_DOC_REL_PATH],
|
|
348
|
+
complete: true,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const { f: exemplarF, F: exemplarF_Pascal, E: exemplarE } = resolveExemplarNames(root);
|
|
352
|
+
const exemplarSpecRel = `specs/${exemplarF}.spec.md`;
|
|
353
|
+
const exemplarKotlinFiles = exemplarKotlinFileSet(exemplarF_Pascal, exemplarF, exemplarE);
|
|
354
|
+
|
|
355
|
+
// Spec-first: the exemplar's behavior clauses are confirmed BEFORE the slice
|
|
356
|
+
// is built — the definition order is the discipline, not just a display order.
|
|
357
|
+
artifacts.push({
|
|
358
|
+
id: "exemplar-spec",
|
|
359
|
+
label: `Exemplar spec (${exemplarSpecRel})`,
|
|
360
|
+
files: [exemplarSpecRel],
|
|
361
|
+
complete: true,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
artifacts.push({
|
|
365
|
+
id: "exemplar-feature",
|
|
366
|
+
label: `Exemplar feature (${exemplarF} — the file set the stamper clones)`,
|
|
367
|
+
files: [
|
|
368
|
+
...exemplarKotlinFiles.map((f) => kotlinFile(root, f.sourceSet, f.rel)).filter(Boolean),
|
|
369
|
+
exemplarSpecRel,
|
|
370
|
+
],
|
|
371
|
+
complete: packageResolved,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// UI-first: the design system LOCKS on the real exemplar (candidates render on
|
|
375
|
+
// real screens, never stubs), and the component vocabulary is DISTILLED from
|
|
376
|
+
// those screens — both follow the exemplar in the definition order.
|
|
377
|
+
artifacts.push({
|
|
378
|
+
id: "design-system",
|
|
379
|
+
label: "Design system (presentation/theme/Theme.kt, Tokens.kt)",
|
|
380
|
+
files: [
|
|
381
|
+
kotlinFile(root, "commonMain", "presentation/theme/Theme.kt"),
|
|
382
|
+
kotlinFile(root, "commonMain", "presentation/theme/Tokens.kt"),
|
|
383
|
+
].filter(Boolean),
|
|
384
|
+
complete: packageResolved,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
artifacts.push({
|
|
388
|
+
id: "components",
|
|
389
|
+
label: "Components (presentation/components/*.kt)",
|
|
390
|
+
files: listComponentFiles(root),
|
|
391
|
+
complete: packageResolved,
|
|
392
|
+
});
|
|
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
|
+
|
|
420
|
+
const specsDir = path.join(root, "specs");
|
|
421
|
+
if (fs.existsSync(specsDir)) {
|
|
422
|
+
const featureSpecs = fs
|
|
423
|
+
.readdirSync(specsDir)
|
|
424
|
+
.filter((f) => f.endsWith(".spec.md") && f !== "app-base.spec.md" && f !== `${exemplarF}.spec.md`)
|
|
425
|
+
.sort((a, b) => a.localeCompare(b));
|
|
426
|
+
for (const file of featureSpecs) {
|
|
427
|
+
const name = file.slice(0, -".spec.md".length);
|
|
428
|
+
artifacts.push({
|
|
429
|
+
id: `feature-spec:${name}`,
|
|
430
|
+
label: `Feature spec (specs/${file})`,
|
|
431
|
+
files: [`specs/${file}`],
|
|
432
|
+
complete: true,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return artifacts;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ── Hashing (mirrors qa/lib/inputs-hash.mjs's computeInputsHash style) ───────
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* sha256 over the sorted `(path, sha256(content))` list of `relFiles` that
|
|
444
|
+
* currently exist under `root`. Deterministic; missing files are reported, not
|
|
445
|
+
* fatal — the hash is simply over what's present.
|
|
446
|
+
* @param {string} root
|
|
447
|
+
* @param {string[]} relFiles
|
|
448
|
+
* @returns {{ hash: string, fileCount: number, missing: string[] }}
|
|
449
|
+
*/
|
|
450
|
+
export function hashArtifactFiles(root, relFiles) {
|
|
451
|
+
// Code-unit sort (default String sort), NOT localeCompare: the hash depends
|
|
452
|
+
// on iteration order and ICU collation varies with the machine's locale —
|
|
453
|
+
// an approval recorded on one machine must verify on every other.
|
|
454
|
+
const files = [...new Set(relFiles)].sort();
|
|
455
|
+
const present = [];
|
|
456
|
+
const missing = [];
|
|
457
|
+
for (const relPath of files) {
|
|
458
|
+
try {
|
|
459
|
+
if (fs.statSync(path.join(root, relPath)).isFile()) {
|
|
460
|
+
present.push(relPath);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
/* fall through to missing */
|
|
465
|
+
}
|
|
466
|
+
missing.push(relPath);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const overall = createHash("sha256");
|
|
470
|
+
for (const relPath of present) {
|
|
471
|
+
const bytes = fs.readFileSync(path.join(root, relPath));
|
|
472
|
+
const fileSha = createHash("sha256").update(bytes).digest("hex");
|
|
473
|
+
overall.update(`${relPath}\0${fileSha}\n`);
|
|
474
|
+
}
|
|
475
|
+
return { hash: overall.digest("hex"), fileCount: present.length, missing };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* The `architecture` artifact's hash basis (docs/proposals/architecture-document-
|
|
480
|
+
* standard.md §4.4): `${ARCHITECTURE_SPEC_REL}`'s raw bytes + `${ARCH_DOC_REL_PATH}`
|
|
481
|
+
* with every `cmp:generated` marker's BODY stripped — `arch-doc.mjs`'s
|
|
482
|
+
* `stripGeneratedSections` is the ONE definition of "generated" for that doc,
|
|
483
|
+
* reused here rather than forked, so a new/changed marker id is understood
|
|
484
|
+
* identically by the regenerator and this hash.
|
|
485
|
+
*
|
|
486
|
+
* The doc's content is also normalized `\r\n` -> `\n` before hashing (spec
|
|
487
|
+
* files are hashed as raw bytes like every other artifact — a checkout-induced
|
|
488
|
+
* EOL difference in a Markdown prose doc is exactly the kind of accident that
|
|
489
|
+
* must never read as "authored drift", but the .spec.md files this repo ships
|
|
490
|
+
* are LF already and their exact bytes are what the human actually reviewed).
|
|
491
|
+
*
|
|
492
|
+
* Same row-hash shape as `hashArtifactFiles` (`path\0sha256(bytes)\n`, rows
|
|
493
|
+
* sorted by path) so the two schemes read the same way in a hex dump — this is
|
|
494
|
+
* a SEPARATE function (not a generic `hashArtifactFiles` call) only because the
|
|
495
|
+
* doc's bytes must be transformed (stripped + normalized) before hashing, never
|
|
496
|
+
* hashed raw.
|
|
497
|
+
*
|
|
498
|
+
* Regenerating a marker section (`node qa/arch-doc.mjs`) changes only the
|
|
499
|
+
* stripped-away body, so this hash does not move. Editing authored prose
|
|
500
|
+
* anywhere else in the doc — including adding, removing, or reordering a
|
|
501
|
+
* `cmp:generated` marker itself (structural, not generated content) — changes
|
|
502
|
+
* it, same as editing the spec.
|
|
503
|
+
* @param {string} root
|
|
504
|
+
* @returns {{ hash: string, fileCount: number, missing: string[] }}
|
|
505
|
+
*/
|
|
506
|
+
export function hashArchitectureArtifact(root) {
|
|
507
|
+
const rows = [];
|
|
508
|
+
const missing = [];
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
const specBytes = fs.readFileSync(path.join(root, ARCHITECTURE_SPEC_REL));
|
|
512
|
+
rows.push([ARCHITECTURE_SPEC_REL, createHash("sha256").update(specBytes).digest("hex")]);
|
|
513
|
+
} catch {
|
|
514
|
+
missing.push(ARCHITECTURE_SPEC_REL);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
const docRaw = fs.readFileSync(path.join(root, ARCH_DOC_REL_PATH), "utf8");
|
|
519
|
+
// Normalize line endings BEFORE stripping: the marker grammar
|
|
520
|
+
// (`arch-doc.mjs`'s MARKER_BLOCK_RE) matches a literal `\n` right after
|
|
521
|
+
// `-->`, so CRLF content would fail to match at all and nothing would be
|
|
522
|
+
// stripped — normalize first so the strip is EOL-independent, same as the
|
|
523
|
+
// hash itself.
|
|
524
|
+
const docNormalized = docRaw.replace(/\r\n/g, "\n");
|
|
525
|
+
const docStripped = stripGeneratedSections(docNormalized);
|
|
526
|
+
rows.push([ARCH_DOC_REL_PATH, createHash("sha256").update(docStripped, "utf8").digest("hex")]);
|
|
527
|
+
} catch {
|
|
528
|
+
missing.push(ARCH_DOC_REL_PATH);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Code-unit sort for the same reason as hashArtifactFiles: hash order must
|
|
532
|
+
// not depend on the machine's locale.
|
|
533
|
+
rows.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
534
|
+
const overall = createHash("sha256");
|
|
535
|
+
for (const [relPath, fileSha] of rows) {
|
|
536
|
+
overall.update(`${relPath}\0${fileSha}\n`);
|
|
537
|
+
}
|
|
538
|
+
return { hash: overall.digest("hex"), fileCount: rows.length, missing };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Recompute one artifact's hash — `hashArchitectureArtifact` for `architecture`
|
|
543
|
+
* (spec + stripped doc, its own basis), `hashArtifactFiles(root, artifact.files)`
|
|
544
|
+
* for every other artifact (raw file bytes). The ONE dispatch point
|
|
545
|
+
* `resolveArtifactStatus`/`approveArtifact` both call, so the two never
|
|
546
|
+
* disagree about what "the architecture artifact's hash" means.
|
|
547
|
+
* @param {string} root
|
|
548
|
+
* @param {{id: string, files: string[]}} artifact
|
|
549
|
+
* @returns {{ hash: string, fileCount: number, missing: string[] }}
|
|
550
|
+
*/
|
|
551
|
+
function computeArtifactHash(root, artifact) {
|
|
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 };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ── State (qa/approvals.json) ─────────────────────────────────────────────────
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Load qa/approvals.json. Absent or corrupt (unparsable JSON, wrong shape) is
|
|
601
|
+
* TOLERATED — returns the empty state, which resolves every artifact as
|
|
602
|
+
* "unreviewed" and every exemplar lookup to the default (`home`). Never throws.
|
|
603
|
+
*
|
|
604
|
+
* `exemplarFeature` is `undefined` when the key is absent or not a non-empty
|
|
605
|
+
* string — callers resolve the default (`getExemplarFeature`), never this
|
|
606
|
+
* function directly, so every ledger written before this key existed keeps
|
|
607
|
+
* meaning what it meant (GENESIS-FLOW-DESIGN.md §1).
|
|
608
|
+
* @param {string} root
|
|
609
|
+
* @returns {{ schema: string, artifacts: Array<{artifact: string, status: string, hash: (string|null), approvedAt: (string|null), mode?: string, reopenedAt?: string}>, exemplarFeature: (string|undefined) }}
|
|
610
|
+
*/
|
|
611
|
+
export function loadApprovals(root) {
|
|
612
|
+
const empty = { schema: APPROVALS_SCHEMA, artifacts: [], exemplarFeature: undefined };
|
|
613
|
+
const p = path.join(root, APPROVALS_REL_PATH);
|
|
614
|
+
let raw;
|
|
615
|
+
try {
|
|
616
|
+
raw = fs.readFileSync(p, "utf8");
|
|
617
|
+
} catch {
|
|
618
|
+
return empty;
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
const parsed = JSON.parse(raw);
|
|
622
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.artifacts)) return empty;
|
|
623
|
+
const exemplarFeature =
|
|
624
|
+
typeof parsed.exemplarFeature === "string" && parsed.exemplarFeature.trim() !== ""
|
|
625
|
+
? parsed.exemplarFeature.trim()
|
|
626
|
+
: undefined;
|
|
627
|
+
return { schema: parsed.schema ?? APPROVALS_SCHEMA, artifacts: parsed.artifacts, exemplarFeature };
|
|
628
|
+
} catch {
|
|
629
|
+
return empty;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Write qa/approvals.json (deterministic key order, trailing newline).
|
|
635
|
+
* `exemplarFeature` is included only when the caller explicitly passes one
|
|
636
|
+
* (undefined is omitted, never written as a literal `null`/`"undefined"`) — every
|
|
637
|
+
* internal transition (approveArtifact, seedUnreviewed, approveAllDefaults,
|
|
638
|
+
* reopenArtifact) reloads and threads the CURRENT value through so a write never
|
|
639
|
+
* silently drops a previously-configured exemplar.
|
|
640
|
+
* @param {string} root
|
|
641
|
+
* @param {{ artifacts: Array<object>, exemplarFeature?: string }} state
|
|
642
|
+
*/
|
|
643
|
+
export function saveApprovals(root, state) {
|
|
644
|
+
const p = path.join(root, APPROVALS_REL_PATH);
|
|
645
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
646
|
+
const out = { schema: APPROVALS_SCHEMA, artifacts: state.artifacts };
|
|
647
|
+
if (state.exemplarFeature !== undefined) out.exemplarFeature = state.exemplarFeature;
|
|
648
|
+
fs.writeFileSync(p, `${JSON.stringify(out, null, 2)}\n`);
|
|
649
|
+
}
|
|
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
|
+
|
|
701
|
+
/**
|
|
702
|
+
* The configured exemplar feature's lowercase name (the package-segment form,
|
|
703
|
+
* e.g. `"home"`, `"favorites"`) — `qa/approvals.json`'s top-level
|
|
704
|
+
* `exemplarFeature` key, defaulting to `"home"` when absent (GENESIS-FLOW-DESIGN.md
|
|
705
|
+
* §1). This is the ONE function both `resolveExemplarNames` (registry) and
|
|
706
|
+
* qa/scaffold-feature.mjs (clone-source resolution) call — never read the raw key
|
|
707
|
+
* directly, so the default lives in exactly one place.
|
|
708
|
+
* @param {string} root
|
|
709
|
+
* @returns {string}
|
|
710
|
+
*/
|
|
711
|
+
export function getExemplarFeature(root) {
|
|
712
|
+
return loadApprovals(root).exemplarFeature ?? "home";
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Seed one artifact as unreviewed if it isn't already recorded. Idempotent —
|
|
717
|
+
* a second call for the same id is a no-op. Used by qa/scaffold-feature.mjs to
|
|
718
|
+
* seed a new feature's spec (create-if-missing, tolerant when absent — this
|
|
719
|
+
* never throws, so a stamp is never blocked by the approvals ledger).
|
|
720
|
+
* @param {string} root
|
|
721
|
+
* @param {string} artifactId
|
|
722
|
+
* @returns {{ added: boolean }}
|
|
723
|
+
*/
|
|
724
|
+
export function seedUnreviewed(root, artifactId) {
|
|
725
|
+
const state = loadApprovals(root);
|
|
726
|
+
if (state.artifacts.some((a) => a.artifact === artifactId)) return { added: false };
|
|
727
|
+
state.artifacts.push({ artifact: artifactId, status: "unreviewed", hash: null, approvedAt: null });
|
|
728
|
+
saveApprovals(root, state);
|
|
729
|
+
return { added: true };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ── Status resolution ─────────────────────────────────────────────────────────
|
|
733
|
+
|
|
734
|
+
function shortHash(hash) {
|
|
735
|
+
return hash ? hash.slice(0, 8) : "none";
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Resolve one artifact's live status: recompute its hash now and compare
|
|
740
|
+
* against the stored record (if any).
|
|
741
|
+
* - no stored record, or stored status !== "approved"/"reopened" -> "unreviewed"
|
|
742
|
+
* - stored status === "reopened" -> "reopened", UNCONDITIONALLY — a reopened
|
|
743
|
+
* artifact never re-derives "changed-since-approval" from further edits (there
|
|
744
|
+
* is no live approval to compare against once reopened; it's fluid again by
|
|
745
|
+
* definition until the next real approveArtifact call). This is the
|
|
746
|
+
* sanctioned-redesign-vs-drift asymmetry the reopen mechanic exists for
|
|
747
|
+
* (GENESIS-FLOW-DESIGN.md §2): only an `approved` artifact can go stale.
|
|
748
|
+
* - approved + hash still matches (over >0 files) -> "approved"
|
|
749
|
+
* - approved + hash no longer matches -> "changed-since-approval"
|
|
750
|
+
* - approved + artifact NOW unresolvable (0 files, or an incomplete kotlin
|
|
751
|
+
* file set) -> "changed-since-approval", UNCONDITIONALLY — even if the
|
|
752
|
+
* stored hash equals the recompute (a hand-written or legacy vacuous
|
|
753
|
+
* approval over the degraded set). An approval that covers none (or only a
|
|
754
|
+
* fraction) of what the artifact governs attests nothing and must never
|
|
755
|
+
* read as PASS.
|
|
756
|
+
* `resolvable` is false when the artifact resolves to 0 files right now OR its
|
|
757
|
+
* file set is incomplete (kotlin roots unresolvable — see listGovernedArtifacts).
|
|
758
|
+
* `mode` (e.g. `"defaults-accepted"`) and `reopenedAt` are surfaced only when the
|
|
759
|
+
* stored record actually carries them — never as an explicit `undefined` key, so
|
|
760
|
+
* structural equality checks against a plain unreviewed/approved status shape
|
|
761
|
+
* still hold.
|
|
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}}
|
|
771
|
+
*/
|
|
772
|
+
export function resolveArtifactStatus(root, artifact, storedRecord) {
|
|
773
|
+
const recomputed = computeArtifactHash(root, artifact);
|
|
774
|
+
const resolvable = recomputed.fileCount > 0 && artifact.complete !== false;
|
|
775
|
+
|
|
776
|
+
if (storedRecord && storedRecord.status === "reopened") {
|
|
777
|
+
return {
|
|
778
|
+
id: artifact.id,
|
|
779
|
+
label: artifact.label,
|
|
780
|
+
status: "reopened",
|
|
781
|
+
hash: recomputed.hash,
|
|
782
|
+
storedHash: storedRecord.hash ?? null,
|
|
783
|
+
approvedAt: storedRecord.approvedAt ?? null,
|
|
784
|
+
fileCount: recomputed.fileCount,
|
|
785
|
+
missing: recomputed.missing,
|
|
786
|
+
resolvable,
|
|
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 } : {}),
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (!storedRecord || storedRecord.status !== "approved") {
|
|
797
|
+
return {
|
|
798
|
+
id: artifact.id,
|
|
799
|
+
label: artifact.label,
|
|
800
|
+
status: "unreviewed",
|
|
801
|
+
hash: recomputed.hash,
|
|
802
|
+
storedHash: null,
|
|
803
|
+
approvedAt: null,
|
|
804
|
+
fileCount: recomputed.fileCount,
|
|
805
|
+
missing: recomputed.missing,
|
|
806
|
+
resolvable,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
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
|
+
}
|
|
830
|
+
return {
|
|
831
|
+
id: artifact.id,
|
|
832
|
+
label: artifact.label,
|
|
833
|
+
status: changed ? "changed-since-approval" : "approved",
|
|
834
|
+
hash: recomputed.hash,
|
|
835
|
+
storedHash: storedRecord.hash,
|
|
836
|
+
approvedAt: storedRecord.approvedAt,
|
|
837
|
+
fileCount: recomputed.fileCount,
|
|
838
|
+
missing: recomputed.missing,
|
|
839
|
+
resolvable,
|
|
840
|
+
...(hashBasis ? { hashBasis } : {}),
|
|
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 } : {}),
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Every governed artifact's live status, right now.
|
|
852
|
+
* @param {string} root
|
|
853
|
+
* @returns {Array<ReturnType<typeof resolveArtifactStatus>>}
|
|
854
|
+
*/
|
|
855
|
+
export function getApprovalStatuses(root) {
|
|
856
|
+
const registry = listGovernedArtifacts(root);
|
|
857
|
+
const state = loadApprovals(root);
|
|
858
|
+
const byId = new Map(state.artifacts.map((a) => [a.artifact, a]));
|
|
859
|
+
return registry.map((artifact) => resolveArtifactStatus(root, artifact, byId.get(artifact.id)));
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// ── Transitions ────────────────────────────────────────────────────────────────
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Record an approval: recompute the artifact's hash now, stamp the time,
|
|
866
|
+
* upsert into qa/approvals.json. A fresh record always REPLACES the stored one
|
|
867
|
+
* wholesale (never merges) — so a real approval on a previously
|
|
868
|
+
* defaults-accepted or reopened artifact automatically clears `mode` and
|
|
869
|
+
* `reopenedAt`, with no separate "clear" step needed.
|
|
870
|
+
*
|
|
871
|
+
* REFUSES an unresolvable artifact — one that resolves to 0 files, or whose
|
|
872
|
+
* kotlin-rooted file set could not be resolved at all (`complete: false`). An
|
|
873
|
+
* approval over 0 files would record the empty-input sha256; an approval over
|
|
874
|
+
* a partial set would attest only a fraction of what the artifact governs.
|
|
875
|
+
* Both are silently vacuous — the exact failure mode this harness exists to
|
|
876
|
+
* kill (evidence must attest execution). Refusal cases: the project package is
|
|
877
|
+
* unresolvable (raw template / pre-stamp tree), the artifact's expected files
|
|
878
|
+
* are all missing on disk, or (a dynamic artifact, e.g. `components`) nothing
|
|
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`.
|
|
885
|
+
* @param {string} root
|
|
886
|
+
* @param {string} artifactId
|
|
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.
|
|
892
|
+
* @returns {{ok: true, artifact: string, hash: string, approvedAt: string, mode?: string} | {ok: false, reason: string}}
|
|
893
|
+
*/
|
|
894
|
+
export function approveArtifact(root, artifactId, options = {}) {
|
|
895
|
+
const registry = listGovernedArtifacts(root);
|
|
896
|
+
const artifact = registry.find((a) => a.id === artifactId);
|
|
897
|
+
if (!artifact) {
|
|
898
|
+
const known = registry.map((a) => a.id).join(", ") || "(none — no governed artifacts resolved in this project)";
|
|
899
|
+
return { ok: false, reason: `unknown artifact "${artifactId}" — valid ids: ${known}` };
|
|
900
|
+
}
|
|
901
|
+
const resolved = computeArtifactHash(root, artifact);
|
|
902
|
+
if (artifact.complete === false) {
|
|
903
|
+
return {
|
|
904
|
+
ok: false,
|
|
905
|
+
reason:
|
|
906
|
+
`cannot approve "${artifactId}" — its file set cannot be fully resolved: the kotlin-rooted files are unresolvable because ` +
|
|
907
|
+
"the project package is not resolvable from composeApp/build.gradle.kts (likely the raw template or a pre-stamp tree — " +
|
|
908
|
+
`run this in a generated project); only ${resolved.fileCount} file(s) resolved. ` +
|
|
909
|
+
"A partial or empty approval is vacuous (it attests nothing for the unresolved files) and is refused.",
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
if (resolved.fileCount === 0) {
|
|
913
|
+
const reason =
|
|
914
|
+
artifact.files.length === 0
|
|
915
|
+
? `cannot approve "${artifactId}" — it resolves to 0 files; nothing currently matches this artifact's pattern (nothing to approve yet). An approval over zero files is vacuous (the empty-input hash attests nothing) and is refused.`
|
|
916
|
+
: `cannot approve "${artifactId}" — it resolves to 0 files; its expected files are all missing on disk: ` +
|
|
917
|
+
`${artifact.files.join(", ")}. An approval over zero files is vacuous (the empty-input hash attests nothing) and is refused.`;
|
|
918
|
+
return { ok: false, reason };
|
|
919
|
+
}
|
|
920
|
+
const state = loadApprovals(root);
|
|
921
|
+
const others = state.artifacts.filter((a) => a.artifact !== artifactId);
|
|
922
|
+
const approvedAt = new Date().toISOString();
|
|
923
|
+
const record = { artifact: artifactId, status: "approved", hash: resolved.hash, approvedAt };
|
|
924
|
+
if (options.mode) record.mode = options.mode;
|
|
925
|
+
if (options.via) record.via = options.via;
|
|
926
|
+
others.push(record);
|
|
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
|
+
});
|
|
935
|
+
return { ok: true, artifact: artifactId, hash: resolved.hash, approvedAt, ...(options.mode ? { mode: options.mode } : {}) };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Express lane (GENESIS-FLOW-DESIGN.md §2): approve every currently-resolvable,
|
|
940
|
+
* not-yet-approved governed artifact in one pass, each stamped
|
|
941
|
+
* `mode: "defaults-accepted"`. An artifact already `"approved"` (real OR a prior
|
|
942
|
+
* defaults-accepted run) is left untouched — the express lane never overwrites a
|
|
943
|
+
* standing approval, shaped or not. Unresolvable artifacts are SKIPPED with the
|
|
944
|
+
* exact refusal `approveArtifact` would have printed (never a silent skip).
|
|
945
|
+
* @param {string} root
|
|
946
|
+
* @returns {{ok: true, approved: string[], skipped: Array<{id: string, reason: string}>}}
|
|
947
|
+
*/
|
|
948
|
+
export function approveAllDefaults(root) {
|
|
949
|
+
const registry = listGovernedArtifacts(root);
|
|
950
|
+
const state = loadApprovals(root);
|
|
951
|
+
const byId = new Map(state.artifacts.map((a) => [a.artifact, a]));
|
|
952
|
+
const approved = [];
|
|
953
|
+
const skipped = [];
|
|
954
|
+
for (const artifact of registry) {
|
|
955
|
+
const live = resolveArtifactStatus(root, artifact, byId.get(artifact.id));
|
|
956
|
+
if (live.status === "approved") continue; // already settled — never overwritten by the express lane
|
|
957
|
+
const result = approveArtifact(root, artifact.id, { mode: "defaults-accepted" });
|
|
958
|
+
if (result.ok) approved.push(artifact.id);
|
|
959
|
+
else skipped.push({ id: artifact.id, reason: result.reason });
|
|
960
|
+
}
|
|
961
|
+
return { ok: true, approved, skipped };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Reopen for redesign (GENESIS-FLOW-DESIGN.md §2): move an `approved` artifact
|
|
966
|
+
* (real or defaults-accepted — both are status `"approved"`) to `"reopened"`,
|
|
967
|
+
* recording `reopenedAt` and clearing any `mode` (a reopened artifact is fluid
|
|
968
|
+
* again, not "the defaults, still"). REFUSES an unknown id, and refuses any
|
|
969
|
+
* artifact whose LIVE status is not `"approved"` — reopening the unreviewed, the
|
|
970
|
+
* already-reopened, or a changed-since-approval artifact is meaningless (there is
|
|
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.
|
|
981
|
+
* @param {string} root
|
|
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.
|
|
987
|
+
* @returns {{ok: true, artifact: string, reopenedAt: string} | {ok: false, reason: string}}
|
|
988
|
+
*/
|
|
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
|
+
}
|
|
999
|
+
const registry = listGovernedArtifacts(root);
|
|
1000
|
+
const artifact = registry.find((a) => a.id === artifactId);
|
|
1001
|
+
if (!artifact) {
|
|
1002
|
+
const known = registry.map((a) => a.id).join(", ") || "(none — no governed artifacts resolved in this project)";
|
|
1003
|
+
return { ok: false, reason: `unknown artifact "${artifactId}" — valid ids: ${known}` };
|
|
1004
|
+
}
|
|
1005
|
+
const state = loadApprovals(root);
|
|
1006
|
+
const stored = state.artifacts.find((a) => a.artifact === artifactId);
|
|
1007
|
+
const live = resolveArtifactStatus(root, artifact, stored);
|
|
1008
|
+
if (live.status !== "approved") {
|
|
1009
|
+
return {
|
|
1010
|
+
ok: false,
|
|
1011
|
+
reason: `cannot reopen "${artifactId}" — it is "${live.status}", not "approved". Only an approved artifact (shaped or defaults-accepted) can be reopened for redesign.`,
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
const others = state.artifacts.filter((a) => a.artifact !== artifactId);
|
|
1015
|
+
const reopenedAt = new Date().toISOString();
|
|
1016
|
+
const record = { artifact: artifactId, status: "reopened", hash: stored.hash, approvedAt: stored.approvedAt, reopenedAt, reason: why };
|
|
1017
|
+
if (options.via) record.via = options.via;
|
|
1018
|
+
others.push(record);
|
|
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
|
+
});
|
|
1027
|
+
// `artifact` is the ID STRING — the same convention approveArtifact returns
|
|
1028
|
+
// (one library, one shape; the console bridge relies on the symmetry).
|
|
1029
|
+
return { ok: true, artifact: artifactId, reopenedAt };
|
|
1030
|
+
}
|
|
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
|
+
|
|
1092
|
+
// ── The verify-lane gate ─────────────────────────────────────────────────────
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* The `approvals` verify-lane gate's pure decision function (qa/verify.mjs
|
|
1096
|
+
* wraps this in the step's name/duration bookkeeping — same split as
|
|
1097
|
+
* compareTokenDrift/qa/lib/token-drift.mjs).
|
|
1098
|
+
*
|
|
1099
|
+
* Aggregate verdict:
|
|
1100
|
+
* - any artifact "changed-since-approval" -> FAIL (names each + the
|
|
1101
|
+
* re-approval command — NEVER names a merely-reopened artifact; see below)
|
|
1102
|
+
* - else any artifact "unreviewed"/"reopened" -> SKIP (warns, non-blocking)
|
|
1103
|
+
* - else (all approved + matching) -> PASS
|
|
1104
|
+
*
|
|
1105
|
+
* The sanctioned-redesign-vs-drift asymmetry (GENESIS-FLOW-DESIGN.md §2) lives
|
|
1106
|
+
* right here: `reopened` is grouped with `unreviewed` as non-blocking pending
|
|
1107
|
+
* work, `changed-since-approval` is checked FIRST and returns immediately — so a
|
|
1108
|
+
* run with one reopened artifact and one genuinely drifted (changed-since-
|
|
1109
|
+
* approval) artifact FAILs, and the FAIL reason names only the drifted one.
|
|
1110
|
+
* @param {string} root
|
|
1111
|
+
* @returns {{verdict: "PASS"|"FAIL"|"SKIP", reason: (string|undefined), statuses: Array<object>}}
|
|
1112
|
+
*/
|
|
1113
|
+
export function evaluateApprovalsGate(root) {
|
|
1114
|
+
const statuses = getApprovalStatuses(root);
|
|
1115
|
+
const mismatched = statuses.filter((s) => s.status === "changed-since-approval");
|
|
1116
|
+
const pending = statuses.filter((s) => s.status === "unreviewed" || s.status === "reopened");
|
|
1117
|
+
|
|
1118
|
+
if (mismatched.length > 0) {
|
|
1119
|
+
const lines = ["Approval invalidated — a governed artifact changed after sign-off:"];
|
|
1120
|
+
for (const s of mismatched) {
|
|
1121
|
+
if (!s.resolvable) {
|
|
1122
|
+
lines.push(
|
|
1123
|
+
` [${s.id}] ${s.label} — approved at ${shortHash(s.storedHash)}, but its files no longer fully resolve (${s.fileCount} present — deleted or unresolvable). Restore the files, then re-approve if the change was intended (approval over an unresolved file set is refused).`,
|
|
1124
|
+
);
|
|
1125
|
+
} else {
|
|
1126
|
+
lines.push(
|
|
1127
|
+
` [${s.id}] ${s.label} — approved at ${shortHash(s.storedHash)}, now ${shortHash(s.hash)}. Re-approve: node qa/approve.mjs ${s.id}`,
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
return { verdict: "FAIL", reason: lines.join("\n"), statuses };
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (pending.length > 0) {
|
|
1135
|
+
const lines = ["Governed artifacts awaiting human approval (non-blocking — approve when ready):"];
|
|
1136
|
+
for (const s of pending) {
|
|
1137
|
+
if (s.status === "reopened") {
|
|
1138
|
+
lines.push(
|
|
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}`,
|
|
1140
|
+
);
|
|
1141
|
+
} else if (!s.resolvable) {
|
|
1142
|
+
lines.push(` [${s.id}] ${s.label} — unreviewed, currently unresolvable (${s.fileCount} of expected files resolved) — not approvable in this tree.`);
|
|
1143
|
+
} else {
|
|
1144
|
+
lines.push(` [${s.id}] ${s.label} — unreviewed. Approve: node qa/approve.mjs ${s.id}`);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return { verdict: "SKIP", reason: lines.join("\n"), statuses };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
return { verdict: "PASS", reason: undefined, statuses };
|
|
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
|
+
}
|