create-cmp-cli 0.17.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cmp-cli",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Create production mobile apps (Android + iOS, one Kotlin codebase) with AI — the delivery harness for Compose Multiplatform, the current generation of cross-platform (Google-backed KMP, iOS stable since May 2025). A deterministic, non-interactive generator that scaffolds a green-building app in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Every app carries a device-free UI preview loop (real screens rendered headlessly on save; changed-screen attribution and compile-error surfacing for coding agents, a live gallery for humans) plus agent-first docs (CLAUDE.md + AGENTS.md). Installs the `create-cmp` command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,11 +7,13 @@
7
7
  // [--target-dir <dir>] [--fix]
8
8
  //
9
9
  // --fix applies only SAFE heals (write local.properties from ANDROID_HOME, add
10
- // ksp.useKSP2=true); everything else prints the exact manual step.
10
+ // ksp.useKSP2=true, wire the walk into .claude/settings.json); everything else
11
+ // prints the exact manual step.
11
12
 
12
13
  import fs from "node:fs";
13
14
  import os from "node:os";
14
15
  import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
15
17
 
16
18
  import { flagBool } from "../lib/args.mjs";
17
19
  import { colors, ok } from "../lib/log.mjs";
@@ -71,6 +73,65 @@ function freeDiskBytes() {
71
73
  }
72
74
  }
73
75
 
76
+ /** This engine checkout root — the template it ships is the wiring of record. */
77
+ const ENGINE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
78
+
79
+ /**
80
+ * The walk wiring the CURRENT engine template declares: the statusLine object
81
+ * and the UserPromptSubmit hook groups that invoke qa/walk-status.mjs. Read from
82
+ * template/.claude/settings.json rather than duplicated here, so the heal cannot
83
+ * drift from what a fresh scaffold gets.
84
+ * @returns {{statusLine: object|null, promptSubmit: Array|null}}
85
+ */
86
+ export function templateWalkWiring() {
87
+ const raw = readIfExists(path.join(ENGINE_ROOT, "template", ".claude", "settings.json"));
88
+ if (raw === null) return { statusLine: null, promptSubmit: null };
89
+ try {
90
+ const t = JSON.parse(raw);
91
+ const statusLine = invokesWalk(t.statusLine) ? t.statusLine : null;
92
+ const groups = (t.hooks?.UserPromptSubmit ?? []).filter((g) =>
93
+ (g?.hooks ?? []).some(invokesWalk)
94
+ );
95
+ return { statusLine, promptSubmit: groups.length > 0 ? groups : null };
96
+ } catch {
97
+ return { statusLine: null, promptSubmit: null };
98
+ }
99
+ }
100
+
101
+ /** Does this settings entry ({type, command}) actually run the walk? */
102
+ function invokesWalk(entry) {
103
+ return String(entry?.command ?? "").includes("walk-status.mjs");
104
+ }
105
+
106
+ /**
107
+ * Is the walk installed, and does .claude/settings.json invoke it? The machinery
108
+ * and the wiring live in separately-owned files (lane vs app config), so they can
109
+ * and do come apart — see the walk-wiring finding in project-doctor.mjs.
110
+ */
111
+ export function gatherWalkInputs(projectDir) {
112
+ const scriptPresent = fs.existsSync(path.join(projectDir, "qa", "walk-status.mjs"));
113
+ if (!scriptPresent) return null; // not a walk-carrying lane — nothing to say
114
+ const raw = readIfExists(path.join(projectDir, ".claude", "settings.json"));
115
+ if (raw === null) {
116
+ return { scriptPresent, settingsPresent: false, statusLine: false, promptHook: false };
117
+ }
118
+ let settings;
119
+ try {
120
+ settings = JSON.parse(raw);
121
+ } catch {
122
+ // Unparseable settings invoke nothing, which is exactly what we report.
123
+ return { scriptPresent, settingsPresent: true, statusLine: false, promptHook: false };
124
+ }
125
+ return {
126
+ scriptPresent,
127
+ settingsPresent: true,
128
+ statusLine: invokesWalk(settings.statusLine),
129
+ promptHook: (settings.hooks?.UserPromptSubmit ?? []).some((g) =>
130
+ (g?.hooks ?? []).some(invokesWalk)
131
+ ),
132
+ };
133
+ }
134
+
74
135
  /** Gather filesystem/env inputs for the pure diagnosis. */
75
136
  export function gatherProjectInputs(projectDir) {
76
137
  const toml = readIfExists(path.join(projectDir, "gradle", "libs.versions.toml"));
@@ -112,6 +173,7 @@ export function gatherProjectInputs(projectDir) {
112
173
  freeDiskBytes: freeDiskBytes(),
113
174
  inspectorHits,
114
175
  inspectorCatalog,
176
+ walk: gatherWalkInputs(projectDir),
115
177
  };
116
178
  }
