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 +56 -0
- package/README.md +4 -4
- package/catalog/categories.json +14 -0
- package/catalog/providers.json +25 -0
- package/catalog/rules.json +19 -0
- package/dist/gate.js +85 -1
- package/docs/TOKENS.md +7 -6
- package/install/install.mjs +5 -6
- package/mcp/clepsydre/src/scheduler.ts +24 -3
- package/novahiz.config.example.json +0 -12
- package/package.json +1 -1
- package/skills/novahiz-stitch-fidelity/SKILL.md +113 -0
- package/skills/novahiz-stitch-fidelity/defaults/verify.config.defaults.json +29 -0
- package/skills/novahiz-stitch-fidelity/references/capture-recipes.md +74 -0
- package/skills/novahiz-stitch-fidelity/references/judgment-grid.md +67 -0
- package/skills/novahiz-stitch-fidelity/scripts/stitch_capture.mjs +598 -0
- package/skills/novahiz-stitch-fidelity/scripts/stitch_composite.mjs +271 -0
- package/src/gate.ts +77 -1
- package/src/spec.ts +6 -0
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,
|
|
5
|
+
17 catégories, 99 skills, 13 gate rules, 8 MCP providers — tout déterministe, tout local, tout JSON.
|
|
6
6
|
|
|
7
|
-
> **Version 0.
|
|
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**, **
|
|
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
|
|
313
|
+
Novahiz livre 99 skills couvrant toutes les catégories :
|
|
314
314
|
|
|
315
315
|
| Catégorie | Skills | Objectif |
|
|
316
316
|
|-----------|--------|----------|
|
package/catalog/categories.json
CHANGED
|
@@ -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",
|
package/catalog/providers.json
CHANGED
|
@@ -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",
|
package/catalog/rules.json
CHANGED
|
@@ -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
|
|
99
|
-
|
|
100
|
-
`
|
|
101
|
-
|
|
102
|
-
`novahiz tokens` consumes it.
|
|
103
|
-
|
|
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.
|
package/install/install.mjs
CHANGED
|
@@ -236,9 +236,11 @@ async function main() {
|
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
-
// opencode charge les .ts
|
|
240
|
-
//
|
|
241
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
@@ -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.
|