novahiz 0.5.1 → 0.6.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/CHANGELOG.md CHANGED
@@ -5,6 +5,62 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.6.0] - 2026-10-06
9
+
10
+ Feature release: the gate learns project scope, and Stitch design projects
11
+ get a capture → composite → fidelity-verdict loop.
12
+
13
+ ### Added
14
+
15
+ - **`R16-stitch-fidelity` gate rule**: editing a UI file (`*.tsx`, `*.jsx`,
16
+ `*.vue`, `*.svelte`, `*.dart`, `*.css`, `*.scss`, `*.html`) now requires
17
+ the `novahiz-stitch-fidelity` skill — but only inside a project that
18
+ carries `stitch/**` references. This is the new `projectGlobs` selector
19
+ (`src/spec.ts`, `src/gate.ts`): nearest ancestor of the edited file
20
+ holding `.git`, `package.json` or `pubspec.yaml`, globs written from that
21
+ root. Projects without Stitch references are unaffected.
22
+ - **`novahiz-stitch-fidelity` skill**: `scripts/stitch_capture.mjs` (adb:
23
+ uiautomator dump, screencap, input, adb.autoStart), `scripts/stitch_composite.mjs`
24
+ (screenshot ↔ design overlay), the fidelity judgment grid, capture
25
+ recipes and a `verify.config.defaults.json`.
26
+ - **`stitch-fidelity` provider** (`kind: commands`): adb prerequisite with
27
+ per-OS bootstrap (winget / brew / apt), categories `design-ui`, `expo`,
28
+ `flutter`; `stitch_capture.mjs --check` reports the seven readiness items.
29
+ - Optional `stitch-verify` roadmap step (`kind: verify`) in two roadmaps.
30
+ - Tests: `tests/gate-stitch-fidelity.test.ts` — project-scoped enforcement.
31
+
32
+ ### Changed
33
+
34
+ - README headline and counts refreshed: 99 skills, 13 gate rules, 8 MCP
35
+ providers, version line at 0.6.0 (was advertising 96/11/7 and 0.4.1).
36
+
37
+ ## [0.5.2] - 2026-10-05
38
+
39
+ Maintenance release closing the three leftovers traced right after `0.5.1`:
40
+ the installer's dead `plugin` config key, the unread `tokens` block of the
41
+ example config, and the scheduler's 0 ms wake loop on a busy task.
42
+
43
+ ### Fixed
44
+
45
+ - `install/install.mjs` no longer writes a `"plugin"` array of **file paths**
46
+ into a freshly created `opencode.jsonc`. opencode never read it (the plugins
47
+ are loaded from `<config>/plugins`, proved in E2E: the key pointed at the
48
+ repo while the loaded copies came from the plugins directory) and a file
49
+ entry triggered `configured plugin path must be a directory` at every
50
+ startup. Existing configs are left untouched.
51
+ - `novahiz.config.example.json` no longer advertises a `tokens` block
52
+ (`enabled`, `trimOutputs`, `keepHeadLines`, `dedupeReads`, `capOutputTokens`,
53
+ `trimTools`, …) that no code ever read — a leftover of a layer that was never
54
+ shipped. The example now carries exactly the keys `defaultConfig()` writes,
55
+ and `docs/TOKENS.md` states it instead of pointing at a block that should
56
+ not be there.
57
+ - `mcp/clepsydre/src/scheduler.ts`: `arm()` no longer arms a timer for a task
58
+ that is already running. A past-due schedule on a busy task used to feed a
59
+ 0 ms `wake → skip → arm` loop (one task-store read per turn) for the whole
60
+ execution, and a manual trigger could sustain it. The lock release now goes
61
+ through `release()`, which re-evaluates the calendar once the task is free,
62
+ so a due occurrence is still caught up exactly once instead of by the spin.
63
+
8
64
  ## [0.5.1] - 2026-10-05
9
65
 
10
66
  Fix-forward release: the `v0.5.0` tag was pushed but its CI never reached
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  > **Couche d'application sans dépendance pour les agents de code IA** — classe les prompts, attribue des roadmaps d'exécution, bloque les éditions non sûres tant que les bonnes skills ne sont pas chargées, et persiste les décisions d'une session à l'autre — tout cela de façon déterministe, sans appel de modèle.
4
4
 
5
- 17 catégories, 96 skills, 11 gate rules, 7 MCP providers — tout déterministe, tout local, tout JSON.
5
+ 17 catégories, 99 skills, 13 gate rules, 8 MCP providers — tout déterministe, tout local, tout JSON.
6
6
 
