create-cmp-cli 0.14.0 → 0.15.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 (39) hide show
  1. package/README.md +51 -353
  2. package/bin/create-cmp.mjs +19 -3
  3. package/llms.txt +3 -3
  4. package/options.schema.json +4 -0
  5. package/package.json +2 -2
  6. package/packages/harness/package.json +11 -3
  7. package/packages/harness/src/lib/harness-lock.mjs +2 -2
  8. package/packages/harness/src/lib/inputs-hash.mjs +1 -1
  9. package/packages/harness/src/lib/receipt-validate.mjs +1 -1
  10. package/packages/harness/src/receipt-check.mjs +1 -1
  11. package/packages/harness/src/verify.mjs +15 -1
  12. package/packages/receipts/package.json +11 -3
  13. package/packages/receipts/src/index.mjs +1 -1
  14. package/packages/receipts/src/inputs-hash.mjs +1 -1
  15. package/packages/receipts/src/receipt-validate.mjs +1 -1
  16. package/src/commands/attach.mjs +250 -0
  17. package/src/commands/create.mjs +13 -2
  18. package/src/commands/harden.mjs +263 -0
  19. package/src/commands/upgrade.mjs +20 -2
  20. package/src/lib/adr-seed.mjs +27 -0
  21. package/src/lib/harness-upgrade.mjs +40 -5
  22. package/src/lib/hooks.mjs +140 -0
  23. package/src/lib/minimal.mjs +130 -0
  24. package/src/lib/toggle.mjs +4 -1
  25. package/src/lib/verify.mjs +6 -1
  26. package/src/scaffold.mjs +18 -1
  27. package/template/.github/workflows/verify.yml +15 -0
  28. package/template/AGENTS.md +57 -8
  29. package/template/CLAUDE.md +49 -0
  30. package/template/CONTRIBUTING.md +13 -0
  31. package/template/README.md +33 -0
  32. package/template/docs/ARCHITECTURE.md +18 -1
  33. package/template/docs/TESTING.md +10 -0
  34. package/template/manifest.json +13 -0
  35. package/template/qa/lib/harness-lock.mjs +2 -2
  36. package/template/qa/lib/inputs-hash.mjs +1 -1
  37. package/template/qa/lib/receipt-validate.mjs +1 -1
  38. package/template/qa/receipt-check.mjs +1 -1
  39. package/template/qa/verify.mjs +15 -1
@@ -5,7 +5,7 @@
5
5
  // there is exactly one definition of the surface and the algorithm.
6
6
  //
7
7
  // SINGLE SOURCE OF TRUTH: packages/receipts/src/inputs-hash.mjs in the
8
- // create-cmp repo (the `cmp-receipts` package). The copy in a generated
8
+ // create-cmp repo (the `@create-cmp/receipts` package). The copy in a generated
9
9
  // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
10
10
  // test/receipts-parity.test.mjs — edit the package source, then run
11
11
  // `node scripts/sync-harness.mjs`.
@@ -6,7 +6,7 @@
6
6
  // tarball rather than the working tree.
7
7
  //
8
8
  // SINGLE SOURCE OF TRUTH: packages/receipts/src/receipt-validate.mjs in the
9
- // create-cmp repo (the `cmp-receipts` package). The copy in a generated
9
+ // create-cmp repo (the `@create-cmp/receipts` package). The copy in a generated
10
10
  // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
11
11
  // test/receipts-parity.test.mjs — edit the package source, then run
12
12
  // `node scripts/sync-harness.mjs`.
@@ -39,7 +39,7 @@ function readStdinJson() {
39
39
  }
40
40
 
41
41
  // The predicate itself lives in qa/lib/receipt-validate.mjs (vendored from the
42
- // cmp-receipts package — one definition everywhere a receipt is judged); this
42
+ // @create-cmp/receipts package — one definition everywhere a receipt is judged); this
43
43
  // CLI only reads the receipt and frames the exit codes.
44
44
  function evaluate() {
45
45
  const receipt = readReceipt(ROOT);
@@ -93,6 +93,14 @@ Flags:
93
93
  with --profile ci (or release) it runs
94
94
  inside the lane and lands on the receipt.
95
95
  Never combinable with --fast
96
+ --no-journal skip the qa/flight-recorder.jsonl append.
97
+ qa/watch.mjs passes this on every
98
+ save-triggered run: journalling each save
99
+ would add hundreds of committed lines a day
100
+ and leave a permanently dirty tree inside
101
+ the loop the recorder exists to observe. The
102
+ gap is disclosed in the retrospective's own
103
+ output
96
104
  --json print the receipt as JSON instead of the
97
105
  human-readable step-by-step log
98
106
  --help, -h print this usage and exit 0 without
@@ -116,7 +124,13 @@ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
116
124
  process.exit(0);
117
125
  }