117
179
 
@@ -146,7 +208,7 @@ function scanInspectorSources(projectDir) {
146
208
  }
147
209
 
148
210
  /** Apply the SAFE auto-heals for --fix. Returns ids of findings it fixed. */
149
- function applySafeFixes(projectDir, findings, inputs) {
211
+ export function applySafeFixes(projectDir, findings, inputs) {
150
212
  const fixed = [];
151
213
  for (const f of findings) {
152
214
  if (!f.fix || !f.fix.auto || f.level === "ok") continue;
@@ -164,6 +226,43 @@ function applySafeFixes(projectDir, findings, inputs) {
164
226
  }
165
227
  }
166
228
 
229
+ if (f.id === "walk-wiring") {
230
+ const { statusLine, promptSubmit } = templateWalkWiring();
231
+ const target = path.join(projectDir, ".claude", "settings.json");
232
+ const raw = readIfExists(target);
233
+ let settings = {};
234
+ if (raw !== null) {
235
+ try {
236
+ settings = JSON.parse(raw);
237
+ } catch {
238
+ // Never overwrite settings we could not read — that is the app's file.
239
+ continue;
240
+ }
241
+ }
242
+ let changed = false;
243
+ if (statusLine && !invokesWalk(settings.statusLine)) {
244
+ // Only claim an unclaimed slot: an app that set its OWN status line keeps it.
245
+ if (!settings.statusLine) {
246
+ settings.statusLine = statusLine;
247
+ changed = true;
248
+ }
249
+ }
250
+ if (promptSubmit) {
251
+ settings.hooks = settings.hooks ?? {};
252
+ const existing = settings.hooks.UserPromptSubmit ?? [];
253
+ if (!existing.some((g) => (g?.hooks ?? []).some(invokesWalk))) {
254
+ settings.hooks.UserPromptSubmit = [...existing, ...promptSubmit];
255
+ changed = true;
256
+ }
257
+ }
258
+ if (changed) {
259
+ fs.mkdirSync(path.dirname(target), { recursive: true });
260
+ fs.writeFileSync(target, `${JSON.stringify(settings, null, 2)}\n`);
261
+ ok("--fix: wired the walk into .claude/settings.json (statusLine + UserPromptSubmit)");
262
+ fixed.push(f.id);
263
+ }
264
+ }
265
+
167
266
  if (f.id === "ksp2-flag") {
168
267
  const target = path.join(projectDir, "gradle.properties");
169
268
  const existing = inputs.gradleProperties ?? "";
package/src/lib/hooks.mjs CHANGED
@@ -42,7 +42,10 @@ export function isEnforcementEvent(event) {
42
42
  return ENFORCEMENT_EVENTS.has(event);
43
43
  }
44
44
 
45
- /** Does this hook's command presuppose the verify lane (`qa/`)? */
45
+ /**
46
+ * Does this entry's command presuppose the verify lane (`qa/`)? Takes anything
47
+ * settings.json can hold a command in — a hook, or the top-level `statusLine`.
48
+ */
46
49
  export function referencesLane(hook) {
47
50
  return String(hook?.command ?? "").includes("qa/");
48
51
  }
@@ -118,12 +121,20 @@ export function sessionStartCommand(context) {
118
121
 
119
122
  /**
120
123
  * The minimal-mode hook set, DERIVED from the full one rather than kept as a
121
- * second file to hold in sync (light is a filter, not a fork). Three edits:
124
+ * second file to hold in sync (light is a filter, not a fork). Four edits:
122
125
  *
123
126
  * (a) enforcement goes — the Stop hook is Act 3;
124
127
  * (b) lane-advisory goes — a nudge naming qa/ presupposes the lane;
125
128
  * (c) SessionStart says what is true HERE — `sessionContext` describes what
126
- * this scaffold carries and the one command that adds the rest.
129
+ * this scaffold carries and the one command that adds the rest;
130
+ * (d) a lane-referencing `statusLine` goes. It is not a hook, so the
131
+ * classifier above never saw it, and the first cut of the walk shipped a
132
+ * minimal scaffold a status line reading `qa/walk-status.mjs` — a file
133
+ * minimal deletes. The command is guarded (`test -f … || true`) so it
134
+ * printed nothing rather than erroring, which is exactly what made it
135
+ * survive review: dead config that fails silently. The lane-reference
136
+ * rule is the same one that drops lane-advisory hooks; it just has to be
137
+ * applied to every surface that carries a command, not only to `hooks`.
127
138
  *
128
139
  * @param {object} settings parsed .claude/settings.json content
129
140
  * @param {object} opts
@@ -131,7 +142,9 @@ export function sessionStartCommand(context) {
131
142
  */
132
143
  export function minimalHookSettings(settings, { sessionContext }) {
133
144
  const out = filterHooks(settings, (event, hook) => classifyHook(event, hook) !== "advisory");
134
- if (!out || typeof out.hooks !== "object" || out.hooks === null) return out;
145
+ if (!out || typeof out !== "object") return out;
146
+ if (referencesLane(out.statusLine)) delete out.statusLine;
147
+ if (typeof out.hooks !== "object" || out.hooks === null) return out;
135
148
  for (const group of out.hooks.SessionStart ?? []) {
136
149
  if (!Array.isArray(group?.hooks)) continue;
137
150
  for (const hook of group.hooks) hook.command = sessionStartCommand(sessionContext);
@@ -36,6 +36,10 @@ export const DISK_WARN_BYTES = 3 * GIB;
36
36
  * @param {string[]|null} [input.inspectorHits] relative (posix) paths of Kotlin sources that
37
37
  * reference the live-inspector endpoint (`/inspect/` or `InspectorHttpServer`);
38
38
  * null = scan skipped (no composeApp sources), [] = project has no inspector code.
39
+ * @param {{scriptPresent:boolean, settingsPresent:boolean, statusLine:boolean,
40
+ * promptHook:boolean}|null} [input.walk] the walk's wiring: is
41
+ * qa/walk-status.mjs installed, and does .claude/settings.json actually
42
+ * INVOKE it (statusLine + UserPromptSubmit)? null = skip the check.
39
43
  * @param {{catalog:string, theme:string}|null} [input.inspectorCatalog] the stamped
40
44
  * InspectorCatalog.kt content + concatenated theme sources (Tokens.kt/Theme.kt) for
41
45
  * the declared-token drift tripwire; null = skip.
@@ -55,6 +59,7 @@ export function diagnoseProject(input) {
55
59
  freeDiskBytes,
56
60
  inspectorHits = null,
57
61
  inspectorCatalog = null,
62
+ walk = null,
58
63
  } = input;
59
64
 
60
65
  // --- version catalog ------------------------------------------------------
@@ -313,6 +318,49 @@ export function diagnoseProject(input) {
313
318
  }
314
319
  }
315
320
 
321
+ // --- the walk: installed but unwired ----------------------------------------
322
+ // qa/walk-status.mjs is inert on its own. What renders it is .claude/settings.json:
323
+ // a statusLine (the ambient "where are we") and a UserPromptSubmit hook (the
324
+ // per-turn position injected into the agent). Both are APP-OWNED config, so an app
325
+ // that hand-edited settings.json can take the machinery on upgrade and lose the
326
+ // wiring — and the failure mode is silence, which is precisely the problem the walk
327
+ // exists to fix. Nothing else in the system can notice, so doctor does.
328
+ if (walk !== null && walk.scriptPresent) {
329
+ const missing = [
330
+ !walk.statusLine ? "no statusLine" : null,
331
+ !walk.promptHook ? "no UserPromptSubmit hook" : null,
332
+ ].filter(Boolean);
333
+ if (missing.length === 0) {
334
+ findings.push({
335
+ id: "walk-wiring",
336
+ level: "ok",
337
+ title: "The walk is wired",
338
+ detail:
339
+ "qa/walk-status.mjs is installed and .claude/settings.json invokes it from both the " +
340
+ "status line and UserPromptSubmit.",
341
+ });
342
+ } else {
343
+ findings.push({
344
+ id: "walk-wiring",
345
+ level: "warn",
346
+ title: "The walk is installed but not wired up",
347
+ detail:
348
+ "qa/walk-status.mjs is present, but " +
349
+ (walk.settingsPresent
350
+ ? `.claude/settings.json does not invoke it (${missing.join(", ")}).`
351
+ : "there is no .claude/settings.json to invoke it from.") +
352
+ " Nothing will show which stage a feature is at, or tell the agent where it is — " +
353
+ "the walk runs nowhere. Running node qa/walk-status.mjs by hand still works.",
354
+ fix: {
355
+ auto: true,
356
+ description:
357
+ "Add the statusLine and UserPromptSubmit entries to .claude/settings.json " +
358
+ "(copied from the engine template; existing hooks are left untouched).",
359
+ },
360
+ });
361
+ }
362
+ }
363
+
316
364
  return findings;
317
365
  }
318
366