7
- > **Version 0.4.1** — publiée sur npm (`npm install -g --allow-scripts=novahiz novahiz` installe et configure tout : core, plugin, agent, skills packs et Playwright MCP). `CHANGELOG.md` et `scripts/` sont dans le tarball.
7
+ > **Version 0.6.0** — publiée sur npm (`npm install -g --allow-scripts=novahiz novahiz` installe et configure tout : core, plugin, agent, skills packs et Playwright MCP). `CHANGELOG.md` et `scripts/` sont dans le tarball.
8
8
 
9
9
  ```
10
10
  ┌──────────────────────────────────────────────────────────────────────────┐
@@ -35,7 +35,7 @@
35
35
  └─ tier, skills ──────│ └─ block : charger les skills nommées, retry une fois
36
36
  ```
37
37
 
38
- **17 catégories**, **96 skills**, **11 gate rules**, **7 MCP providers** — tout déterministe, tout local, tout JSON.
38
+ **17 catégories**, **99 skills**, **13 gate rules**, **8 MCP providers** — tout déterministe, tout local, tout JSON.
39
39
 
40
40
  ---
41
41
 
@@ -310,7 +310,7 @@ Deux pièces complémentaires : la session consomme moins sans perdre en puissan
310
310
 
311
311
  ## Skills installées
312
312
 
313
- Novahiz livre 98 skills couvrant toutes les catégories :
313
+ Novahiz livre 99 skills couvrant toutes les catégories :
314
314
 
315
315
  | Catégorie | Skills | Objectif |
316
316
  |-----------|--------|----------|
@@ -198,6 +198,13 @@
198
198
  "kind": "skill",
199
199
  "requireSkills": ["dart-add-unit-test"]
200
200
  },
201
+ {
202
+ "id": "stitch-verify",
203
+ "label": "Verify screens against Stitch references (projects carrying stitch/**)",
204
+ "kind": "verify",
205
+ "requireSkills": ["novahiz-stitch-fidelity"],
206
+ "optional": true
207
+ },
201
208
  {
202
209
  "id": "impeccable-critique",
203
210
  "label": "Impeccable critique (UI work)",
@@ -307,6 +314,13 @@
307
314
  "kind": "edit",
308
315
  "requireSkills": ["novahiz-implement"]
309
316
  },
317
+ {
318
+ "id": "stitch-verify",
319
+ "label": "Verify screens against Stitch references (projects carrying stitch/**)",
320
+ "kind": "verify",
321
+ "requireSkills": ["novahiz-stitch-fidelity"],
322
+ "optional": true
323
+ },
310
324
  {
311
325
  "id": "converge",
312
326
  "label": "Converge",
@@ -108,6 +108,31 @@
108
108
  "categories": ["audit"],
109
109
  "purpose": "Local stdio static-analysis server (zero npm dependency): scans files and directories with local security rules (SQL injection, XSS, command injection, hardcoded secrets, insecure deserialization), creates custom rules behind a ReDoS gate, exports json/sarif/csv/html. Original clean-room implementation for the audit category."
110
110
  },
111
+ {
112
+ "id": "stitch-fidelity",
113
+ "label": "Stitch fidelity capture (adb + Android emulator)",
114
+ "kind": "commands",
115
+ "requires": ["adb"],
116
+ "bootstrap": {
117
+ "win32": [
118
+ "winget install --id Google.PlatformTools",
119
+ "winget install --id Google.AndroidStudio"
120
+ ],
121
+ "darwin": [
122
+ "brew install --cask android-platform-tools",
123
+ "brew install --cask android-commandlinetools"
124
+ ],
125
+ "linux": [
126
+ "sudo apt-get install -y adb",
127
+ "sudo apt-get install -y android-sdk"
128
+ ],
129
+ "default": ["install Android platform-tools (adb) and the Android emulator on PATH"]
130
+ },
131
+ "source": "https://github.com/novahiz/novahiz",
132
+ "license": "Apache-2.0",
133
+ "categories": ["design-ui", "expo", "flutter"],
134
+ "purpose": "Prerequisite for skill novahiz-stitch-fidelity: `adb` drives the capture (uiautomator dump, screencap, input). `emulator` is only needed to auto-start a device when nothing is online (adb.autoStart); `stitch_capture.mjs --check` reports all seven readiness items."
135
+ },
111
136
  {
112
137
  "id": "novahiz-gate",
113
138
  "label": "novahiz-gate",
@@ -156,5 +156,24 @@
156
156
  ]
