create-cmp-cli 0.14.1 → 0.16.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/README.md +54 -353
- package/bin/create-cmp.mjs +19 -3
- package/llms.txt +3 -3
- package/options.schema.json +4 -0
- package/package.json +2 -2
- package/packages/harness/package.json +9 -1
- package/packages/harness/src/lib/harness-lock.mjs +2 -2
- package/packages/harness/src/lib/inputs-hash.mjs +1 -1
- package/packages/harness/src/lib/receipt-validate.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +1 -1
- package/packages/receipts/package.json +11 -3
- package/packages/receipts/src/index.mjs +1 -1
- package/packages/receipts/src/inputs-hash.mjs +1 -1
- package/packages/receipts/src/receipt-validate.mjs +1 -1
- package/src/commands/attach.mjs +250 -0
- package/src/commands/create.mjs +13 -2
- package/src/commands/harden.mjs +263 -0
- package/src/lib/adr-seed.mjs +27 -0
- package/src/lib/harness-upgrade.mjs +4 -0
- package/src/lib/hooks.mjs +140 -0
- package/src/lib/minimal.mjs +130 -0
- package/src/lib/toggle.mjs +4 -1
- package/src/lib/verify.mjs +6 -1
- package/src/scaffold.mjs +18 -1
- package/template/.github/workflows/verify.yml +15 -0
- package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
- package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
- package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
- package/template/.gradle/8.11.1/gc.properties +0 -0
- package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/template/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/template/.gradle/vcs-1/gc.properties +0 -0
- package/template/AGENTS.md +57 -8
- package/template/CLAUDE.md +49 -0
- package/template/CONTRIBUTING.md +13 -0
- package/template/README.md +33 -0
- package/template/docs/ARCHITECTURE.md +18 -1
- package/template/docs/TESTING.md +10 -0
- package/template/manifest.json +13 -0
- package/template/qa/lib/harness-lock.mjs +2 -2
- package/template/qa/lib/inputs-hash.mjs +1 -1
- package/template/qa/lib/receipt-validate.mjs +1 -1
- package/template/qa/receipt-check.mjs +1 -1
package/src/lib/adr-seed.mjs
CHANGED
|
@@ -57,6 +57,33 @@ function renderAdr(number, title, body, dateIso) {
|
|
|
57
57
|
// true, room true, firebase.auth "both") — matching every default seeds
|
|
58
58
|
// nothing beyond the shipped four; only a genuine choice gets a record.
|
|
59
59
|
const DECISION_RULES = [
|
|
60
|
+
{
|
|
61
|
+
id: "mode",
|
|
62
|
+
applies: (config) => config.harness === false,
|
|
63
|
+
title: () => "Minimal scaffold — verification harness deferred",
|
|
64
|
+
render: () => ({
|
|
65
|
+
context:
|
|
66
|
+
"create-cmp stamps the full verification harness by default: the verify lane " +
|
|
67
|
+
"(`qa/verify.mjs`) with evidence receipts, behavior specs (`specs/`), approval " +
|
|
68
|
+
"gates, feature generators, and a Stop hook making the definition of done " +
|
|
69
|
+
"machine-checked. This app was scaffolded `--minimal` — a deliberate choice to " +
|
|
70
|
+
"start with the smallest thing that builds green, deferring the harness rather " +
|
|
71
|
+
"than rejecting it.",
|
|
72
|
+
decision:
|
|
73
|
+
"We will start without the verification harness. The app keeps its full " +
|
|
74
|
+
"architecture, unit/conformance/golden tests (`./gradlew :composeApp:desktopTest`), " +
|
|
75
|
+
"headless previews, the live inspector, and advisory session hooks; it carries no " +
|
|
76
|
+
"verify lane, receipts, specs, approvals, generators, or enforcement hooks.",
|
|
77
|
+
consequences:
|
|
78
|
+
"- The definition of done is honor-system: green `desktopTest` plus review, with " +
|
|
79
|
+
"no receipt attesting what actually ran.\n" +
|
|
80
|
+
"- No spec-first behavior flow and no generators — new features are written by " +
|
|
81
|
+
"hand against `docs/ARCHITECTURE.md`.\n" +
|
|
82
|
+
"- Reversing this is one idempotent command, not a re-scope: " +
|
|
83
|
+
"`npx create-cmp-cli harden` installs the full harness and this ADR is " +
|
|
84
|
+
"superseded by that act.",
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
60
87
|
{
|
|
61
88
|
id: "persistence",
|
|
62
89
|
applies: (config) => config.room === false,
|
|
@@ -537,6 +537,10 @@ export function configFromSpecRecord(record, targetDir) {
|
|
|
537
537
|
iosBundleId: record.bundleId,
|
|
538
538
|
region: record.region ?? "us-central1",
|
|
539
539
|
themePrefix: record.themePrefix,
|
|
540
|
+
// Deliberately the OPPOSITE default from the feature toggles below: every
|
|
541
|
+
// app stamped before the mode split carries the full harness, so an
|
|
542
|
+
// absent `harness` key means full, not absent.
|
|
543
|
+
harness: record.harness ?? true,
|
|
540
544
|
platforms: record.platforms ?? { android: true, ios: true },
|
|
541
545
|
firebase: record.firebase ?? { enabled: false },
|
|
542
546
|
room: record.room ?? false,
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// hooks.mjs — the advisory/enforcement split for a stamped project's hook set.
|
|
2
|
+
//
|
|
3
|
+
// The template's .claude/settings.json is not one thing. It carries three
|
|
4
|
+
// kinds of hook, and telling them apart IS the product's Act 2 / Act 3 line:
|
|
5
|
+
//
|
|
6
|
+
// ENFORCEMENT — the Stop hook (qa/receipt-check.mjs --hook). It can refuse
|
|
7
|
+
// to let a session claim "done" without a fresh PASS receipt. It is the
|
|
8
|
+
// harness's teeth, it presupposes the lane, and it ships only in full
|
|
9
|
+
// mode. Classified BY EVENT: Stop/SubagentStop are where Claude Code can
|
|
10
|
+
// block, so any hook registered there is enforcement by construction.
|
|
11
|
+
//
|
|
12
|
+
// LANE ADVISORY — wall-time nudges whose command text names `qa/` (the
|
|
13
|
+
// verify-fast reminder, the device-lease reminder). They constrain
|
|
14
|
+
// nothing, but they presuppose the lane: in a scaffold without qa/ they
|
|
15
|
+
// would advertise commands the agent cannot run. A discovery surface that
|
|
16
|
+
// lies is worse than one that is absent, so these ship only where the
|
|
17
|
+
// lane does. Classified by reference: naming the lane is depending on it.
|
|
18
|
+
//
|
|
19
|
+
// PORTABLE ADVISORY — everything else that informs (the screenshots-lose-
|
|
20
|
+
// structure nudge). True in every mode; ships in every mode. An advisory
|
|
21
|
+
// hook always resolves to permissionDecision "allow" — if a future
|
|
22
|
+
// PreToolUse hook wanted to DENY, that is an enforcement decision to make
|
|
23
|
+
// deliberately here, not a string to pattern-match.
|
|
24
|
+
//
|
|
25
|
+
// SessionStart is deliberately exempt from the lane-reference rule, and the
|
|
26
|
+
// distinction is not a special case but the actual difference between the two
|
|
27
|
+
// kinds of hook. A PreToolUse nudge is a fixed instruction that fires at a
|
|
28
|
+
// wall: its command IS the advice, so a command naming qa/ can only be kept
|
|
29
|
+
// or dropped. SessionStart's command is a `printf` of narration that the
|
|
30
|
+
// stamper AUTHORS PER MODE — it is the one hook whose content is a variable,
|
|
31
|
+
// so it is never dropped for describing the full mode's lane; it is rewritten
|
|
32
|
+
// to describe the mode actually being stamped. Dropping it instead (the first
|
|
33
|
+
// cut of this module did) left a minimal scaffold with no opening context at
|
|
34
|
+
// all, which is the silence this whole discovery layer exists to prevent.
|
|
35
|
+
//
|
|
36
|
+
// Every function is pure: settings in, new settings out, input never mutated.
|
|
37
|
+
|
|
38
|
+
export const ENFORCEMENT_EVENTS = new Set(["Stop", "SubagentStop"]);
|
|
39
|
+
|
|
40
|
+
/** Events whose hooks can constrain the agent (vs inform it). */
|
|
41
|
+
export function isEnforcementEvent(event) {
|
|
42
|
+
return ENFORCEMENT_EVENTS.has(event);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Does this hook's command presuppose the verify lane (`qa/`)? */
|
|
46
|
+
export function referencesLane(hook) {
|
|
47
|
+
return String(hook?.command ?? "").includes("qa/");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Events whose hook command is narration the stamper rewrites per mode,
|
|
52
|
+
* rather than a fixed instruction that fires at a wall. These are never
|
|
53
|
+
* dropped for naming the lane — they are re-authored. See the header.
|
|
54
|
+
*/
|
|
55
|
+
const REWRITTEN_EVENTS = new Set(["SessionStart"]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Classify one hook: "enforcement" | "lane-advisory" | "advisory".
|
|
59
|
+
* @param {string} event the settings.hooks key the hook is registered under
|
|
60
|
+
* @param {object} hook one entry of a group's `hooks` array
|
|
61
|
+
*/
|
|
62
|
+
export function classifyHook(event, hook) {
|
|
63
|
+
if (isEnforcementEvent(event)) return "enforcement";
|
|
64
|
+
if (REWRITTEN_EVENTS.has(event)) return "advisory";
|
|
65
|
+
if (referencesLane(hook)) return "lane-advisory";
|
|
66
|
+
return "advisory";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Remove hooks matching `drop(event, hook)`; drop groups and events left
|
|
71
|
+
* empty, so the result is an honest hook set rather than a skeleton of empty
|
|
72
|
+
* arrays. Tolerates malformed shapes by passing them through untouched.
|
|
73
|
+
*/
|
|
74
|
+
function filterHooks(settings, drop) {
|
|
75
|
+
const out = structuredClone(settings);
|
|
76
|
+
if (!out || typeof out.hooks !== "object" || out.hooks === null) return out;
|
|
77
|
+
for (const [event, groups] of Object.entries(out.hooks)) {
|
|
78
|
+
if (!Array.isArray(groups)) continue;
|
|
79
|
+
for (const group of groups) {
|
|
80
|
+
if (!Array.isArray(group?.hooks)) continue;
|
|
81
|
+
group.hooks = group.hooks.filter((h) => !drop(event, h));
|
|
82
|
+
}
|
|
83
|
+
out.hooks[event] = groups.filter((g) => !Array.isArray(g?.hooks) || g.hooks.length > 0);
|
|
84
|
+
if (out.hooks[event].length === 0) delete out.hooks[event];
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The hook set with enforcement removed — advisory hooks (both kinds) pass
|
|
91
|
+
* through byte-identical. Idempotent; input never mutated.
|
|
92
|
+
* @param {object} settings parsed .claude/settings.json content
|
|
93
|
+
*/
|
|
94
|
+
export function stripEnforcementHooks(settings) {
|
|
95
|
+
return filterHooks(settings, (event) => isEnforcementEvent(event));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build a SessionStart hook command that prints `context` as
|
|
100
|
+
* additionalContext, in the exact shape the template's own hook uses
|
|
101
|
+
* (`printf '%s'` around a single-quoted JSON payload). The payload is
|
|
102
|
+
* single-quoted for the shell, so the copy must carry no apostrophe — that is
|
|
103
|
+
* a constraint on the author of the copy, enforced here rather than escaped
|
|
104
|
+
* around, so the stamped command stays trivially auditable.
|
|
105
|
+
* @param {string} context
|
|
106
|
+
*/
|
|
107
|
+
export function sessionStartCommand(context) {
|
|
108
|
+
if (context.includes("'")) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
"SessionStart context must not contain an apostrophe (the command is single-quoted for the shell) — reword the copy"
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const payload = JSON.stringify({
|
|
114
|
+
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context },
|
|
115
|
+
});
|
|
116
|
+
return `printf '%s' '${payload}'`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 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:
|
|
122
|
+
*
|
|
123
|
+
* (a) enforcement goes — the Stop hook is Act 3;
|
|
124
|
+
* (b) lane-advisory goes — a nudge naming qa/ presupposes the lane;
|
|
125
|
+
* (c) SessionStart says what is true HERE — `sessionContext` describes what
|
|
126
|
+
* this scaffold carries and the one command that adds the rest.
|
|
127
|
+
*
|
|
128
|
+
* @param {object} settings parsed .claude/settings.json content
|
|
129
|
+
* @param {object} opts
|
|
130
|
+
* @param {string} opts.sessionContext additionalContext for the SessionStart hook
|
|
131
|
+
*/
|
|
132
|
+
export function minimalHookSettings(settings, { sessionContext }) {
|
|
133
|
+
const out = filterHooks(settings, (event, hook) => classifyHook(event, hook) !== "advisory");
|
|
134
|
+
if (!out || typeof out.hooks !== "object" || out.hooks === null) return out;
|
|
135
|
+
for (const group of out.hooks.SessionStart ?? []) {
|
|
136
|
+
if (!Array.isArray(group?.hooks)) continue;
|
|
137
|
+
for (const hook of group.hooks) hook.command = sessionStartCommand(sessionContext);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// minimal.mjs — the `--minimal` mode subtraction (LADDER §R3): stamp the app
|
|
2
|
+
// without its verification harness, keeping the eyes.
|
|
3
|
+
//
|
|
4
|
+
// The mode is a FILTER over the one template, never a fork (design invariant:
|
|
5
|
+
// no second artifact to keep green). Three mechanisms, each already owned by
|
|
6
|
+
// the engine, do the whole job:
|
|
7
|
+
//
|
|
8
|
+
// 1. content variance — `cmp:feature harness` / `!harness` marker blocks
|
|
9
|
+
// (CLAUDE.md, AGENTS.md, README, CI workflow, docs),
|
|
10
|
+
// stripped by the standard toggle machinery;
|
|
11
|
+
// 2. path subtraction — manifest `features.harness.paths` for the
|
|
12
|
+
// app-owned governance surfaces (specs/, approvals,
|
|
13
|
+
// skills, evidence, hooks);
|
|
14
|
+
// 3. lane subtraction — THIS module, for the machine-owned region: delete
|
|
15
|
+
// every machine-owned .mjs EXCEPT the preview
|
|
16
|
+
// entry points and their transitive imports.
|
|
17
|
+
//
|
|
18
|
+
// The keep-set is DERIVED by walking import statements from the entry points,
|
|
19
|
+
// never transcribed as a list — a hand-maintained enumeration of qa/lib files
|
|
20
|
+
// is exactly the kind of claim that rots (design invariant 5). What survives
|
|
21
|
+
// in a minimal scaffold is precisely what its own kept scripts can reach.
|
|
22
|
+
//
|
|
23
|
+
// `create-cmp harden` is the inverse: a three-way stamp-merge (base = the
|
|
24
|
+
// minimal stamp, new = the full stamp) that installs the subtraction back —
|
|
25
|
+
// additive, idempotent, never clobbering. See src/commands/harden.mjs.
|
|
26
|
+
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
|
|
30
|
+
import { listHarnessFiles } from "../../packages/harness/src/lib/harness-region.mjs";
|
|
31
|
+
import { minimalHookSettings } from "./hooks.mjs";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Machine-owned entry points a minimal scaffold keeps: the preview gallery is
|
|
35
|
+
* the eyes' no-plugin surface (LADDER §R3 keeps previews; the manifest's own
|
|
36
|
+
* inspector notes already treat it as inspector-owned, not lane-owned).
|
|
37
|
+
* Entries missing from the tree (e.g. --no-inspector) are skipped.
|
|
38
|
+
*/
|
|
39
|
+
export const MINIMAL_LANE_ENTRY_POINTS = ["qa/preview-gallery.mjs"];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* SessionStart context for a minimal scaffold — what is true HERE, and the
|
|
43
|
+
* one command that adds the rest. No apostrophes: the hook command is
|
|
44
|
+
* single-quoted for the shell (hooks.mjs enforces this).
|
|
45
|
+
*/
|
|
46
|
+
export const MINIMAL_SESSION_CONTEXT =
|
|
47
|
+
"This is a create-cmp MINIMAL scaffold: full app architecture with tests, " +
|
|
48
|
+
"no verification harness. AGENTS.md maps symptoms to commands. Fast signal: " +
|
|
49
|
+
"./gradlew :composeApp:desktopTest. Headless screen previews: ./gradlew " +
|
|
50
|
+
":composeApp:renderScreens then node qa/preview-gallery.mjs. One idempotent " +
|
|
51
|
+
"command installs the full harness (verify lane, evidence receipts, " +
|
|
52
|
+
"machine-checked done): npx create-cmp-cli harden.";
|
|
53
|
+
|
|
54
|
+
// Matches the project's two import forms in lane code: static
|
|
55
|
+
// `from "./lib/x.mjs"` and dynamic `import(new URL("./lib/x.mjs", ...))`.
|
|
56
|
+
// Only ./-relative .mjs specifiers matter — node: and package imports are not
|
|
57
|
+
// files we ship.
|
|
58
|
+
const IMPORT_SPECIFIER_RE = /(?:from\s+|new URL\(\s*)["'](\.\.?\/[^"']+\.mjs)["']/g;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The machine-owned files a minimal scaffold keeps: the entry points plus
|
|
62
|
+
* their transitive ./-relative imports, resolved against the tree as stamped.
|
|
63
|
+
* @param {string} projectDir
|
|
64
|
+
* @returns {Set<string>} project-relative posix paths
|
|
65
|
+
*/
|
|
66
|
+
export function laneKeepSet(projectDir) {
|
|
67
|
+
const keep = new Set();
|
|
68
|
+
const queue = MINIMAL_LANE_ENTRY_POINTS.filter((rel) =>
|
|
69
|
+
fs.existsSync(path.join(projectDir, rel))
|
|
70
|
+
);
|
|
71
|
+
while (queue.length > 0) {
|
|
72
|
+
const rel = queue.pop();
|
|
73
|
+
if (keep.has(rel)) continue;
|
|
74
|
+
keep.add(rel);
|
|
75
|
+
let source;
|
|
76
|
+
try {
|
|
77
|
+
source = fs.readFileSync(path.join(projectDir, rel), "utf8");
|
|
78
|
+
} catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
for (const m of source.matchAll(IMPORT_SPECIFIER_RE)) {
|
|
82
|
+
const resolved = path.posix.join(path.posix.dirname(rel), m[1]);
|
|
83
|
+
if (!keep.has(resolved) && fs.existsSync(path.join(projectDir, resolved))) {
|
|
84
|
+
queue.push(resolved);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return keep;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Delete every machine-owned lane file outside the keep-set.
|
|
93
|
+
* @param {string} projectDir
|
|
94
|
+
* @param {(msg:string)=>void} [log]
|
|
95
|
+
* @returns {string[]} deleted relative paths
|
|
96
|
+
*/
|
|
97
|
+
export function subtractLane(projectDir, log = () => {}) {
|
|
98
|
+
const keep = laneKeepSet(projectDir);
|
|
99
|
+
const deleted = [];
|
|
100
|
+
for (const rel of listHarnessFiles(projectDir)) {
|
|
101
|
+
if (keep.has(rel)) continue;
|
|
102
|
+
fs.rmSync(path.join(projectDir, rel));
|
|
103
|
+
deleted.push(rel);
|
|
104
|
+
}
|
|
105
|
+
if (deleted.length > 0) {
|
|
106
|
+
log(` removed ${deleted.length} lane file(s) (minimal mode keeps ${[...keep].sort().join(", ") || "none"})`);
|
|
107
|
+
}
|
|
108
|
+
return deleted;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Apply minimal mode to a stamped tree: subtract the lane, then rewrite
|
|
113
|
+
* .claude/settings.json to the derived minimal hook set (enforcement and
|
|
114
|
+
* lane-advisory hooks gone, SessionStart telling the truth about this
|
|
115
|
+
* scaffold). Marker stripping and manifest path deletion have already
|
|
116
|
+
* happened via the standard feature machinery by the time this runs.
|
|
117
|
+
* @param {string} projectDir
|
|
118
|
+
* @param {(msg:string)=>void} [log]
|
|
119
|
+
*/
|
|
120
|
+
export function applyMinimalMode(projectDir, log = () => {}) {
|
|
121
|
+
subtractLane(projectDir, log);
|
|
122
|
+
|
|
123
|
+
const settingsPath = path.join(projectDir, ".claude", "settings.json");
|
|
124
|
+
if (fs.existsSync(settingsPath)) {
|
|
125
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
126
|
+
const minimal = minimalHookSettings(settings, { sessionContext: MINIMAL_SESSION_CONTEXT });
|
|
127
|
+
fs.writeFileSync(settingsPath, JSON.stringify(minimal, null, 2) + "\n");
|
|
128
|
+
log(" rewrote .claude/settings.json to the advisory-only hook set");
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/lib/toggle.mjs
CHANGED
|
@@ -86,12 +86,15 @@ export function stripFeatureBlocks(content, disabledFeatures) {
|
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
88
|
* Map an engine config object to the set of DISABLED feature names that the
|
|
89
|
-
* manifest understands: ios, firebase, room, e2e, inspector,
|
|
89
|
+
* manifest understands: harness, ios, firebase, room, e2e, inspector,
|
|
90
|
+
* dev-client. `harness` is the mode split (LADDER §R3): absent means full —
|
|
91
|
+
* only an explicit `harness: false` (`--minimal`) subtracts it.
|
|
90
92
|
* @param {object} config
|
|
91
93
|
* @returns {Set<string>}
|
|
92
94
|
*/
|
|
93
95
|
export function disabledFeaturesFromConfig(config) {
|
|
94
96
|
const disabled = new Set();
|
|
97
|
+
if (config.harness === false) disabled.add("harness");
|
|
95
98
|
if (!config.platforms?.ios) disabled.add("ios");
|
|
96
99
|
if (!config.firebase?.enabled) disabled.add("firebase");
|
|
97
100
|
if (!config.room) disabled.add("room");
|
package/src/lib/verify.mjs
CHANGED
|
@@ -41,8 +41,13 @@ export async function runVerify({ projectDir, manifest, config, dryRun = false }
|
|
|
41
41
|
const verify = (manifest && manifest.verify) || {};
|
|
42
42
|
const results = [];
|
|
43
43
|
|
|
44
|
+
// Minimal mode has no in-app lane to run — its gate is the Gradle tier the
|
|
45
|
+
// scaffold DOES ship (unit + conformance + golden tests, debug build).
|
|
46
|
+
const minimal = config?.harness === false;
|
|
47
|
+
const androidCommand = minimal && verify.androidMinimal ? verify.androidMinimal : verify.android;
|
|
48
|
+
|
|
44
49
|
const plan = [];
|
|
45
|
-
if (
|
|
50
|
+
if (androidCommand) plan.push({ platform: "android", command: androidCommand, eligible: true });
|
|
46
51
|
if (verify.ios) {
|
|
47
52
|
const eligible = isMacOS() && !!config?.platforms?.ios;
|
|
48
53
|
plan.push({ platform: "ios", command: verify.ios, eligible });
|
package/src/scaffold.mjs
CHANGED
|
@@ -324,6 +324,7 @@ function writeSpecOfRecord(projectDir, config) {
|
|
|
324
324
|
bundleId: config.iosBundleId,
|
|
325
325
|
themePrefix: config.themePrefix,
|
|
326
326
|
region: config.region,
|
|
327
|
+
harness: config.harness !== false,
|
|
327
328
|
platforms: config.platforms,
|
|
328
329
|
firebase: config.firebase,
|
|
329
330
|
room: config.room,
|
|
@@ -464,9 +465,25 @@ export async function scaffold(config, opts = {}) {
|
|
|
464
465
|
if (seeded.length === 0) process.stdout.write(" no configuration deviated from the interview default — nothing to seed\n");
|
|
465
466
|
|
|
466
467
|
// (e.2) regenerate the architecture doc's derived sections for the tree as
|
|
467
|
-
// stamped — see regenerateArchDoc above.
|
|
468
|
+
// stamped — see regenerateArchDoc above. MUST precede the minimal-mode lane
|
|
469
|
+
// subtraction below: the walker it imports (qa/lib/arch-doc.mjs) is lane
|
|
470
|
+
// code a minimal scaffold does not keep, and the doc's derived sections
|
|
471
|
+
// describe composeApp/ (which minimal mode never touches), so regenerating
|
|
472
|
+
// first is both necessary and correct.
|
|
468
473
|
await regenerateArchDoc(projectDir);
|
|
469
474
|
|
|
475
|
+
// (e.3) minimal mode — subtract the machine-owned lane (keeping the preview
|
|
476
|
+
// entry points + their import closure) and rewrite the hook set to
|
|
477
|
+
// advisory-only. Marker blocks and manifest paths were already handled by
|
|
478
|
+
// the standard feature machinery above; this is the part only the engine
|
|
479
|
+
// can derive. Runs BEFORE writeLaneLock so the lock hashes exactly the
|
|
480
|
+
// region this app ships.
|
|
481
|
+
if (config.harness === false) {
|
|
482
|
+
step("Applying minimal mode (no verification harness)…");
|
|
483
|
+
const { applyMinimalMode } = await import("./lib/minimal.mjs");
|
|
484
|
+
applyMinimalMode(projectDir, (m) => process.stdout.write(`${m}\n`));
|
|
485
|
+
}
|
|
486
|
+
|
|
470
487
|
// Write local.properties (sdk.dir) so the Gradle build can find the Android
|
|
471
488
|
// SDK even when ANDROID_HOME/ANDROID_SDK_ROOT aren't exported (manifest
|
|
472
489
|
// stampPipeline step 7). Skip silently if no SDK is found and env vars are
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
# CI for your Compose Multiplatform app — stamped in by create-cmp.
|
|
2
2
|
#
|
|
3
|
+
# >>> cmp:feature harness
|
|
3
4
|
# What runs on every push/PR: a toolchain report (advisory) and the VERIFY LANE
|
|
4
5
|
# (qa/verify.mjs) — build + unit tests + every other gate this project carries,
|
|
5
6
|
# producing the evidence receipt. Green here = your frozen version set still
|
|
6
7
|
# builds AND the harness's checks pass.
|
|
8
|
+
# <<< cmp:feature harness
|
|
9
|
+
# >>> cmp:feature !harness
|
|
10
|
+
# What runs on every push/PR: a toolchain report (advisory), the JVM test tier
|
|
11
|
+
# (unit + conformance + golden trees), and the Android debug build. This is a
|
|
12
|
+
# minimal scaffold — `npx create-cmp-cli harden` upgrades this workflow to the
|
|
13
|
+
# full verify lane with evidence receipts.
|
|
14
|
+
# <<< cmp:feature !harness
|
|
7
15
|
#
|
|
8
16
|
# iOS: a ready-to-enable macOS job is included (commented out) at the bottom.
|
|
9
17
|
# macOS runners cost ~10x Linux minutes, so it's opt-in.
|
|
@@ -36,6 +44,12 @@ jobs:
|
|
|
36
44
|
continue-on-error: true
|
|
37
45
|
run: npx --yes create-cmp-cli@latest doctor --yes --no-install --no-ios
|
|
38
46
|
|
|
47
|
+
# >>> cmp:feature !harness
|
|
48
|
+
- name: Tests (JVM tier) + debug build
|
|
49
|
+
run: ./gradlew :composeApp:desktopTest :composeApp:assembleDebug
|
|
50
|
+
# <<< cmp:feature !harness
|
|
51
|
+
|
|
52
|
+
# >>> cmp:feature harness
|
|
39
53
|
# Receipt attests HEAD: the committed evidence receipt (qa/evidence/latest.json)
|
|
40
54
|
# must validly attest the checked-out tree — verdict PASS and the inputs-hash
|
|
41
55
|
# still matching the verified surface — BEFORE we spend a runner re-running the
|
|
@@ -62,6 +76,7 @@ jobs:
|
|
|
62
76
|
with:
|
|
63
77
|
name: verify-evidence
|
|
64
78
|
path: qa/evidence/latest.json
|
|
79
|
+
# <<< cmp:feature harness
|
|
65
80
|
|
|
66
81
|
# ── iOS (opt-in) ──────────────────────────────────────────────────────────
|
|
67
82
|
# Uncomment to build the iOS app on every push to main. Uses the exact
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
File without changes
|
package/template/AGENTS.md
CHANGED
|
@@ -1,13 +1,62 @@
|
|
|
1
1
|
# Agent instructions
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
<!-- >>> cmp:feature harness -->
|
|
4
|
+
This repository is agent-first, with a verification harness. The working contract — the
|
|
5
|
+
definition of done (`node qa/verify.mjs` must PASS, receipt committed), the architecture
|
|
6
|
+
gates, and the device-free **UI feedback loop** — lives in [CLAUDE.md](./CLAUDE.md).
|
|
7
|
+
<!-- <<< cmp:feature harness -->
|
|
8
|
+
<!-- >>> cmp:feature !harness -->
|
|
9
|
+
This repository is agent-first. The working guide — the architecture, the commands that
|
|
10
|
+
build and test it, and the device-free **UI feedback loop** — lives in
|
|
11
|
+
[CLAUDE.md](./CLAUDE.md).
|
|
12
|
+
<!-- <<< cmp:feature !harness -->
|
|
7
13
|
|
|
8
14
|
Read CLAUDE.md before making changes. It applies to every coding agent, not only Claude.
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
the
|
|
16
|
+
## Stuck? Symptom → command
|
|
17
|
+
|
|
18
|
+
Every command below runs from the repo root with **nothing to install**: Gradle is
|
|
19
|
+
wrapped, the scripts ship inside this project, and `npx` fetches on demand. The
|
|
20
|
+
create-cmp Claude Code plugin layers better ergonomics over the same capabilities
|
|
21
|
+
(skills, the `cmp-inspector` MCP's structured tools) — an accelerator, never a
|
|
22
|
+
prerequisite.
|
|
23
|
+
|
|
24
|
+
| Symptom | Run |
|
|
25
|
+
|---|---|
|
|
26
|
+
<!-- >>> cmp:feature harness -->
|
|
27
|
+
| "Did my edit break anything?" | `node qa/verify.mjs --fast` — the inner loop: seconds of JVM-tier signal; never the done-gate |
|
|
28
|
+
| Want that answer on every save | `node qa/watch.mjs` — resident watcher; re-runs the fast tier on save, debounced |
|
|
29
|
+
<!-- <<< cmp:feature harness -->
|
|
30
|
+
<!-- >>> cmp:feature !harness -->
|
|
31
|
+
| "Did my edit break anything?" | `./gradlew :composeApp:desktopTest` — unit + conformance + golden-tree tests in seconds, no device |
|
|
32
|
+
<!-- <<< cmp:feature !harness -->
|
|
33
|
+
<!-- >>> cmp:feature inspector -->
|
|
34
|
+
| Can't see the UI (no device attached) | `./gradlew :composeApp:renderScreens && node qa/preview-gallery.mjs` — every real screen headless: `tree.json` for you to assert on, one gallery page for the human |
|
|
35
|
+
| Need the RUNNING app's real state | `adb forward tcp:9500 tcp:9500`, then `http://127.0.0.1:9500/inspect/remote` — debug builds serve the live semantics tree on loopback |
|
|
36
|
+
<!-- <<< cmp:feature inspector -->
|
|
37
|
+
<!-- >>> cmp:feature harness -->
|
|
38
|
+
| Adding a feature / screen / repository | `node qa/scaffold-feature.mjs <Name>` — clones the tested exemplar through every layer; never freehand the pattern (skills: `add-feature`, `add-screen`, `add-repository`) |
|
|
39
|
+
| A gate failed and looks arbitrary | `node qa/refusal-demo.mjs` — stages canonical violations so each gate names the clause it protects |
|
|
40
|
+
<!-- <<< cmp:feature harness -->
|
|
41
|
+
| Build broken, toolchain suspect | `npx create-cmp-cli doctor --fix` — diagnoses machine AND project (kotlin↔ksp lockstep, catalog drift); asks before any repair |
|
|
42
|
+
| Dependency versions stale or mismatched | `npx create-cmp-cli upgrade --dry-run` — diff against the next proven-green set before touching anything |
|
|
43
|
+
<!-- >>> cmp:feature harness -->
|
|
44
|
+
| Ready to claim done | `node qa/verify.mjs` — the full lane, once, deliberately; commit the receipt it writes |
|
|
45
|
+
<!-- <<< cmp:feature harness -->
|
|
46
|
+
|
|
47
|
+
Famous build failures (kotlin↔KSP mismatch, the KSP2/iOS catch-22, `SDK location not
|
|
48
|
+
found`, `No space left on device`): `doctor` diagnoses all of them offline; the worked
|
|
49
|
+
write-ups live upstream at
|
|
50
|
+
<https://github.com/kvdm-co-pilot/create-cmp/tree/main/docs/errors>.
|
|
51
|
+
|
|
52
|
+
<!-- >>> cmp:feature harness -->
|
|
53
|
+
One rule before you edit anything: the `.mjs` files directly under `qa/` and `qa/lib/`
|
|
54
|
+
are **machine-owned** harness code — byte-identical in every create-cmp app and
|
|
55
|
+
hash-locked by `qa/harness.lock.json`. Editing them fails the lane's first step. If the
|
|
56
|
+
lane is wrong, the fix is upstream — see "The lane is not yours to edit" in CLAUDE.md.
|
|
57
|
+
<!-- <<< cmp:feature harness -->
|
|
58
|
+
<!-- >>> cmp:feature !harness -->
|
|
59
|
+
This is a **minimal scaffold** — no verify lane, receipts, specs, or generators are
|
|
60
|
+
installed. One idempotent command installs the full harness and its machine-checked
|
|
61
|
+
definition of done: `npx create-cmp-cli harden`.
|
|
62
|
+
<!-- <<< cmp:feature !harness -->
|
package/template/CLAUDE.md
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
<!-- >>> cmp:feature harness -->
|
|
1
2
|
# __APP_NAME__ — AI delivery contract
|
|
2
3
|
|
|
3
4
|
Generated by [create-cmp](https://github.com/kvdm-co-pilot/create-cmp) with a verification
|
|
@@ -422,3 +423,51 @@ conventions) · [`CONTRIBUTING.md`](./CONTRIBUTING.md) (workflow, Conventional C
|
|
|
422
423
|
| `node qa/verify.mjs --determinism` | Timezone determinism probe, alone: runs the JVM test tier twice under UTC-12 and UTC+14 and FAILs naming any test whose outcome differs — the dynamic net behind ARCH-13's static one. Opt-in inside a lane via `--profile ci --determinism`; never with `--fast`; writes no receipt on its own |
|
|
423
424
|
| `node qa/record-audit.mjs <subsystem>` | Record that a `cmp-audit` of an androidMain subsystem happened (appends subsystem + HEAD sha + timestamp to `qa/audits.jsonl`; refuses dirty/unknown targets). `--list` shows every derived subsystem and its audit status |
|
|
424
425
|
| `node qa/retrospective.mjs` | How this project actually uses its harness, from `qa/flight-recorder.jsonl` (appended by every lane run): fast vs full ratio, verbatim SKIP reasons grouped, whether the device tier is ever reached, longest stretch with no full lane. States only what the journal recorded |
|
|
426
|
+
<!-- <<< cmp:feature harness -->
|
|
427
|
+
<!-- >>> cmp:feature !harness -->
|
|
428
|
+
# __APP_NAME__ — working guide
|
|
429
|
+
|
|
430
|
+
Generated by [create-cmp](https://github.com/kvdm-co-pilot/create-cmp) as a **minimal
|
|
431
|
+
scaffold**: the full app architecture and its tests, without the verification harness.
|
|
432
|
+
|
|
433
|
+
## Build & test
|
|
434
|
+
|
|
435
|
+
| Command | What |
|
|
436
|
+
|---|---|
|
|
437
|
+
| `./gradlew :composeApp:desktopTest` | Unit + conformance + golden-tree tests (JVM, seconds) |
|
|
438
|
+
| `./gradlew :composeApp:assembleDebug` | Android debug build |
|
|
439
|
+
| `./gradlew :composeApp:installDebug` | Install on the attached device/emulator |
|
|
440
|
+
|
|
441
|
+
Run `desktopTest` after every change — it carries the architecture gates that keep this
|
|
442
|
+
codebase coherent. Never delete or weaken a failing test to reach green.
|
|
443
|
+
|
|
444
|
+
## Architecture
|
|
445
|
+
|
|
446
|
+
[`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) is the working guide: `presentation` →
|
|
447
|
+
`domain` ← `data`; typed errors (`AppResult`, no cross-layer throws); design tokens from
|
|
448
|
+
`presentation/theme/` (no hardcoded values); every screen a `*Screen` composable with a
|
|
449
|
+
tested ViewModel, mirroring the `home` exemplar through every layer.
|
|
450
|
+
<!-- >>> cmp:feature inspector -->
|
|
451
|
+
|
|
452
|
+
## UI feedback loop — see what you build, without a device
|
|
453
|
+
|
|
454
|
+
`./gradlew :composeApp:renderScreens` renders every screen in
|
|
455
|
+
`inspector/PreviewRegistry.kt` headlessly (real DI, real theme) to
|
|
456
|
+
`composeApp/build/previews/<id>/{screen.png, tree.json}`; `node qa/preview-gallery.mjs`
|
|
457
|
+
builds one self-contained gallery page. Assert on `tree.json` structure — pixels are for
|
|
458
|
+
humans. Register new screens in the PreviewRegistry.
|
|
459
|
+
|
|
460
|
+
With a debug build running: `adb forward tcp:9500 tcp:9500`, then
|
|
461
|
+
`http://127.0.0.1:9500/inspect/remote` mirrors the live app with click-to-tap.
|
|
462
|
+
<!-- <<< cmp:feature inspector -->
|
|
463
|
+
|
|
464
|
+
## What full mode adds
|
|
465
|
+
|
|
466
|
+
The verification harness: a verify lane (`qa/verify.mjs`) with evidence receipts,
|
|
467
|
+
behavior specs, approval gates, feature generators, and a Stop hook that makes "done"
|
|
468
|
+
machine-checked instead of honor-system. One idempotent command installs it all:
|
|
469
|
+
|
|
470
|
+
```bash
|
|
471
|
+
npx create-cmp-cli harden
|
|
472
|
+
```
|
|
473
|
+
<!-- <<< cmp:feature !harness -->
|
package/template/CONTRIBUTING.md
CHANGED
|
@@ -6,14 +6,27 @@
|
|
|
6
6
|
2. Make the change — new features mirror the `home` exemplar
|
|
7
7
|
(see [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md)), with tests at every layer
|
|
8
8
|
(see [`docs/TESTING.md`](./docs/TESTING.md)).
|
|
9
|
+
<!-- >>> cmp:feature harness -->
|
|
9
10
|
3. Run the verify lane: `node qa/verify.mjs`.
|
|
10
11
|
4. Commit **including the updated receipt** (`qa/evidence/latest.json`). A change without a
|
|
11
12
|
PASS receipt is not done — CI re-runs the same lane and will say so.
|
|
13
|
+
<!-- <<< cmp:feature harness -->
|
|
14
|
+
<!-- >>> cmp:feature !harness -->
|
|
15
|
+
3. Run the tests: `./gradlew :composeApp:desktopTest` must be green (CI re-runs it on
|
|
16
|
+
every push).
|
|
17
|
+
4. Commit.
|
|
18
|
+
<!-- <<< cmp:feature !harness -->
|
|
12
19
|
5. Open a PR. Keep it one concern; note any intended golden/baseline changes explicitly.
|
|
13
20
|
|
|
14
21
|
## Definition of done
|
|
15
22
|
|
|
23
|
+
<!-- >>> cmp:feature harness -->
|
|
16
24
|
- `node qa/verify.mjs` → **PASS**, receipt committed.
|
|
25
|
+
<!-- <<< cmp:feature harness -->
|
|
26
|
+
<!-- >>> cmp:feature !harness -->
|
|
27
|
+
- `./gradlew :composeApp:desktopTest` green. (This is a minimal scaffold — the
|
|
28
|
+
machine-checked definition of done arrives with `npx create-cmp-cli harden`.)
|
|
29
|
+
<!-- <<< cmp:feature !harness -->
|
|
17
30
|
- New behavior has tests; existing tests untouched unless the behavior intentionally changed
|
|
18
31
|
(say so in the PR).
|
|
19
32
|
- No hardcoded design values; testTags on anything E2E needs to reach.
|