118
126
 
119
- const RECOGNIZED_FLAGS = new Set(["--profile", "--json", "--fast", "--determinism"]);
127
+ // Every flag this file CONSUMES must be listed here, or the strict check below
128
+ // rejects it. `--no-journal` was consumed but unlisted in 0.13.0, which meant
129
+ // qa/watch.mjs — whose spawn passes exactly these flags — exited 2 on every
130
+ // save without ever running the lane. test/verify-flags.test.mjs now pins
131
+ // consumed ⊆ recognized and watch's spawn ⊆ recognized so the class cannot
132
+ // recur.
133
+ const RECOGNIZED_FLAGS = new Set(["--profile", "--json", "--fast", "--determinism", "--no-journal"]);
120
134
  for (let i = 0; i < rawArgs.length; i += 1) {
121
135
  const arg = rawArgs[i];
122
136
  if (arg === "--profile") {
@@ -1,7 +1,7 @@
1
1
  {
2
- "name": "cmp-receipts",
2
+ "name": "@create-cmp/receipts",
3
3
  "version": "0.1.0",
4
- "description": "Validate create-cmp evidence receipts: inputs-hash recompute, receipt-vs-tree binding, freshness, and execution-plausibility. Dependency-free ESM the single source of truth vendored into every generated project's qa/lib/ and consumed by the hosted receipt check.",
4
+ "description": "Validate create-cmp evidence receipts: inputs-hash recompute, receipt-vs-tree binding, freshness, and execution-plausibility. Dependency-free ESM \u2014 the single source of truth vendored into every generated project's qa/lib/ and consumed by the hosted receipt check.",
5
5
  "type": "module",
6
6
  "main": "src/index.mjs",
7
7
  "exports": {
@@ -14,7 +14,8 @@
14
14
  "README.md"
15
15
  ],
16
16
  "scripts": {
17
- "test": "node --test"
17
+ "test": "node --test",
18
+ "prepublishOnly": "npm test"
18
19
  },
19
20
  "engines": {
20
21
  "node": ">=18"
@@ -32,5 +33,12 @@
32
33
  "type": "git",
33
34
  "url": "git+https://github.com/kvdm-co-pilot/create-cmp.git",
34
35
  "directory": "packages/receipts"
36
+ },
37
+ "homepage": "https://github.com/kvdm-co-pilot/create-cmp/tree/main/packages/receipts#readme",
38
+ "bugs": {
39
+ "url": "https://github.com/kvdm-co-pilot/create-cmp/issues"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
35
43
  }
36
44
  }
@@ -1,4 +1,4 @@
1
- // cmp-receipts — validate create-cmp evidence receipts.
1
+ // @create-cmp/receipts — validate create-cmp evidence receipts.
2
2
  // Single source of truth for the inputs-hash algorithm and the receipt
3
3
  // predicate; vendored byte-identical into every generated project's qa/lib/
4
4
  // and consumed by hosted validators. Dependency-free ESM.
@@ -5,7 +5,7 @@
5
5
  // there is exactly one definition of the surface and the algorithm.
6
6
  //
7
7
  // SINGLE SOURCE OF TRUTH: packages/receipts/src/inputs-hash.mjs in the
8
- // create-cmp repo (the `cmp-receipts` package). The copy in a generated
8
+ // create-cmp repo (the `@create-cmp/receipts` package). The copy in a generated
9
9
  // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
10
10
  // test/receipts-parity.test.mjs — edit the package source, then run
11
11
  // `node scripts/sync-harness.mjs`.
@@ -6,7 +6,7 @@
6
6
  // tarball rather than the working tree.
7
7
  //
8
8
  // SINGLE SOURCE OF TRUTH: packages/receipts/src/receipt-validate.mjs in the
9
- // create-cmp repo (the `cmp-receipts` package). The copy in a generated
9
+ // create-cmp repo (the `@create-cmp/receipts` package). The copy in a generated
10
10
  // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
11
11
  // test/receipts-parity.test.mjs — edit the package source, then run
12
12
  // `node scripts/sync-harness.mjs`.
@@ -0,0 +1,250 @@
1
+ // `create-cmp attach` — M0a: the agent contract for an EXISTING Compose/KMP
2
+ // repo that create-cmp never scaffolded (docs/features/attach-mode.md is the
3
+ // design of record; LADDER §R5).
4
+ //
5
+ // The honesty constraint IS the design: a foreign repo carries none of the
6
+ // stamped machinery our discovery surfaces normally point at, so attach
7
+ // writes ONLY surfaces that are true in any Compose/KMP repo — the engine's
8
+ // own doctor/upgrade at the toolchain walls, and an advisory SessionStart
9
+ // context that says plainly what is and is not wired here. Previews, the
10
+ // lane, and enforcement are named as NOT wired (M0b's staged scope), never
11
+ // faked. Files are never clobbered: an existing differing file keeps its
12
+ // bytes and our content lands beside it as `*.cmp-new` (the same sidecar
13
+ // convention as `upgrade --harness` and `harden`).
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ import { colors, ok, warn, fail } from "../lib/log.mjs";
19
+ import { consent } from "../bootstrap/exec.mjs";
20
+ import { sessionStartCommand } from "../lib/hooks.mjs";
21
+ import { SIDECAR_SUFFIX } from "../lib/harness-upgrade.mjs";
22
+
23
+ const COMPOSE_SIGNALS = [
24
+ "org.jetbrains.compose",
25
+ 'kotlin("multiplatform")',
26
+ "org.jetbrains.kotlin.multiplatform",
27
+ "org.jetbrains.kotlin.plugin.compose",
28
+ ];
29
+
30
+ /** SessionStart context for an attached repo. No apostrophes (shell-quoted). */
31
+ export const ATTACH_SESSION_CONTEXT =
32
+ "This Compose/KMP repo carries the create-cmp attach surfaces: AGENTS.md maps " +
33
+ "toolchain walls to zero-consent commands (doctor diagnoses machine AND project, " +
34
+ "upgrade diffs against a proven-green version set). No verify lane, previews, or " +
35
+ "inspector are wired here — do not assume create-cmp scaffold machinery exists in " +
36
+ "this repo. A new app scaffolded with npx create-cmp-cli would carry all of it.";
37
+
38
+ /** The attach rendering of the symptom table — only rows true in ANY Compose/KMP repo. */
39
+ export function attachAgentsMd() {
40
+ return `# Agent instructions
41
+
42
+ This is an existing Compose/KMP repository with the create-cmp **attach surfaces**
43
+ installed: discovery for the walls every KMP build eventually hits. It was NOT
44
+ scaffolded by create-cmp — none of the generated-project machinery (verify lane,
45
+ headless previews, live inspector, generators) is wired here.
46
+
47
+ ## Stuck? Symptom → command
48
+
49
+ Both commands run from the repo root with nothing to install — \`npx\` fetches on demand.
50
+
51
+ | Symptom | Run |
52
+ |---|---|
53
+ | Build broken, toolchain suspect | \`npx create-cmp-cli doctor --fix\` — diagnoses machine AND project (kotlin↔ksp lockstep, catalog drift); asks before any repair |
54
+ | Dependency versions stale or mismatched | \`npx create-cmp-cli upgrade --dry-run\` — diff against the next proven-green set before touching anything |
55
+
56
+ Famous build failures (kotlin↔KSP mismatch, the KSP2/iOS catch-22, \`SDK location not
57
+ found\`, \`No space left on device\`): \`doctor\` diagnoses all of them offline; the worked
58
+ write-ups live upstream at
59
+ <https://github.com/kvdm-co-pilot/create-cmp/tree/main/docs/errors>.
60
+
61
+ ## What is NOT wired here
62
+
63
+ A create-cmp-scaffolded app additionally carries device-free screen previews (structure
64
+ for the agent, pixels for the human), a live on-device inspector, feature generators,
65
+ and a verify lane with evidence receipts and a machine-checked definition of done. Those
66
+ surfaces presuppose scaffold machinery this repo does not have; attach does not fake
67
+ them. To see the full contract, scaffold with \`npx create-cmp-cli@latest\`.
68
+ `;
69
+ }
70
+
71
+ function attachSettings() {
72
+ return (
73
+ JSON.stringify(
74
+ {
75
+ hooks: {
76
+ SessionStart: [
77
+ {
78
+ matcher: "",
79
+ hooks: [{ type: "command", command: sessionStartCommand(ATTACH_SESSION_CONTEXT) }],
80
+ },
81
+ ],
82
+ },
83
+ },
84
+ null,
85
+ 2
86
+ ) + "\n"
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Classify the target directory. Pure; returns {ok:true} or {ok:false, reason}.
92
+ */
93
+ export function classifyTarget(projectDir) {
94
+ if (fs.existsSync(path.join(projectDir, "create-cmp.json"))) {
95
+ return {
96
+ ok: false,
97
+ reason:
98
+ "this is a create-cmp-stamped app (create-cmp.json present). Attach is for repos " +
99
+ "create-cmp never scaffolded — use `create-cmp harden` (minimal → full) or " +
100
+ "`create-cmp upgrade --harness` (refresh) here instead.",
101
+ };
102
+ }
103
+ const hasGradleSettings =
104
+ fs.existsSync(path.join(projectDir, "settings.gradle.kts")) ||
105
+ fs.existsSync(path.join(projectDir, "settings.gradle"));
106
+ if (!hasGradleSettings) {
107
+ return {
108
+ ok: false,
109
+ reason: "no settings.gradle(.kts) — attach targets an existing Gradle Compose/KMP project.",
110
+ };
111
+ }
112
+ // A Compose/KMP signal anywhere in the repo's build files (two levels deep
113
+ // covers the conventional module layout without a full tree walk).
114
+ const buildFiles = [];
115
+ const collect = (dir, depth) => {
116
+ let entries;
117
+ try {
118
+ entries = fs.readdirSync(dir, { withFileTypes: true });
119
+ } catch {
120
+ return;
121
+ }
122
+ for (const e of entries) {
123
+ if (e.name === "build" || e.name.startsWith(".")) continue;
124
+ const abs = path.join(dir, e.name);
125
+ if (e.isDirectory() && depth > 0) collect(abs, depth - 1);
126
+ else if (/^build\.gradle(\.kts)?$|^libs\.versions\.toml$/.test(e.name)) buildFiles.push(abs);
127
+ }
128
+ };
129
+ collect(projectDir, 2);
130
+ collect(path.join(projectDir, "gradle"), 1);
131
+ const signals = buildFiles.some((f) => {
132
+ try {
133
+ const src = fs.readFileSync(f, "utf8");
134
+ return COMPOSE_SIGNALS.some((s) => src.includes(s));
135
+ } catch {
136
+ return false;
137
+ }
138
+ });
139
+ if (!signals) {
140
+ return {
141
+ ok: false,
142
+ reason:
143
+ "no Compose/KMP signal found in the build files (looked for the Compose or Kotlin " +
144
+ "Multiplatform plugins). Attach only claims to help Compose/KMP repos.",
145
+ };
146
+ }
147
+ return { ok: true };
148
+ }
149
+
150
+ /**
151
+ * Plan (and optionally write) the attach surfaces. Testable core: no exit, no
152
+ * prompt. Each unit lands as one of: current | written | sidecar.
153
+ * @param {object} params
154
+ * @param {string} params.projectDir
155
+ * @param {boolean} [params.apply=false]
156
+ * @returns {{units: Array<{relPath:string, action:string}>, notWired: string[]}}
157
+ */
158
+ export function attachProject({ projectDir, apply = false }) {
159
+ const target = classifyTarget(projectDir);
160
+ if (!target.ok) throw new Error(target.reason);
161
+
162
+ const files = [
163
+ { relPath: "AGENTS.md", content: attachAgentsMd() },
164
+ { relPath: ".claude/settings.json", content: attachSettings() },
165
+ ];
166
+ const units = [];
167
+ for (const { relPath, content } of files) {
168
+ const abs = path.join(projectDir, relPath);
169
+ if (fs.existsSync(abs)) {
170
+ const current = fs.readFileSync(abs, "utf8");
171
+ if (current === content) {
172
+ units.push({ relPath, action: "current" });
173
+ continue;
174
+ }
175
+ if (apply) {
176
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
177
+ fs.writeFileSync(abs + SIDECAR_SUFFIX, content);
178
+ }
179
+ units.push({ relPath, action: "sidecar" });
180
+ continue;
181
+ }
182
+ if (apply) {
183
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
184
+ fs.writeFileSync(abs, content);
185
+ }
186
+ units.push({ relPath, action: "written" });
187
+ }
188
+
189
+ return {
190
+ units,
191
+ notWired: [
192
+ "headless screen previews (PreviewRegistry + renderScreens) — staged M0b, needs a per-repo Gradle wiring design",
193
+ "live on-device inspector — ships with scaffolded apps; not injectable into a foreign build yet",
194
+ "verify lane / evidence receipts / enforcement — the lane addresses the stamped layout by name",
195
+ ],
196
+ };
197
+ }
198
+
199
+ /**
200
+ * `create-cmp attach [dir] [--dry-run] [--yes]`
201
+ */
202
+ export async function runAttach(flags, positional) {
203
+ const targetDir =
204
+ (typeof flags["target-dir"] === "string" && flags["target-dir"]) || positional || ".";
205
+ const projectDir = path.resolve(targetDir);
206
+
207
+ process.stdout.write(
208
+ `\n${colors.bold("create-cmp attach")} — agent contract for an existing Compose/KMP repo\n` +
209
+ ` project: ${colors.cyan(projectDir)}\n\n`
210
+ );
211
+
212
+ let plan;
213
+ try {
214
+ plan = attachProject({ projectDir, apply: false });
215
+ } catch (e) {
216
+ fail(e.message);
217
+ process.exit(1);
218
+ }
219
+
220
+ for (const u of plan.units) {
221
+ if (u.action === "current") ok(`${u.relPath} — already current`);
222
+ if (u.action === "written") process.stdout.write(` will write ${u.relPath}\n`);
223
+ if (u.action === "sidecar")
224
+ warn(`${u.relPath} exists and differs — ours will land as ${u.relPath}${SIDECAR_SUFFIX}`);
225
+ }
226
+ process.stdout.write(`\n${colors.bold("Not wired")} (staged, never faked):\n`);
227
+ for (const n of plan.notWired) process.stdout.write(` · ${n}\n`);
228
+
229
+ const actionable = plan.units.filter((u) => u.action !== "current");
230
+ if (actionable.length === 0) {
231
+ ok("\nNothing to do — attach surfaces are current.");
232
+ process.exit(0);
233
+ }
234
+ if (flags["dry-run"] === true) {
235
+ process.stdout.write(`\n${colors.yellow("Dry run")} — nothing written.\n`);
236
+ process.exit(0);
237
+ }
238
+ const approved = await consent("\nWrite the attach surfaces?", { assumeYes: flags.yes === true });
239
+ if (!approved) {
240
+ process.stdout.write(`${colors.yellow("Not applied")} — re-run with --yes to skip the prompt.\n`);
241
+ process.exit(0);
242
+ }
243
+
244
+ const applied = attachProject({ projectDir, apply: true });
245
+ for (const u of applied.units) {
246
+ if (u.action === "written") ok(`wrote ${u.relPath}`);
247
+ if (u.action === "sidecar") warn(`wrote ${u.relPath}${SIDECAR_SUFFIX} (yours untouched)`);
248
+ }
249
+ process.exit(0);
250
+ }
@@ -74,6 +74,10 @@ function buildConfigFromFlags(flags, positional) {
74
74
  region: typeof flags.region === "string" ? flags.region : "us-central1",
75
75
  themePrefix:
76
76
  typeof flags["theme-prefix"] === "string" ? flags["theme-prefix"] : pascalFromName(name),
77
+ // `--minimal` is the Act 1 door (LADDER §2): same app, tests, previews and
78
+ // advisory hooks, without the verify lane / receipts / governance.
79
+ // `create-cmp harden` installs the subtraction back.
80
+ harness: !flagBool(flags, "minimal", false),
77
81
  platforms: { android: true, ios },
78
82
  firebase: {
79
83
  enabled: firebase,
@@ -95,7 +99,7 @@ function buildConfigFromFlags(flags, positional) {
95
99
  };
96
100
  }
97
101
 
98
- async function interactiveConfig(positional) {
102
+ async function interactiveConfig(positional, flags = {}) {
99
103
  let prompts;
100
104
  try {
101
105
  prompts = (await import("prompts")).default;
@@ -157,6 +161,12 @@ async function interactiveConfig(positional) {
157
161
 
158
162
  const extras = await prompts(
159
163
  [
164
+ {
165
+ type: "confirm",
166
+ name: "harness",
167
+ message: "Verification harness (verify lane, evidence receipts, machine-checked done)?",
168
+ initial: flags.minimal !== true, // --minimal pre-answers the interview question
169
+ },
160
170
  { type: "confirm", name: "room", message: "Room local cache?", initial: true },
161
171
  { type: "confirm", name: "e2e", message: "E2E test harness (Maestro)?", initial: true },
162
172
  {
@@ -193,6 +203,7 @@ async function interactiveConfig(positional) {
193
203
  iosBundleId: base.package,
194
204
  region: base.region,
195
205
  themePrefix: pascalFromName(base.appName),
206
+ harness: extras.harness,
196
207
  platforms: { android: true, ios: base.ios },
197
208
  firebase: { enabled: base.firebase, auth, firestore, storage, functions, fcm },
198
209
  room: extras.room,
@@ -222,7 +233,7 @@ export async function runCreate(flags, positional) {
222
233
 
223
234
  const config = nonInteractive
224
235
  ? buildConfigFromFlags(flags, positional)
225
- : await interactiveConfig(positional);
236
+ : await interactiveConfig(positional, flags);
226
237
 
227
238
  const verify = flagBool(flags, "verify", true);
228
239