157
157
  },
158
158
  "require": ["token-economy"]
159
+ },
160
+ {
161
+ "id": "R16-stitch-fidelity",
162
+ "description": "Load novahiz-stitch-fidelity before editing the UI of a project that carries Stitch references (stitch/**): capture, composite and fidelity verdict must be part of that loop.",
163
+ "when": {
164
+ "match": "all",
165
+ "pathGlobs": [
166
+ "**/*.tsx",
167
+ "**/*.jsx",
168
+ "**/*.vue",
169
+ "**/*.svelte",
170
+ "**/*.dart",
171
+ "**/*.css",
172
+ "**/*.scss",
173
+ "**/*.html"
174
+ ],
175
+ "projectGlobs": ["stitch/**"]
176
+ },
177
+ "require": ["novahiz-stitch-fidelity"]
159
178
  }
160
179
  ]
package/dist/gate.js CHANGED
@@ -1,4 +1,5 @@
1
- import { isAbsolute, relative, resolve } from "node:path";
1
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
2
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
3
  import { hasPlaceholder, hasProse, hasStyle, isTrivial } from "./content.js";
3
4
  import { determineTier } from "./complexity.js";
4
5
  // Shared ledger enforcement (audit P1-D/M1): the CLI command and the MCP
@@ -215,6 +216,76 @@ export function contentSatisfies(content, patterns) {
215
216
  }
216
217
  });
217
218
  }
219
+ // R16: "only in projects that carry X" cannot be expressed per file — the gate
220
+ // sees the edited path, not the repository. `when.projectGlobs` closes that gap:
221
+ // the globs are resolved from the project root (the nearest ancestor of the
222
+ // edited file holding .git, package.json or pubspec.yaml) and scanned from the
223
+ // glob's literal prefix, so a rule never walks node_modules.
224
+ const PROJECT_ROOT_MARKERS = [".git", "package.json", "pubspec.yaml"];
225
+ const PROJECT_SKIP_DIRS = new Set([".git", "node_modules"]);
226
+ export function projectRootOf(filePath) {
227
+ let dir = dirname(resolve(filePath || "."));
228
+ for (;;) {
229
+ if (PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(dir, marker))))
230
+ return dir;
231
+ const parent = dirname(dir);
232
+ if (parent === dir)
233
+ return null;
234
+ dir = parent;
235
+ }
236
+ }
237
+ function listFilesUnder(dir, out) {
238
+ let entries;
239
+ try {
240
+ entries = readdirSync(dir, { withFileTypes: true });
241
+ }
242
+ catch {
243
+ return; // unreadable directory: contributes nothing rather than throwing
244
+ }
245
+ for (const entry of entries) {
246
+ const full = join(dir, entry.name);
247
+ if (entry.isDirectory()) {
248
+ if (!PROJECT_SKIP_DIRS.has(entry.name))
249
+ listFilesUnder(full, out);
250
+ }
251
+ else if (entry.isFile()) {
252
+ out.push(full);
253
+ }
254
+ }
255
+ }
256
+ export function projectGlobsMatch(root, globs) {
257
+ if (!root) {
258
+ console.error("[Novahiz] projectGlobs: project root could not be resolved — the rule will not apply");
259
+ return false;
260
+ }
261
+ for (const glob of globs) {
262
+ const normalized = glob.replace(/\\/g, "/");
263
+ // Scanning starts at the literal prefix: "stitch/**" only reads stitch/.
264
+ // A glob whose prefix is pure wildcards cannot be scoped — fail closed on
265
+ // that pattern (never matches) instead of walking the whole project.
266
+ const literalPrefix = normalized.split(/[?*[\]]/, 1)[0].replace(/\/+$/, "");
267
+ if (literalPrefix.length === 0) {
268
+ console.error(`[Novahiz] projectGlobs "${glob}" must start with a literal segment — skipped`);
269
+ continue;
270
+ }
271
+ const base = join(root, literalPrefix);
272
+ const stats = statSync(base, { throwIfNoEntry: false });
273
+ if (!stats)
274
+ continue;
275
+ const candidates = [];
276
+ if (stats.isFile())
277
+ candidates.push(base);
278
+ else
279
+ listFilesUnder(base, candidates);
280
+ const pattern = globToRegExp(normalized);
281
+ for (const file of candidates) {
282
+ const rel = relative(root, file).split("\\").join("/");
283
+ if (pattern.test(rel))
284
+ return true;
285
+ }
286
+ }
287
+ return false;
288
+ }
218
289
  function selectorMatches(rule, classification, path, categories, pathless = false) {
219
290
  const when = rule.when;
220
291
  const checks = [];
@@ -290,9 +361,22 @@ export function evaluateGate(input) {
290
361
  const tier = input.tier ?? determineTier(prompt.length > 0 ? prompt : content);
291
362
  const requiredSkills = [];
292
363
  const matchedRules = [];
364
+ // Resolved lazily: only a rule carrying projectGlobs pays for the walk up to
365
+ // the project root (and the result is reused by every following rule).
366
+ let projectRoot;
293
367
  for (const rule of input.spec.rules) {
294
368
  if (!selectorMatches(rule, classification, path, categories, pathless))
295
369
  continue;
370
+ if (rule.when.projectGlobs && rule.when.projectGlobs.length > 0) {
371
+ // Pathless probes carry no file: there is no repository to inspect, so
372
+ // an unprovable project condition never grants the rule (fail closed).
373
+ if (pathless)
374
+ continue;
375
+ if (projectRoot === undefined)
376
+ projectRoot = projectRootOf(path);
377
+ if (!projectGlobsMatch(projectRoot, rule.when.projectGlobs))
378
+ continue;
379
+ }
296
380
  // Tier gating for R6-Novahiz: trivial = skip entirely, lite = only implement+converge
297
381
  if (rule.id === "R6-Novahiz") {
298
382
  if (tier === "trivial")
package/docs/TOKENS.md CHANGED
@@ -95,9 +95,10 @@ from the local enforcement database.
95
95
 
96
96
  ## Not implemented
97
97
 
98
- `novahiz.config.example.json` still carries a `tokens` block (`enabled`,
99
- `trimOutputs`, `keepHeadLines`, `keepTailLines`, `keepErrorLines`,
100
- `dedupeReads`, `capOutputTokens`, `trimTools`, `readTools`). No code reads it:
101
- it is a leftover of a layer that was never shipped, and neither the plugin nor
102
- `novahiz tokens` consumes it. Configure the plugin with the environment
103
- variables above instead.
98
+ There is no `tokens` section in `novahiz.config.example.json`: no code ever
99
+ read the one it carried (`enabled`, `trimOutputs`, `keepHeadLines`,
100
+ `keepTailLines`, `keepErrorLines`, `dedupeReads`, `capOutputTokens`,
101
+ `trimTools`, `readTools`) — a leftover of a layer that was never shipped, and
102
+ neither the plugin nor `novahiz tokens` consumes it. It has been removed from
103
+ the example rather than left to contradict this document. Token economy is
104
+ configured with the environment variables above, and nothing else.
@@ -236,9 +236,11 @@ async function main() {
236
236
  }
237
237
  }
238
238
 
239
- // opencode charge les .ts posés dans <config>/plugins: E2E montre que le
240
- // registre des plugins déclare le chemin de ce répertoire, pas celui du
241
- // tableau "plugin" de la config — les deux restent en cohérence.
239
+ // opencode charge automatiquement les .ts de <config>/plugins. Preuve E2E:
240
+ // le tableau "plugin" de la config pointait vers ce depôt alors que les
241
+ // plugins charges venaient de <config>/plugins — le tableau est donc retire
242
+ // du modele de config, une entree fichier y declenchait l'avertissement
243
+ // "configured plugin path must be a directory" a chaque demarrage.
242
244
  for (const pluginFile of ["novahiz-plugin.ts", "novahiz-token-economy.ts"]) {
243
245
  const pluginSource = join(home, "adapters", "opencode", pluginFile);
244
246
  const pluginTarget = join(pluginsDir, pluginFile);
@@ -519,9 +521,6 @@ async function main() {
519
521
  "skills": {
520
522
  "paths": [skillsDir]
521
523
  },
522
- "plugin": [
523
- join(home, "adapters", "opencode", "novahiz-plugin.ts")
524
- ],
525
524
  "compaction": {
526
525
  "auto": true,
527
526
  "prune": true,
@@ -11,6 +11,10 @@
11
11
  // bascule toujours en traitement special : une tache n'execute jamais deux
12
12
  // fois en parallele (verrou `busy`), meme si la cadence est plus rapide que
13
13
  // la duree de l'execution.
14
+ // Le verrou ne s'arme jamais : `arm()` ignore les taches busy, sinon une
15
+ // echeance passee donnerait un timer 0 ms en boucle (`skip -> arm`) pendant
16
+ // toute l'execution ; la liberation re-evalue les echeances et rattrape
17
+ // l'occurrence due une seule fois.
14
18
 
15
19
  import { appendExecution, findTask, loadTasks, mutateTasks, newId, saveTasks, type Task } from "./store.ts";
16
20
  import { nextRun, parseSchedule, type Schedule } from "./cron.ts";
@@ -125,6 +129,11 @@ export class Scheduler {
125
129
  }
126
130
 
127
131
  /** Arme le prochain reveil sur l'echeance la plus proche.
132
+ * Une tache en execution (verrou `busy`) est ignoree : son echeance passe
133
+ * reste en file jusqu'a la liberation, et un reveil dessus ne ferait que
134
+ * boucler (`timer 0 ms -> wake -> skip -> arm`) pendant toute
135
+ * l'execution, a coup de lecture disque. Le reveil de liberation
136
+ * (`release`) re-evalue les echeances quand le verrou saute.
128
137
  * Retourne le delai retenu (null : aucune tache a armer), pour test. */
129
138
  arm(): number | null {
130
139
  this.stopTimer();
@@ -133,6 +142,7 @@ export class Scheduler {
133
142
  let earliest: number | null = null;
134
143
  for (const task of tasks) {
135
144
  if (!task.enabled || !task.schedule) continue;
145
+ if (this.busy.has(task.id)) continue;
136
146
  const due = this.dueMs(task);
137
147
  if (due === null) continue;
138
148
  if (earliest === null || due < earliest) earliest = due;
@@ -239,6 +249,17 @@ export class Scheduler {
239
249
  this.arm();
240
250
  }
241
251
 
252
+ /** Libere le verrou d'une tache puis re-evalue les echeances.
253
+ * Sans ce reveil, une occurrence devenue due pendant l'execution
254
+ * (`triggerNow` / `runNow` n'avancent pas le calendrier) resterait due sur
255
+ * disque en panne depuis que `arm()` ignore les taches busy. Le
256
+ * rattrapage reste unique : le premier reveil avance `nextRunAt`, le
257
+ * suivant ne trouve plus rien a lancer. */
258
+ private release(id: string): void {
259
+ this.busy.delete(id);
260
+ this.wake();
261
+ }
262
+
242
263
  /** Declenchement manuel DETACHE : pre-controles synchrones (tache connue,
243
264
  * aucune execution en cours), puis execution en arriere-plan. Le serveur
244
265
  * stdio reste disponible pour les autres requetes ; le resultat se lit
@@ -257,7 +278,7 @@ export class Scheduler {
257
278
  // non capte qui tuerait le serveur.
258
279
  })
259
280
  .finally(() => {
260
- this.busy.delete(task.id);
281
+ this.release(task.id);
261
282
  });
262
283
  return { ok: true };
263
284
  }
@@ -275,7 +296,7 @@ export class Scheduler {
275
296
  } catch (error) {
276
297
  return { ok: false, reason: error instanceof Error ? error.message : String(error) };
277
298
  } finally {
278
- this.busy.delete(task.id);
299
+ this.release(task.id);
279
300
  }
280
301
  }
281
302
 
@@ -300,7 +321,7 @@ export class Scheduler {
300
321
  .then(() => runOne(index + 1));
301
322
  };
302
323
  void runOne(0).finally(() => {
303
- this.busy.delete(task.id);
324
+ this.release(task.id);
304
325
  });
305
326
  }
306
327
 
@@ -19,17 +19,5 @@
19
19
  "autoRegister": true,
20
20
  "autoInstall": true,
21
21
  "disabled": []
22
- },
23
- "tokens": {
24
- "enabled": true,
25
- "trimOutputs": true,
26
- "maxOutputBytes": 40000,
27
- "keepHeadLines": 120,
28
- "keepTailLines": 40,
29
- "keepErrorLines": 40,
30
- "dedupeReads": true,
31
- "capOutputTokens": 0,
32
- "trimTools": ["read", "bash", "shell"],
33
- "readTools": ["read"]
34
22
  }
35
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novahiz",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Enforce AI agent skills before every file edit: prompt classifier, skill catalog, and pre-edit gate.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,113 @@
1
+ ---
2
+ name: novahiz-stitch-fidelity
3
+ description: Prove a screen matches its Stitch reference instead of asserting it — capture the running app, composite reference vs render with a fresh marker, judge on a fixed grid, then fix and re-verify until the verdict is clean. Use when a screen originates from a Stitch maquette, when the user asks whether the build is identical to the mockup, before shipping screens in a project that carries stitch/ exports, or when a fidelity todo must be closed with evidence. Different from novahiz-wcag-audit (accessibility) and impeccable (design quality judgement): this one returns a per-screen PASS or DIVERGE verdict against one specific reference image, with measured numbers. Works for Expo, Flutter and any adb-driven mobile app; needs stitch/verify.config.json in the project.
4
+ license: Apache-2.0
5
+ metadata:
6
+ author: novahiz
7
+ ---
8
+
9
+ # novahiz-stitch-fidelity
10
+
11
+ A fidelity claim is only true once a screenshot of the running app has been compared with the Stitch
12
+ reference **in the same session, with a marker that proves the image is current**. This skill is the
13
+ loop that produces that proof: reach the screen, capture it, build the comparison, judge it, fix it,
14
+ capture again.
15
+
16
+ The gate (rule `R16-stitch-fidelity`) blocks edits to screens in a project that carries Stitch
17
+ references until this skill is loaded. Load it before touching those files, ahead of the refusal.
18
+
19
+ ## When this applies
20
+
21
+ - The screen was built from a Stitch maquette (a `stitchId` in the screen registry, or a reference
22
+ file in `stitch/`).
23
+ - The user asks "identique à la maquette ?", "does it match the mockup?", "vérifie la fidélité".
24
+ - A verification todo of kind `verify` targets screens of a Stitch project.
25
+
26
+ Not this skill: judging whether a design is *good* (use `impeccable`), whether it is *accessible*
27
+ (use `novahiz-wcag-audit`), whether the *code* is correct (use `novahiz-converge`).
28
+
29
+ ## Contract: `stitch/verify.config.json`
30
+
31
+ Everything project-specific lives there, so both scripts stay framework-agnostic. Create it next to
32
+ the references if it does not exist.
33
+
34
+ ```jsonc
35
+ {
36
+ "adb": { "serial": "emulator-5554", "density": 2.625 }, // density = dp -> px factor
37
+ "launch": {
38
+ // {route} is replaced by the route of the screen. Leave null to use the ladder below.
39
+ "deepLink": "exp://10.0.2.2:8081/--/{route}", // Expo Go example; Flutter: "myapp://"
40
+ "waitForIdleMs": 4500,
41
+ "batch": 3 // screens per shell command, max 3
42
+ },
43
+ "screens": [
44
+ { "id": "otp", "route": "/otp", "ref": "stitch/10-otp-hi.png", "stitchId": "0e0fad..." },
45
+ { "id": "home", "route": "/", "ref": "stitch/01-home-hi.png" }
46
+ ],
47
+ "out": "stitch/_cmp", // git-ignored: captures, bounds, composites
48
+ "serve": { "root": "stitch", "port": 8123 },
49
+ "project": { "stitchProjectId": "14523755384223316097" } // optional, for MCP fetch
50
+ }
51
+ ```
52
+
53
+ ## The loop
54
+
55
+ Scripts live in this skill's `scripts/` directory (base directory shown with the skill). Run them
56
+ from the project root so relative paths in the config resolve.
57
+
58
+ 1. **References first.** Confirm every screen you are about to judge has a local reference PNG.
59
+ Missing one: fetch it from the Stitch MCP (`stitch.get_screen`, project id above, `htmlCode`
60
+ plus a screenshot) or from the exported `*-hi.png`, and write it to the path in `config.screens`.
61
+ No reference, no verdict — record the screen as *not evaluable*.
62
+ 2. **Server up.** The app must be running (Metro / `flutter run`) in an **external terminal**, and
63
+ the device awake and unlocked.
64
+ 3. **Capture:** `node <scripts>/stitch_capture.mjs --config stitch/verify.config.json [--only otp,home]`
65
+ Writes `<out>/<id>.png` and `<out>/<id>.bounds.json` (uiautomator bounds, for measured numbers)
66
+ and prints a per-screen status table (`ok` / `unreachable`). Non-zero exit if any selected screen
67
+ failed. Never run more than `launch.batch` screens in one command: adb drops past ~3.
68
+ 4. **Composite:** `node <scripts>/stitch_composite.mjs --config stitch/verify.config.json`
69
+ Writes `<out>/verify_<nonce>.html`: reference and capture side by side, `<img>` cache-busted with
70
+ `?v=<nonce>`, a unique marker `STITCH_FRESH_<nonce>`, and a legend per screen with the deltas
71
+ computed from the bounds files (bottom inset px, tap target sizes, text bounds).
72
+ 5. **Look at the composite, fresh.** Serve `serve.root` on `serve.port` and open
73
+ `http://localhost:<port>/_cmp/verify_<nonce>.html?v=<nonce>` in a **new browser tab**, then take a
74
+ screenshot of that tab. Before any verdict, confirm the marker `STITCH_FRESH_<nonce>` is visible
75
+ in what you just captured. Not visible → you are looking at a stale render: restart at step 5.
76
+ 6. **Judge** every screen on the grid in `references/judgment-grid.md`. Each line gets PASS or
77
+ DIVERGE **with a number** (px, dp, hex, token name). A DIVERGE without a measurement is a guess.
78
+ 7. **Fix** the divergences in the app code, then re-run steps 3 → 5 for those screens only.
79
+ 8. **Close** with the table: screen, verdict, measurement, commit. Screens that stayed unreachable
80
+ are reported as *not evaluable* with the reason. Never report a screen as matching because the
81
+ code "looks like" the reference.
82
+
83
+ Repeat 3 → 7 until every reachable screen is PASS, or until the remaining divergences are recorded
84
+ as known and accepted.
85
+
86
+ ## Reaching a screen
87
+
88
+ Not every screen is one deep link away. Use the ladder in order and stop when one works:
89
+
90
+ 1. **Deep link** — `config.launch.deepLink`, cold start when the route is guarded.
91
+ 2. **Initial route** — start the app and the screen appears first (useful for `/`).
92
+ 3. **Taps** — drive the real UI: `uiautomator dump`, read `bounds`, `adb shell input tap x y`.
93
+ Prefer this over hacks; a screen only reachable through taps must be reached through taps.
94
+ 4. **Not evaluable** — auth wall, backend state, or an animation that cannot be settled. Record the
95
+ screen with the reason and move on. **This is a valid outcome; a fabricated verdict is not.**
96
+
97
+ Details per framework (Expo vs Flutter, launch, wait, gesture insets) in
98
+ `references/capture-recipes.md`.
99
+
100
+ ## Failure notes
101
+
102
+ - `uiautomator dump` fails with "idle state" while an animation runs: wait, retry once, then tap a
103
+ neutral point to settle the UI.
104
+ - adb silently truncates `screencap`: always use `adb exec-out screencap -p`.
105
+ - dp → px: multiply by `adb.density`. Never compare raw dp to raw px.
106
+ - Composites are git-ignored artifacts. Commit app code only — never `stitch/_cmp/`.
107
+ - The reference PNG must be the *hi* export, not a thumbnail; a scaled reference makes every
108
+ spacing comparison wrong.
109
+
110
+ ## Sources
111
+
112
+ Loop, anti-staleness protocol and access ladder synthesised from the Novahiz Stitch integration
113
+ sessions (Expo + adb). Reference: Stitch exports and the project's own `stitch/` folder.
@@ -0,0 +1,29 @@
1
+ {
2
+ // Global defaults for the novahiz-stitch-fidelity skill.
3
+ // Shipped with Novahiz: every project inherits these, a project's own
4
+ // stitch/verify.config.json only overrides what is really project specific
5
+ // (its screens, routes, references and deep link template).
6
+ "adb": {
7
+ "serial": "auto",
8
+ "autoStart": true,
9
+ "density": null,
10
+ "bootTimeoutMs": 180000
11
+ },
12
+ "launch": {
13
+ "deepLink": null,
14
+ "waitForIdleMs": 6000,
15
+ "maxWaitMs": 30000,
16
+ "pollMs": 800,
17
+ "tapSettleMs": 900,
18
+ "batch": 3,
19
+ "coldStart": false,
20
+ "resetScroll": true,
21
+ "scrollSettleMs": 900
22
+ },
23
+ "ready": {
24
+ "minTexts": 3,
25
+ "minNodes": 12
26
+ },
27
+ "out": "stitch/_cmp",
28
+ "serve": { "root": "stitch", "port": 8123 }
29
+ }
@@ -0,0 +1,74 @@
1
+ # Capture recipes
2
+
3
+ Per-framework launch, device commands, and the anti-staleness protocol. Everything else lives in
4
+ `SKILL.md` and `stitch/verify.config.json`.
5
+
6
+ ## Device basics
7
+
8
+ ```sh
9
+ adb devices # serial goes in config.adb.serial
10
+ adb -s <serial> shell wm size # e.g. 1080x2424
11
+ adb -s <serial> shell wm density # e.g. 420 -> 2.625 px per dp
12
+ adb -s <serial> shell input keyevent 82 # wake
13
+ adb -s <serial> exec-out screencap -p > out.png # never plain `screencap` (truncates)
14
+ adb -s <serial> shell uiautomator dump /sdcard/d.xml && adb -s <serial> pull /sdcard/d.xml
15
+ adb -s <serial> shell input tap <x> <y>
16
+ ```
17
+
18
+ `uiautomator dump` fails with *"unable to get idle state"* while an animation runs. Wait, retry once,
19
+ then tap an empty area of the screen to settle it before the second attempt.
20
+
21
+ **Batch at most 3 screens per shell invocation.** Past that adb drops the connection and the batch
22
+ dies midway; the capture script enforces `launch.batch`.
23
+
24
+ ## Expo (React Native / Expo Go)
25
+
26
+ - Metro runs in an **external terminal** (`npx expo start`), never as a background shell of the agent.
27
+ - Emulator reaches the host at `10.0.2.2`: `exp://10.0.2.2:8081/--/<route>`.
28
+ - Cold start a guarded route: `adb shell am force-stop <package>` first, then
29
+ `adb shell am start -a android.intent.action.VIEW -d "<deeplink>"`.
30
+ - Route `/foo` maps to `.../--/foo`; the app root is `--/` (with a trailing slash).
31
+ - If a route renders blank, the file may be misnamed (`index.tsx` serves `/`, not `/index`): verify
32
+ with `uiautomator dump` before blaming the capture.
33
+
34
+ ## Flutter
35
+
36
+ - `flutter run` in an external terminal; keep the same hot-reload session while fixing.
37
+ - Deep link through the registered scheme: `adb shell am start -a android.intent.action.VIEW -d "<scheme>://<path>"`.
38
+ - Widgets that are not routable (dialogs, bottom sheets) are reached by tap: dump, read bounds,
39
+ `input tap`.
40
+ - Take screenshots from the device, never from the IDE preview or a widget test — neither renders
41
+ the real theme, real fonts, or real insets.
42
+
43
+ ## Reaching a screen (the ladder)
44
+
45
+ 1. Deep link from `launch.deepLink`.
46
+ 2. Initial route (cold start, screen appears first).
47
+ 3. Taps driven by `uiautomator dump` bounds.
48
+ 4. **Not evaluable** — record `unreachable` with the reason (auth wall, backend state, animation
49
+ that cannot settle). A recorded `unreachable` is an honest result; a fabricated PASS is not.
50
+
51
+ ## Anti-staleness protocol (mandatory before any verdict)
52
+
53
+ A rendered file read back by an agent can be stale — the viewer, the cache, or the tool may hand you
54
+ the previous image. Never compare what you did not just prove is fresh.
55
+
56
+ 1. Build the composite (`stitch_composite.mjs`) — it emits `STITCH_FRESH_<nonce>` in the page and
57
+ appends `?v=<nonce>` to **every** `<img>`.
58
+ 2. Serve `serve.root` on `serve.port` (`npx http-server stitch -p 8123`, or
59
+ `python -m http.server 8123 -d stitch`, in an external terminal).
60
+ 3. Open `http://localhost:<port>/_cmp/verify_<nonce>.html?v=<nonce>` in a **new browser tab**.
61
+ 4. Screenshot that tab.
62
+ 5. Confirm the marker string is present in the screenshot / DOM you just captured. Missing → the
63
+ render is stale: reload with a new `?v=` and repeat from step 3.
64
+ 6. Only then form a verdict.
65
+
66
+ ## Fix loop
67
+
68
+ Change the screen code → hot reload → `stitch_capture.mjs --only <ids>` → `stitch_composite.mjs` →
69
+ steps 3-5 of the protocol → re-judge **only those screens**. Cap iterations; after the cap, record
70
+ the remaining divergences with their measurements instead of looping silently.
71
+
72
+ ## Out of scope for commits
73
+
74
+ `stitch/_cmp/` holds captures, bounds JSON and composites — all git-ignored. Commit app code only.