jig-ui 0.18.10 → 0.20.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 +91 -0
- package/README.md +4 -1
- package/dist/index.js +483 -59
- package/package.json +1 -1
- package/rules/01-modes.md +1 -1
- package/rules/03-patterns.md +6 -1
- package/templates/COMMAND.md.tmpl +165 -18
- package/templates/command-metadata.json +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,96 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.20.0 (2026-09-27)
|
|
4
|
+
|
|
5
|
+
Critiques count owner rulings apart and say what the last round fixed; the gate
|
|
6
|
+
keeps decisions the owner's, and tweak works on its first real pages.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **A `ruled` verdict.** A finding the owner has already decided is judged
|
|
11
|
+
`ruled`, citing the decision's heading in `ruling`, and counted apart
|
|
12
|
+
(`ruled=`). On jig-site five of a header critique's 15 findings were the
|
|
13
|
+
owner's rulings, counted as findings and sent back to make.
|
|
14
|
+
- **What happened to the last round's findings.** `jig verdicts` compares a
|
|
15
|
+
critique with the one before it in git and prints how many were fixed, are
|
|
16
|
+
still open, were ruled, and are new, with the ids. The report carries it.
|
|
17
|
+
- **`jig verdicts --reprobe`** re-takes probes taken on an older page. The
|
|
18
|
+
gate and `--reprobe` now find the page from the probes themselves, so a spec
|
|
19
|
+
whose `surface:` is a sentence no longer leaves stale probes blocking.
|
|
20
|
+
- **The probe names text drawn in a weight or style no loaded face covers**,
|
|
21
|
+
which the browser fakes from the nearest face. It is an error, like a page in
|
|
22
|
+
the default font. Probe version 8.
|
|
23
|
+
- **`jig update` names the tokens** each refreshed mode file gained or lost,
|
|
24
|
+
and those an edited one did not get, since anything aliasing tokens (a
|
|
25
|
+
Tailwind `@theme`) needs the same change.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **`decide` keeps the owner's reasons apart from its own.** A `Why:` is the
|
|
30
|
+
owner's words in quotation marks or `not given`; anything the agent adds goes
|
|
31
|
+
under `**Why (inferred):**`. The gate checks each decision the session wrote
|
|
32
|
+
or changed.
|
|
33
|
+
- **`make` does not edit DECISIONS.md.** The gate stops a make session that
|
|
34
|
+
changed it; decisions go through `decide` or `tweak`.
|
|
35
|
+
- **A spec that no longer matches its approved drawing is not approved.** In
|
|
36
|
+
`spec` and `make`, each region the spec lists must be labelled in its size's
|
|
37
|
+
frame. Region names are read as the spec means them: a parenthetical
|
|
38
|
+
qualifies, "(… only)" is conditional, and a list names several regions.
|
|
39
|
+
- **Critique's reader arms do not read the page's history**: no earlier
|
|
40
|
+
critiques, no git log. Which findings were fixed is the CLI's question.
|
|
41
|
+
- **P-14 says how a menu in a fixed header behaves**: it lies over the page, a
|
|
42
|
+
tap outside closes it and does nothing else, controls in the header row work
|
|
43
|
+
on the first tap, and a long list scrolls inside itself.
|
|
44
|
+
- **M-01's script budget is "none by default"**, not an absolute zero: a
|
|
45
|
+
feature may have script, named in a comment and as small as it allows.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- **`tweak`'s gate blocked both of its first real uses, on jig-site.** Three
|
|
50
|
+
causes, all in the gate:
|
|
51
|
+
- It took the session's command from any line that mentioned `/jig`, so the
|
|
52
|
+
command file Jig loads into the session, and tool results quoting it, read
|
|
53
|
+
as `/jig critique`. Only what the user sent counts now.
|
|
54
|
+
- It looked for the critique under the spec's `surface:` field, which on
|
|
55
|
+
jig-site is a sentence. A critique lives under the spec's file name, as
|
|
56
|
+
the command file says.
|
|
57
|
+
- It held the page to its structure at the commit that first approved the
|
|
58
|
+
mockup, before five confirmed, critiqued rounds. It now holds the page to
|
|
59
|
+
its structure at its last critique.
|
|
60
|
+
|
|
61
|
+
The gate also works on the spec the command names (`/jig tweak site-header`),
|
|
62
|
+
not whichever spec was written last.
|
|
63
|
+
- **`jig verdicts` refuses reasons that judge nothing.** On jig-site a render
|
|
64
|
+
arm stopped with 33 of 34 verdicts reading "DRAFT, being refined", and the
|
|
65
|
+
review passed with full counts. A placeholder reason is now an error, and so
|
|
66
|
+
is one reason given word for word by four or more `ok` or `finding` verdicts
|
|
67
|
+
in a file. `n/a` verdicts may still share a reason, since one absence can
|
|
68
|
+
clear many rules.
|
|
69
|
+
|
|
70
|
+
## 0.19.0 (2026-09-27)
|
|
71
|
+
|
|
72
|
+
A new command, `tweak`, for a small change to a built page that its approved
|
|
73
|
+
mockup does not show.
|
|
74
|
+
|
|
75
|
+
### Added
|
|
76
|
+
|
|
77
|
+
- **`/jig tweak`: a small change to a built page, in one pass.** On jig-site
|
|
78
|
+
half a day's rounds were changes the approved mockup does not show: a word
|
|
79
|
+
that wrapped, a mark's colour, how a version is written. Each went through
|
|
80
|
+
`decide`, `spec`, `make` and a full critique of every page it touched, or
|
|
81
|
+
would have been an edit by hand, which is the drift Jig exists to catch.
|
|
82
|
+
`tweak` records the decision if the change is one, brings the spec in line if
|
|
83
|
+
it disagrees, builds the change, and has a reader that did not make it
|
|
84
|
+
re-judge only the verdicts the change could affect. The gate bounds it: the
|
|
85
|
+
regions under the spec's `sizes:` must match the ones approved with the
|
|
86
|
+
mockup, the drawing must be unchanged since its approval, and the tweak may
|
|
87
|
+
change only the verdicts it names in `tweak.json`, each re-judged and stamped.
|
|
88
|
+
A change to structure is refused and routed through `spec` and `mockup`.
|
|
89
|
+
- **A critique's lock records each verdict.** `verdicts.lock` now holds a digest
|
|
90
|
+
per verdict as well as the whole-file checksum, so the gate can tell which
|
|
91
|
+
verdicts a tweak changed. A lock written before this release is trusted once
|
|
92
|
+
by the first tweak and rewritten.
|
|
93
|
+
|
|
3
94
|
## 0.18.10 (2026-09-26)
|
|
4
95
|
|
|
5
96
|
The rule against a stranded word holds in every browser, and the probe checks
|
package/README.md
CHANGED
|
@@ -250,7 +250,10 @@ Adopting Jig everywhere is not the price of using it anywhere. The rules apply
|
|
|
250
250
|
to what you point them at:
|
|
251
251
|
|
|
252
252
|
- **New work follows the loop** — decide, spec, mockup, make, critique — and the
|
|
253
|
-
Stop hook holds it to that, if you asked for the hook.
|
|
253
|
+
Stop hook holds it to that, if you asked for the hook. A small change to a
|
|
254
|
+
built page that its approved mockup does not show (a word, a colour, how text
|
|
255
|
+
wraps) goes through `tweak` instead: decided, specced, built and re-judged in
|
|
256
|
+
one pass, with the page's structure held as approved.
|
|
254
257
|
- **Old pages sit where they are.** They are not rewritten, and the default
|
|
255
258
|
`check` says nothing about a file nobody has touched.
|
|
256
259
|
- **`jig.config.json` can exempt paths** you have no intention of revisiting, and
|
package/dist/index.js
CHANGED
|
@@ -46,7 +46,7 @@ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as rea
|
|
|
46
46
|
import { join as join7 } from "path";
|
|
47
47
|
|
|
48
48
|
// src/adapters/types.ts
|
|
49
|
-
var COMMAND_DESCRIPTION = "Run a Jig design-system command: set the project up, plan and build one feature at a time (decide, spec, mockup, make, critique), check the UI against the rules, or refresh the install.";
|
|
49
|
+
var COMMAND_DESCRIPTION = "Run a Jig design-system command: set the project up, plan and build one feature at a time (decide, spec, mockup, make, critique), make a small change to a built page (tweak), check the UI against the rules, or refresh the install.";
|
|
50
50
|
var SKILL_DESCRIPTION = "Design system rules for generating and reviewing UI. Load before building any interface.";
|
|
51
51
|
function assertSafeRelPath(relPath, adapterName) {
|
|
52
52
|
const isAbsolute2 = relPath.startsWith("/") || /^[A-Za-z]:/.test(relPath);
|
|
@@ -941,6 +941,16 @@ function removeLegacyFiles(projectRoot, relPaths) {
|
|
|
941
941
|
}
|
|
942
942
|
|
|
943
943
|
// src/commands/update.ts
|
|
944
|
+
function declaredTokens(css) {
|
|
945
|
+
return new Set([...css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/(--[\w-]+)\s*:/g)].map((m) => m[1]));
|
|
946
|
+
}
|
|
947
|
+
function tokenChange(file, before, after, skipped = false) {
|
|
948
|
+
const was = declaredTokens(before);
|
|
949
|
+
const now = declaredTokens(after);
|
|
950
|
+
const added = [...now].filter((t) => !was.has(t)).sort();
|
|
951
|
+
const removed = [...was].filter((t) => !now.has(t)).sort();
|
|
952
|
+
return added.length || removed.length ? { file, added, removed, ...skipped ? { skipped } : {} } : void 0;
|
|
953
|
+
}
|
|
944
954
|
function update(opts) {
|
|
945
955
|
const resolved = resolveAllInstalled(opts.projectRoot, opts.homeDir);
|
|
946
956
|
if (resolved.length === 0) {
|
|
@@ -972,7 +982,8 @@ function update(opts) {
|
|
|
972
982
|
skipped,
|
|
973
983
|
fromVersion: resolved[0].manifest.version,
|
|
974
984
|
toVersion: opts.version,
|
|
975
|
-
targets
|
|
985
|
+
targets,
|
|
986
|
+
tokens: initResult.tokens
|
|
976
987
|
};
|
|
977
988
|
}
|
|
978
989
|
function updateTarget(opts, resolved) {
|
|
@@ -1058,6 +1069,7 @@ function updateTarget(opts, resolved) {
|
|
|
1058
1069
|
function updateInitFiles(opts) {
|
|
1059
1070
|
const updated = [];
|
|
1060
1071
|
const skipped = [];
|
|
1072
|
+
const tokens = [];
|
|
1061
1073
|
const tokensDir = join10(opts.packageRoot, "tokens");
|
|
1062
1074
|
const initManifest = readInitManifest(opts.projectRoot);
|
|
1063
1075
|
if (initManifest) {
|
|
@@ -1068,11 +1080,21 @@ function updateInitFiles(opts) {
|
|
|
1068
1080
|
if (!file.startsWith("mode.")) continue;
|
|
1069
1081
|
const keys = Object.keys(initManifest.files).filter((k) => k === file || k.endsWith(`/${file}`));
|
|
1070
1082
|
for (const key of keys) {
|
|
1083
|
+
let before = "";
|
|
1084
|
+
try {
|
|
1085
|
+
before = readFileSync7(join10(opts.projectRoot, ...key.split("/")), "utf8");
|
|
1086
|
+
} catch {
|
|
1087
|
+
}
|
|
1088
|
+
const fresh = readFileSync7(join10(tokensDir, file), "utf8");
|
|
1071
1089
|
if (isInitFileModified(opts.projectRoot, key, initManifest)) {
|
|
1072
1090
|
skipped.push(key);
|
|
1091
|
+
const change2 = tokenChange(key, before, fresh, true);
|
|
1092
|
+
if (change2) tokens.push(change2);
|
|
1073
1093
|
continue;
|
|
1074
1094
|
}
|
|
1075
|
-
const
|
|
1095
|
+
const change = tokenChange(key, before, fresh);
|
|
1096
|
+
if (change) tokens.push(change);
|
|
1097
|
+
const content = vendorHeader(file, opts.version, "css", null) + fresh;
|
|
1076
1098
|
updated.push(writer.write(key, content));
|
|
1077
1099
|
initChanged = true;
|
|
1078
1100
|
}
|
|
@@ -1080,7 +1102,7 @@ function updateInitFiles(opts) {
|
|
|
1080
1102
|
Object.assign(initFiles, writer.files);
|
|
1081
1103
|
if (initChanged) writeInitManifest(opts.projectRoot, { ...initManifest, version: opts.version, files: initFiles });
|
|
1082
1104
|
}
|
|
1083
|
-
return { updated, skipped };
|
|
1105
|
+
return { updated, skipped, tokens };
|
|
1084
1106
|
}
|
|
1085
1107
|
|
|
1086
1108
|
// src/commands/explain.ts
|
|
@@ -5749,7 +5771,7 @@ import { fileURLToPath as fileURLToPath2 } from "url";
|
|
|
5749
5771
|
import { join as join33, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
|
|
5750
5772
|
|
|
5751
5773
|
// src/probe/script.ts
|
|
5752
|
-
var PROBE_VERSION =
|
|
5774
|
+
var PROBE_VERSION = 8;
|
|
5753
5775
|
var PROBE_SCRIPT = `(async () => {
|
|
5754
5776
|
const doc = document.documentElement;
|
|
5755
5777
|
// checkVisibility also sees content a closed <details> hides: Chromium hides
|
|
@@ -5913,6 +5935,32 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5913
5935
|
focusReturned: document.activeElement === toggle,
|
|
5914
5936
|
};
|
|
5915
5937
|
}
|
|
5938
|
+
// A weight or style the page asks for that no loaded face covers: the
|
|
5939
|
+
// browser draws it by thickening or slanting the nearest face it has. On
|
|
5940
|
+
// jig-site a heading asked for a weight the site never loaded and every
|
|
5941
|
+
// review passed it, since the page was styled and nothing was missing.
|
|
5942
|
+
// Only web fonts are judged; a family with no @font-face is the system's.
|
|
5943
|
+
if (document.fonts) await document.fonts.ready;
|
|
5944
|
+
const faces = document.fonts ? [...document.fonts] : [];
|
|
5945
|
+
const unquote = (f) => f.trim().replace(/^["']|["']$/g, '').toLowerCase();
|
|
5946
|
+
const weightOf = (w) => (w === 'normal' ? 400 : w === 'bold' ? 700 : Number(w));
|
|
5947
|
+
const covers = (face, w) => { const [lo, hi = lo] = String(face.weight).trim().split(/\\s+/).map(weightOf); return w >= lo && w <= hi; };
|
|
5948
|
+
const fauxFaces = [];
|
|
5949
|
+
const fauxSeen = new Set();
|
|
5950
|
+
for (const el of [...document.body.querySelectorAll('*')].slice(0, 4000)) {
|
|
5951
|
+
if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()) || !vis(el)) continue;
|
|
5952
|
+
const cs = getComputedStyle(el);
|
|
5953
|
+
const first = cs.fontFamily.split(',')[0];
|
|
5954
|
+
const own = faces.filter((f) => unquote(f.family) === unquote(first));
|
|
5955
|
+
if (!own.length) continue;
|
|
5956
|
+
const weight = weightOf(cs.fontWeight);
|
|
5957
|
+
const italic = cs.fontStyle !== 'normal';
|
|
5958
|
+
if (own.some((f) => f.status === 'loaded' && covers(f, weight) && (f.style !== 'normal') === italic)) continue;
|
|
5959
|
+
const key = unquote(first) + ' ' + weight + ' ' + italic;
|
|
5960
|
+
if (fauxSeen.has(key)) continue;
|
|
5961
|
+
fauxSeen.add(key);
|
|
5962
|
+
fauxFaces.push({ family: first.trim().replace(/^["']|["']$/g, ''), weight, style: italic ? 'italic' : 'normal', text: (el.innerText || '').trim().slice(0, 40) });
|
|
5963
|
+
}
|
|
5916
5964
|
return JSON.stringify({
|
|
5917
5965
|
jigProbe: ${PROBE_VERSION},
|
|
5918
5966
|
url: location.href,
|
|
@@ -5929,6 +5977,7 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5929
5977
|
navLinksVisible: navAtRest,
|
|
5930
5978
|
strandedCount: stranded.length,
|
|
5931
5979
|
strandedWords: stranded.slice(0, 6),
|
|
5980
|
+
fauxFaces: fauxFaces.slice(0, 6),
|
|
5932
5981
|
head: {
|
|
5933
5982
|
title: (document.title || '').trim(),
|
|
5934
5983
|
description: (document.querySelector('meta[name=description]') || {}).content || '',
|
|
@@ -6269,6 +6318,18 @@ async function withPageUrl(projectRoot, abs, serve, use) {
|
|
|
6269
6318
|
await server.close();
|
|
6270
6319
|
}
|
|
6271
6320
|
}
|
|
6321
|
+
function recordedPage(projectRoot, surface) {
|
|
6322
|
+
const dir = join33(projectRoot, ".jig", "critique", surface);
|
|
6323
|
+
if (!existsSync19(dir)) return void 0;
|
|
6324
|
+
for (const f of readdirSync12(dir).filter((name) => /^probe-\d+\.json$/.test(name)).sort()) {
|
|
6325
|
+
try {
|
|
6326
|
+
const probe = JSON.parse(readFileSync26(join33(dir, f), "utf8"));
|
|
6327
|
+
if (probe.pageFile && existsSync19(resolve4(projectRoot, probe.pageFile))) return probe.pageFile;
|
|
6328
|
+
} catch {
|
|
6329
|
+
}
|
|
6330
|
+
}
|
|
6331
|
+
return void 0;
|
|
6332
|
+
}
|
|
6272
6333
|
async function ensureProbes(opts) {
|
|
6273
6334
|
const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
6274
6335
|
const abs = resolve4(opts.projectRoot, opts.page);
|
|
@@ -6371,6 +6432,10 @@ function probeContradictions(probes, verdictOf, indexable = true) {
|
|
|
6371
6432
|
if (p.defaultFont) {
|
|
6372
6433
|
errors.push(`${at(p)}: the page renders in the browser's default font (${p.bodyFont ?? "unknown"}) \u2014 its styles are not applying. No review of this page can pass until they do.`);
|
|
6373
6434
|
}
|
|
6435
|
+
if (p.fauxFaces?.length) {
|
|
6436
|
+
const shown = p.fauxFaces.map((f) => `${f.family} ${f.weight}${f.style === "italic" ? " italic" : ""} ("${f.text}")`).join(", ");
|
|
6437
|
+
errors.push(`${at(p)}: the page asks for ${shown}, and no loaded face covers ${p.fauxFaces.length === 1 ? "it" : "them"}, so the browser fakes the weight or slant from the nearest face it has. Load that weight, or use one the page already loads.`);
|
|
6438
|
+
}
|
|
6374
6439
|
if (p.unresolvedTokens.length) {
|
|
6375
6440
|
errors.push(`${at(p)}: ${p.unresolvedTokens.length} token(s) have no value in the browser (${p.unresolvedTokens.slice(0, 6).join(", ")}) \u2014 every property using them is dropped (H-117).`);
|
|
6376
6441
|
}
|
|
@@ -6473,6 +6538,43 @@ function decisionHeadings(projectRoot) {
|
|
|
6473
6538
|
}
|
|
6474
6539
|
return headings;
|
|
6475
6540
|
}
|
|
6541
|
+
function decisionSections(body) {
|
|
6542
|
+
const sections = /* @__PURE__ */ new Map();
|
|
6543
|
+
const lines = body.split("\n");
|
|
6544
|
+
let name;
|
|
6545
|
+
let text = [];
|
|
6546
|
+
const close = () => {
|
|
6547
|
+
if (name && !NOT_A_DECISION.test(name) && !sections.has(name)) sections.set(name, text.join("\n").trim());
|
|
6548
|
+
};
|
|
6549
|
+
for (const line of lines) {
|
|
6550
|
+
const heading = /^(#{1,3})\s+(.+?)\s*$/.exec(line);
|
|
6551
|
+
if (heading) {
|
|
6552
|
+
close();
|
|
6553
|
+
name = heading[1].length >= 2 ? heading[2].replace(/[`*]/g, "").trim() : void 0;
|
|
6554
|
+
text = [];
|
|
6555
|
+
} else text.push(line);
|
|
6556
|
+
}
|
|
6557
|
+
close();
|
|
6558
|
+
return sections;
|
|
6559
|
+
}
|
|
6560
|
+
function unsourcedReasons(current, before) {
|
|
6561
|
+
const then = decisionSections(before);
|
|
6562
|
+
const problems = [];
|
|
6563
|
+
for (const [name, text] of decisionSections(current)) {
|
|
6564
|
+
if (then.get(name) === text) continue;
|
|
6565
|
+
const whys = text.split(/\n\s*\n/).filter((p) => /^\s*\*\*Why\b[^*]*:\*\*/.test(p));
|
|
6566
|
+
for (const why of whys) {
|
|
6567
|
+
const label = /^\s*\*\*(Why\b[^*]*):\*\*/.exec(why)[1];
|
|
6568
|
+
const said = why.replace(/^\s*\*\*Why\b[^*]*:\*\*/, "").trim();
|
|
6569
|
+
if (/inferred/i.test(label)) continue;
|
|
6570
|
+
if (/^not given\b/i.test(said)) continue;
|
|
6571
|
+
if (/["“][^"”]{3,}["”]/.test(said)) continue;
|
|
6572
|
+
problems.push(`"${name}": its \`**${label}:**\` is not the owner's words in quotation marks. Quote what the owner said, write \`not given\`, or put what you added under \`**Why (inferred):**\`.`);
|
|
6573
|
+
break;
|
|
6574
|
+
}
|
|
6575
|
+
}
|
|
6576
|
+
return problems;
|
|
6577
|
+
}
|
|
6476
6578
|
|
|
6477
6579
|
// src/check/spec-shape.ts
|
|
6478
6580
|
import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
|
|
@@ -6485,6 +6587,13 @@ function newestSpec(projectRoot) {
|
|
|
6485
6587
|
const newest = files.map((f) => ({ f, at: statSync4(join36(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
|
|
6486
6588
|
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync29(join36(dir, newest), "utf8") };
|
|
6487
6589
|
}
|
|
6590
|
+
function specFor(projectRoot, surface) {
|
|
6591
|
+
if (surface) {
|
|
6592
|
+
const path = join36(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
6593
|
+
if (existsSync22(path)) return { path: `.jig/specs/${surface}.spec.md`, slug: surface, body: readFileSync29(path, "utf8") };
|
|
6594
|
+
}
|
|
6595
|
+
return newestSpec(projectRoot);
|
|
6596
|
+
}
|
|
6488
6597
|
function specProblems(spec) {
|
|
6489
6598
|
const parts = spec.body.split(/^---\s*$/m);
|
|
6490
6599
|
const front = parts.length >= 3 ? parts[1] : "";
|
|
@@ -6562,7 +6671,28 @@ function specIndexableField(front) {
|
|
|
6562
6671
|
|
|
6563
6672
|
// src/commands/verdicts.ts
|
|
6564
6673
|
var VERDICTS = ["ok", "finding", "n/a"];
|
|
6674
|
+
var RULE_VERDICTS = [...VERDICTS, "ruled"];
|
|
6565
6675
|
var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (find|read|access) (the )?rule|not in (the )?(accessible )?corpus)\b/i;
|
|
6676
|
+
var PLACEHOLDER = /^\W*(draft|tbd|todo|placeholder|wip|fixme|xxx|lorem ipsum)\W*($|[,.;:(\u2014-])|\bbeing refined\b|\bto be (judged|written|refined|filled in|completed)\b|\bfill (this )?in later\b/i;
|
|
6677
|
+
var REPEAT_LIMIT = 4;
|
|
6678
|
+
function reasonProblems(file, judged, errors) {
|
|
6679
|
+
const shared = /* @__PURE__ */ new Map();
|
|
6680
|
+
for (const { label, verdict, reason } of judged) {
|
|
6681
|
+
const bare2 = reason.replace(/^[A-Z]{1,2}-\d+[a-z0-9-]*\s*[:\u2014-]?\s*/i, "");
|
|
6682
|
+
if (PLACEHOLDER.test(bare2)) {
|
|
6683
|
+
errors.push(`${file}: ${label} \u2014 "${reason}" is a placeholder, not a judgment. Judge it against the page and write what you saw.`);
|
|
6684
|
+
continue;
|
|
6685
|
+
}
|
|
6686
|
+
if (verdict === "n/a") continue;
|
|
6687
|
+
const key = reason.toLowerCase().replace(/\s+/g, " ");
|
|
6688
|
+
shared.set(key, [...shared.get(key) ?? [], label]);
|
|
6689
|
+
}
|
|
6690
|
+
for (const [, labels] of shared) {
|
|
6691
|
+
if (labels.length < REPEAT_LIMIT) continue;
|
|
6692
|
+
const reason = judged.find((j) => j.label === labels[0]).reason;
|
|
6693
|
+
errors.push(`${file}: ${labels.length} verdicts give the same reason, "${reason}" (${labels.slice(0, 5).join(", ")}${labels.length > 5 ? ", \u2026" : ""}). A reason names what on this page holds or breaks that one rule; judge each of them.`);
|
|
6694
|
+
}
|
|
6695
|
+
}
|
|
6566
6696
|
function readJson(path, errors) {
|
|
6567
6697
|
if (!existsSync23(path)) return null;
|
|
6568
6698
|
try {
|
|
@@ -6592,7 +6722,7 @@ function specNeedsNav(projectRoot, surface) {
|
|
|
6592
6722
|
const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
|
|
6593
6723
|
return navField || navRegion;
|
|
6594
6724
|
}
|
|
6595
|
-
function checkArm(name, file, required, otherPass, extraAllowed, extraRequired, errors) {
|
|
6725
|
+
function checkArm(name, file, required, otherPass, extraAllowed, extraRequired, errors, decisionList = []) {
|
|
6596
6726
|
const total = required.length + extraRequired.length;
|
|
6597
6727
|
if (file === null) {
|
|
6598
6728
|
errors.push(`${name}.json is missing \u2014 the ${name} arm has not run, or did not write its verdicts.`);
|
|
@@ -6601,7 +6731,9 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
6601
6731
|
const list = Array.isArray(file.verdicts) ? file.verdicts : [];
|
|
6602
6732
|
if (!Array.isArray(file.verdicts)) errors.push(`${name}.json has no "verdicts" array.`);
|
|
6603
6733
|
const seen = /* @__PURE__ */ new Set();
|
|
6734
|
+
const reasons = [];
|
|
6604
6735
|
let findings = 0;
|
|
6736
|
+
let ruled = 0;
|
|
6605
6737
|
for (const v of list) {
|
|
6606
6738
|
const written = typeof v.id === "string" ? v.id.trim() : "";
|
|
6607
6739
|
const id = written.toUpperCase();
|
|
@@ -6619,21 +6751,30 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
6619
6751
|
errors.push(pass === "mechanical" ? `${name}.json: ${id} is a mechanical rule \u2014 \`jig check\` decides it, so it has no verdict here. Remove it.` : pass ? `${name}.json: ${id} is a pass: ${pass} rule \u2014 it belongs to the other arm.` : `${name}.json: ${written} is not a rule or spec in this corpus. Run \`jig explain ${written}\`; an id that does not resolve is not a verdict.`);
|
|
6620
6752
|
continue;
|
|
6621
6753
|
}
|
|
6622
|
-
if (typeof v.verdict !== "string" || !
|
|
6623
|
-
errors.push(`${name}.json: ${id} has verdict ${JSON.stringify(v.verdict)} \u2014 it must be ok, finding or n/a.`);
|
|
6754
|
+
if (typeof v.verdict !== "string" || !RULE_VERDICTS.includes(v.verdict)) {
|
|
6755
|
+
errors.push(`${name}.json: ${id} has verdict ${JSON.stringify(v.verdict)} \u2014 it must be ok, finding, ruled or n/a.`);
|
|
6624
6756
|
continue;
|
|
6625
6757
|
}
|
|
6758
|
+
if (v.verdict === "ruled") {
|
|
6759
|
+
const ruling = typeof v.ruling === "string" ? v.ruling.trim() : "";
|
|
6760
|
+
const match = decisionList.find((d) => d.toLowerCase() === ruling.toLowerCase());
|
|
6761
|
+
if (!ruling) errors.push(`${name}.json: ${id} is ruled, but names no \`ruling\`. A ruled verdict cites the DECISIONS.md heading that decided it; with none, it is a finding.`);
|
|
6762
|
+
else if (!match) errors.push(`${name}.json: ${id} cites the ruling "${ruling}", which is not a heading in DECISIONS.md. Cite the decision as its heading reads, or judge it a finding.`);
|
|
6763
|
+
else ruled++;
|
|
6764
|
+
}
|
|
6626
6765
|
const reason = typeof v.reason === "string" ? v.reason.trim() : "";
|
|
6627
6766
|
if (!reason) errors.push(`${name}.json: ${id} has no reason.`);
|
|
6628
6767
|
else if (ABSENCE.test(reason)) errors.push(`${name}.json: ${id} \u2014 "${reason}" says the rule was not read. Read it with \`jig explain ${id}\` and judge it.`);
|
|
6768
|
+
else reasons.push({ label: id, verdict: v.verdict, reason });
|
|
6629
6769
|
if (v.verdict === "finding") findings++;
|
|
6630
6770
|
}
|
|
6771
|
+
reasonProblems(`${name}.json`, reasons, errors);
|
|
6631
6772
|
const missing = [...required, ...extraRequired].filter((id) => !seen.has(id));
|
|
6632
6773
|
if (missing.length) {
|
|
6633
6774
|
errors.push(`${name}.json: ${missing.length} of ${total} ids have no verdict: ${missing.join(", ")}. Re-run the arm; never report a short pass.`);
|
|
6634
6775
|
}
|
|
6635
6776
|
const judged = total - missing.length;
|
|
6636
|
-
return { state: missing.length ? "incomplete" : "ran", judged, total, findings };
|
|
6777
|
+
return { state: missing.length ? "incomplete" : "ran", judged, total, findings, ...ruled ? { ruled } : {} };
|
|
6637
6778
|
}
|
|
6638
6779
|
function checkDecisions(projectRoot, dir, errors) {
|
|
6639
6780
|
const required = decisionNames(projectRoot);
|
|
@@ -6648,6 +6789,7 @@ function checkDecisions(projectRoot, dir, errors) {
|
|
|
6648
6789
|
const list = Array.isArray(file.verdicts) ? file.verdicts : [];
|
|
6649
6790
|
if (!Array.isArray(file.verdicts)) errors.push('decisions.json has no "verdicts" array.');
|
|
6650
6791
|
const seen = /* @__PURE__ */ new Set();
|
|
6792
|
+
const reasons = [];
|
|
6651
6793
|
let findings = 0;
|
|
6652
6794
|
for (const v of list) {
|
|
6653
6795
|
const name = typeof v.decision === "string" ? v.decision.trim() : "";
|
|
@@ -6672,8 +6814,10 @@ function checkDecisions(projectRoot, dir, errors) {
|
|
|
6672
6814
|
const reason = typeof v.reason === "string" ? v.reason.trim() : "";
|
|
6673
6815
|
if (!reason) errors.push(`decisions.json: "${match}" has no reason. Name what on the page satisfies it, or what does not.`);
|
|
6674
6816
|
else if (ABSENCE.test(reason)) errors.push(`decisions.json: "${match}" \u2014 "${reason}" says the decision was not read.`);
|
|
6817
|
+
else reasons.push({ label: `"${match}"`, verdict: v.verdict, reason });
|
|
6675
6818
|
if (v.verdict === "finding") findings++;
|
|
6676
6819
|
}
|
|
6820
|
+
reasonProblems("decisions.json", reasons, errors);
|
|
6677
6821
|
const unjudged = required.filter((r) => !seen.has(r));
|
|
6678
6822
|
const since = decisionsSince(projectRoot, dir, unjudged);
|
|
6679
6823
|
const missing = unjudged.filter((r) => !since.includes(r));
|
|
@@ -6726,8 +6870,9 @@ function verifyVerdicts(opts) {
|
|
|
6726
6870
|
const screenFile = readJson(join37(dir, "screen.json"), errors);
|
|
6727
6871
|
const codeFile = readJson(join37(dir, "code.json"), errors);
|
|
6728
6872
|
const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
|
|
6729
|
-
const
|
|
6730
|
-
const
|
|
6873
|
+
const decisionList = decisionNames(opts.projectRoot);
|
|
6874
|
+
const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors, decisionList);
|
|
6875
|
+
const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors, decisionList);
|
|
6731
6876
|
let rendered = false;
|
|
6732
6877
|
if (screenFile && screenFile.rendered === true) {
|
|
6733
6878
|
const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
|
|
@@ -6763,8 +6908,60 @@ function verifyVerdicts(opts) {
|
|
|
6763
6908
|
if (screen.state === "ran" && !rendered) screen.state = "skipped";
|
|
6764
6909
|
const decisions = checkDecisions(opts.projectRoot, dir, errors);
|
|
6765
6910
|
const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
|
|
6766
|
-
const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} decisions=${field(decisions)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings + decisions.findings}`;
|
|
6767
|
-
|
|
6911
|
+
const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} decisions=${field(decisions)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings + decisions.findings}${(screen.ruled ?? 0) + (code.ruled ?? 0) ? ` ruled=${(screen.ruled ?? 0) + (code.ruled ?? 0)}` : ""}`;
|
|
6912
|
+
const previous = previousFindings(opts.projectRoot, dir);
|
|
6913
|
+
return { ok: errors.length === 0, errors, screen, code, decisions, rendered, line, ...previous ? { previous } : {} };
|
|
6914
|
+
}
|
|
6915
|
+
function previousFindings(projectRoot, dir) {
|
|
6916
|
+
const git = (args) => execFileSync3("git", args, { cwd: projectRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
6917
|
+
const rel = relative4(projectRoot, dir).split("\\").join("/");
|
|
6918
|
+
const files = ["screen.json", "code.json", "decisions.json"];
|
|
6919
|
+
const paths = files.map((f) => `${rel}/${f}`);
|
|
6920
|
+
let commit;
|
|
6921
|
+
try {
|
|
6922
|
+
const dirty = git(["status", "--porcelain", "--", ...paths]).trim() !== "";
|
|
6923
|
+
const touched = git(["log", "--format=%H", "-2", "--", ...paths]).split("\n").filter(Boolean);
|
|
6924
|
+
commit = dirty ? touched[0] ?? "" : touched[1] ?? "";
|
|
6925
|
+
} catch {
|
|
6926
|
+
return void 0;
|
|
6927
|
+
}
|
|
6928
|
+
if (!commit) return void 0;
|
|
6929
|
+
const read = (f, at) => {
|
|
6930
|
+
const out = /* @__PURE__ */ new Map();
|
|
6931
|
+
let text;
|
|
6932
|
+
try {
|
|
6933
|
+
text = at ? git(["show", `${at}:./${rel}/${f}`]) : readFileSync30(join37(dir, f), "utf8");
|
|
6934
|
+
} catch {
|
|
6935
|
+
return out;
|
|
6936
|
+
}
|
|
6937
|
+
let body;
|
|
6938
|
+
try {
|
|
6939
|
+
body = JSON.parse(text);
|
|
6940
|
+
} catch {
|
|
6941
|
+
return out;
|
|
6942
|
+
}
|
|
6943
|
+
for (const v of Array.isArray(body.verdicts) ? body.verdicts : []) {
|
|
6944
|
+
const key = String(v.id ?? v.decision ?? "").trim();
|
|
6945
|
+
if (key && typeof v.verdict === "string") out.set(f === "decisions.json" ? `"${key}"` : key.toUpperCase(), v.verdict);
|
|
6946
|
+
}
|
|
6947
|
+
return out;
|
|
6948
|
+
};
|
|
6949
|
+
const before = /* @__PURE__ */ new Map();
|
|
6950
|
+
const now = /* @__PURE__ */ new Map();
|
|
6951
|
+
for (const f of files) {
|
|
6952
|
+
for (const [k, v] of read(f, commit)) before.set(k, v);
|
|
6953
|
+
for (const [k, v] of read(f)) now.set(k, v);
|
|
6954
|
+
}
|
|
6955
|
+
const result = { commit: commit.slice(0, 7), fixed: [], open: [], added: [], ruled: [] };
|
|
6956
|
+
for (const [k, v] of before) {
|
|
6957
|
+
if (v !== "finding") continue;
|
|
6958
|
+
const after = now.get(k);
|
|
6959
|
+
if (after === "finding") result.open.push(k);
|
|
6960
|
+
else if (after === "ruled") result.ruled.push(k);
|
|
6961
|
+
else if (after === "ok" || after === "n/a") result.fixed.push(k);
|
|
6962
|
+
}
|
|
6963
|
+
for (const [k, v] of now) if (v === "finding" && before.get(k) !== "finding") result.added.push(k);
|
|
6964
|
+
return result;
|
|
6768
6965
|
}
|
|
6769
6966
|
|
|
6770
6967
|
// src/commands/gate.ts
|
|
@@ -6797,23 +6994,7 @@ function mockupDrawingProblems(root, specBody, at) {
|
|
|
6797
6994
|
`${at} draws icons or images in its frames (${[...new Set(drawn)].slice(0, 4).join("; ")}). A mockup is structure only: an icon is its name in brackets, \`[search]\`, and an image is a labelled box saying what goes there.`
|
|
6798
6995
|
);
|
|
6799
6996
|
}
|
|
6800
|
-
|
|
6801
|
-
for (const [size] of FRAMES) {
|
|
6802
|
-
const listed = regions[size];
|
|
6803
|
-
const inFrame = frames.filter((f) => f.size === size).flatMap((f) => f.labels);
|
|
6804
|
-
if (!listed || listed.length === 0 || !frames.some((f) => f.size === size)) continue;
|
|
6805
|
-
const named = listed.filter((r) => r.name).map((r) => r.name);
|
|
6806
|
-
const absent = named.filter((name) => !inFrame.some((label) => labelMatches(label, name)));
|
|
6807
|
-
if (absent.length) {
|
|
6808
|
-
problems.push(
|
|
6809
|
-
`${at}: the ${size} frame has no labelled region for ${absent.map((n) => `"${n}"`).join(", ")}. Every region the spec lists for a size is drawn in that size's frame, with its name in a \`.name\` label.`
|
|
6810
|
-
);
|
|
6811
|
-
} else if (inFrame.length < listed.length) {
|
|
6812
|
-
problems.push(
|
|
6813
|
-
`${at}: the ${size} frame labels ${inFrame.length} region(s) and the spec lists ${listed.length} for ${size}. Draw each one in the frame, with its name in a \`.name\` label.`
|
|
6814
|
-
);
|
|
6815
|
-
}
|
|
6816
|
-
}
|
|
6997
|
+
problems.push(...frameRegionProblems(frames, specBody, at));
|
|
6817
6998
|
const widths = new Set(frames.map((f) => f.width).filter((w) => w !== void 0));
|
|
6818
6999
|
const crossed = specSwitches(specBody);
|
|
6819
7000
|
const recorded = declaredSwitches(root);
|
|
@@ -6833,6 +7014,37 @@ function mockupDrawingProblems(root, specBody, at) {
|
|
|
6833
7014
|
}
|
|
6834
7015
|
return problems;
|
|
6835
7016
|
}
|
|
7017
|
+
function frameRegionProblems(frames, specBody, at) {
|
|
7018
|
+
const problems = [];
|
|
7019
|
+
const regions = specRegions(specBody);
|
|
7020
|
+
for (const [size] of FRAMES) {
|
|
7021
|
+
const listed = regions[size];
|
|
7022
|
+
const inFrame = frames.filter((f) => f.size === size).flatMap((f) => f.labels);
|
|
7023
|
+
if (!listed || listed.length === 0 || !frames.some((f) => f.size === size)) continue;
|
|
7024
|
+
const absent = listed.filter((r) => r.name && !r.optional && !regionDrawn(inFrame, r)).map((r) => r.name);
|
|
7025
|
+
if (absent.length) {
|
|
7026
|
+
problems.push(
|
|
7027
|
+
`${at}: the ${size} frame has no labelled region for ${absent.map((n) => `"${n}"`).join(", ")}. Every region the spec lists for a size is drawn in that size's frame, with its name in a \`.name\` label.`
|
|
7028
|
+
);
|
|
7029
|
+
} else if (inFrame.length < listed.filter((r) => !r.optional).length) {
|
|
7030
|
+
problems.push(
|
|
7031
|
+
`${at}: the ${size} frame labels ${inFrame.length} region(s) and the spec lists ${listed.filter((r) => !r.optional).length} for ${size}. Draw each one in the frame, with its name in a \`.name\` label.`
|
|
7032
|
+
);
|
|
7033
|
+
}
|
|
7034
|
+
}
|
|
7035
|
+
return problems;
|
|
7036
|
+
}
|
|
7037
|
+
function approvedDrawingProblems(root, specBody, at) {
|
|
7038
|
+
let html;
|
|
7039
|
+
try {
|
|
7040
|
+
html = readFileSync31(join38(root, at), "utf8");
|
|
7041
|
+
} catch {
|
|
7042
|
+
return [];
|
|
7043
|
+
}
|
|
7044
|
+
const frames = readFrames(html);
|
|
7045
|
+
if (!frames.some((f) => f.size)) return [];
|
|
7046
|
+
return frameRegionProblems(frames, specBody, at);
|
|
7047
|
+
}
|
|
6836
7048
|
function specSwitches(specBody) {
|
|
6837
7049
|
const front = specBody.split(/^---\s*$/m)[1] ?? "";
|
|
6838
7050
|
const value = /^switches\s*:\s*(.+)$/im.exec(front)?.[1]?.replace(/#.*$/, "").trim();
|
|
@@ -6894,7 +7106,18 @@ function regionEntries(block) {
|
|
|
6894
7106
|
function regionName(entry) {
|
|
6895
7107
|
const text = entry.trim().replace(/^["']|["']$/g, "").replace(/\\"/g, '"');
|
|
6896
7108
|
const head = normalise(text.split(":")[0]);
|
|
6897
|
-
|
|
7109
|
+
const optional = /\([^)]*\bonly\b[^)]*\)/.test(head);
|
|
7110
|
+
const bare2 = stripQualifiers(head);
|
|
7111
|
+
if (!bare2 || bare2.split(" ").length > 4) return {};
|
|
7112
|
+
const parts = bare2.split(/\s*,\s*(?:and\s+)?|\s+and\s+(?=[^,]*$)/).map((p) => p.trim()).filter(Boolean);
|
|
7113
|
+
return { name: bare2, ...parts.length > 1 ? { parts } : {}, ...optional ? { optional } : {} };
|
|
7114
|
+
}
|
|
7115
|
+
function stripQualifiers(text) {
|
|
7116
|
+
return text.replace(/\s*\([^)]*\)/g, "").replace(/\s+/g, " ").trim();
|
|
7117
|
+
}
|
|
7118
|
+
function regionDrawn(labels, region) {
|
|
7119
|
+
const found = (name) => labels.some((label) => labelMatches(label, name) || labelMatches(stripQualifiers(label), name));
|
|
7120
|
+
return found(region.name) || !!region.parts && region.parts.every(found);
|
|
6898
7121
|
}
|
|
6899
7122
|
function labelMatches(label, name) {
|
|
6900
7123
|
return label === name || label.startsWith(`${name} `) || label.startsWith(`${name}:`) || new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(label);
|
|
@@ -6915,8 +7138,9 @@ function sizeBlock2(sizes, size) {
|
|
|
6915
7138
|
}
|
|
6916
7139
|
|
|
6917
7140
|
// src/commands/gate.ts
|
|
7141
|
+
import { execFileSync as execFileSync4 } from "child_process";
|
|
6918
7142
|
var MAX_BLOCKS = 3;
|
|
6919
|
-
function
|
|
7143
|
+
function lastJigInvocation(transcriptPath) {
|
|
6920
7144
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6921
7145
|
let text;
|
|
6922
7146
|
try {
|
|
@@ -6927,10 +7151,20 @@ function lastJigCommand(transcriptPath) {
|
|
|
6927
7151
|
let found;
|
|
6928
7152
|
for (const line of text.split("\n")) {
|
|
6929
7153
|
if (!line.includes("/jig")) continue;
|
|
6930
|
-
|
|
6931
|
-
|
|
6932
|
-
|
|
6933
|
-
|
|
7154
|
+
let entry;
|
|
7155
|
+
try {
|
|
7156
|
+
entry = JSON.parse(line);
|
|
7157
|
+
} catch {
|
|
7158
|
+
continue;
|
|
7159
|
+
}
|
|
7160
|
+
if (entry.type !== "user" || entry.isMeta) continue;
|
|
7161
|
+
const content = entry.message?.content;
|
|
7162
|
+
const texts = typeof content === "string" ? [content] : Array.isArray(content) ? content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text) : [];
|
|
7163
|
+
for (const t of texts) {
|
|
7164
|
+
const args = /<command-name>\/?jig<\/command-name>[\s\S]{0,200}?<command-args>([^<]*)<\/command-args>/.exec(t)?.[1] ?? /^\s*\/jig\s+([^\n]*)/.exec(t)?.[1];
|
|
7165
|
+
const [command, surface] = (args ?? "").trim().split(/\s+/);
|
|
7166
|
+
if (command && /^[a-z]+$/i.test(command)) found = { command: command.toLowerCase(), ...surface && /^[\w.-]+$/.test(surface) ? { surface } : {} };
|
|
7167
|
+
}
|
|
6934
7168
|
}
|
|
6935
7169
|
return found;
|
|
6936
7170
|
}
|
|
@@ -6960,7 +7194,7 @@ function asksOwner(text) {
|
|
|
6960
7194
|
const prose = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
|
|
6961
7195
|
return /\?(?=[\s)\]"'*_]|$)/.test(prose);
|
|
6962
7196
|
}
|
|
6963
|
-
var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup"]);
|
|
7197
|
+
var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup", "tweak"]);
|
|
6964
7198
|
function sessionStart(transcriptPath) {
|
|
6965
7199
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6966
7200
|
try {
|
|
@@ -6985,18 +7219,27 @@ function verdictsMtime(dir) {
|
|
|
6985
7219
|
}
|
|
6986
7220
|
return newest;
|
|
6987
7221
|
}
|
|
6988
|
-
function surfacesInPlay(root, command, transcriptPath) {
|
|
7222
|
+
function surfacesInPlay(root, command, transcriptPath, surface) {
|
|
6989
7223
|
const critiqueDir = join39(root, ".jig", "critique");
|
|
6990
7224
|
if (!existsSync24(critiqueDir)) return [];
|
|
6991
7225
|
const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
|
|
6992
7226
|
const start = sessionStart(transcriptPath);
|
|
6993
7227
|
if (start === void 0) return all;
|
|
6994
|
-
const current = command === "critique"
|
|
7228
|
+
const current = command === "critique" || command === "tweak" ? specFor(root, surface)?.slug : void 0;
|
|
6995
7229
|
return all.filter((s) => s === current || verdictsMtime(join39(critiqueDir, s)) >= start - 1e3);
|
|
6996
7230
|
}
|
|
6997
|
-
function
|
|
7231
|
+
function fileAtSessionStart(root, path, start) {
|
|
7232
|
+
const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
7233
|
+
try {
|
|
7234
|
+
const base = start === void 0 ? "HEAD" : git(["rev-list", "-1", `--before=@${Math.floor(start / 1e3)}`, "HEAD"]).trim();
|
|
7235
|
+
return base ? git(["show", `${base}:./${path}`]) : "";
|
|
7236
|
+
} catch {
|
|
7237
|
+
return "";
|
|
7238
|
+
}
|
|
7239
|
+
}
|
|
7240
|
+
function commandProblems(root, command, surface, start) {
|
|
6998
7241
|
const problems = [];
|
|
6999
|
-
const spec =
|
|
7242
|
+
const spec = specFor(root, surface);
|
|
7000
7243
|
if (command === "decide") {
|
|
7001
7244
|
const found = decisionsFile(root);
|
|
7002
7245
|
if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
@@ -7006,9 +7249,10 @@ function commandProblems(root, command) {
|
|
|
7006
7249
|
problems.push("DECISIONS.md has no `## Unresolved` section. Round 3 asks by name what is still undecided; write what the owner named, or `None named by the owner.`");
|
|
7007
7250
|
}
|
|
7008
7251
|
if (/\[TODO\]/.test(body)) problems.push("DECISIONS.md still contains [TODO] markers.");
|
|
7252
|
+
problems.push(...unsourcedReasons(body, fileAtSessionStart(root, found, start)).map((p) => `DECISIONS.md: ${p}`));
|
|
7009
7253
|
}
|
|
7010
7254
|
}
|
|
7011
|
-
if (command === "spec" || command === "mockup" || command === "make" || command === "critique") {
|
|
7255
|
+
if (command === "spec" || command === "mockup" || command === "make" || command === "critique" || command === "tweak") {
|
|
7012
7256
|
if (!spec) problems.push(`${command} needs a spec: there is no file in .jig/specs/.`);
|
|
7013
7257
|
else {
|
|
7014
7258
|
problems.push(...specProblems(spec));
|
|
@@ -7027,16 +7271,44 @@ function commandProblems(root, command) {
|
|
|
7027
7271
|
}
|
|
7028
7272
|
if (at && /\.html?$/i.test(at) && existsSync24(join39(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
|
|
7029
7273
|
}
|
|
7274
|
+
if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec));
|
|
7275
|
+
if ((command === "spec" || command === "make") && spec) {
|
|
7276
|
+
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7277
|
+
const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
|
|
7278
|
+
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
7279
|
+
if (/^approved/i.test(mockup) && /\.html?$/i.test(at) && !/^https?:/i.test(at)) {
|
|
7280
|
+
const drift = approvedDrawingProblems(root, spec.body, at);
|
|
7281
|
+
if (drift.length) {
|
|
7282
|
+
problems.push(
|
|
7283
|
+
`${spec.path} says \`mockup: approved\`, but the approved drawing no longer shows what the spec lists: ${drift.join(" ")} ` + (command === "spec" ? `The spec changed after the owner approved the drawing. Set \`mockup: pending\` in the spec; \`/jig mockup\` redraws it for the owner.` : `make builds from a drawing the owner approved of this spec, and this one is of an earlier spec. Stop, and ask for \`/jig mockup\`.`)
|
|
7284
|
+
);
|
|
7285
|
+
}
|
|
7286
|
+
}
|
|
7287
|
+
}
|
|
7288
|
+
if (command === "make") {
|
|
7289
|
+
const found = decisionsFile(root);
|
|
7290
|
+
if (found) {
|
|
7291
|
+
let now = "";
|
|
7292
|
+
try {
|
|
7293
|
+
now = readFileSync32(join39(root, found), "utf8");
|
|
7294
|
+
} catch {
|
|
7295
|
+
}
|
|
7296
|
+
const then = fileAtSessionStart(root, found, start);
|
|
7297
|
+
if (then && now !== then) {
|
|
7298
|
+
problems.push(`${found} changed in a \`make\` session. make carries decisions out; it does not take them. Restore it (\`git checkout -- ${found}\`, or \`git show <commit>:${found}\` if the change is committed), and put what the owner must decide to them: \`/jig decide\`, or \`/jig tweak\` for a small change to a built page.`);
|
|
7299
|
+
}
|
|
7300
|
+
}
|
|
7301
|
+
}
|
|
7030
7302
|
if (command === "critique") {
|
|
7031
7303
|
const dir = join39(root, ".jig", "critique");
|
|
7032
7304
|
const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join39(dir, s, "screen.json")) || existsSync24(join39(dir, s, "code.json"))) : [];
|
|
7033
|
-
for (const
|
|
7034
|
-
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
7305
|
+
for (const surface2 of surfaces) {
|
|
7306
|
+
const v = verifyVerdicts({ projectRoot: root, surface: surface2 });
|
|
7035
7307
|
if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
|
|
7036
|
-
problems.push(`${
|
|
7308
|
+
problems.push(`${surface2}: ${v.decisions.total - v.decisions.judged} of ${v.decisions.total} decisions in DECISIONS.md have no verdict. A page can satisfy every rule and still break what this project decided.`);
|
|
7037
7309
|
}
|
|
7038
7310
|
if (v.ok && v.screen.state === "skipped") {
|
|
7039
|
-
problems.push(`${
|
|
7311
|
+
problems.push(`${surface2}: the screen pass judged ${v.screen.judged} rules with rendered: false. Those rules are judged on a render \u2014 open the page at 360, 768 and 1280, run \`jig probe\` at each, and judge them there.`);
|
|
7040
7312
|
}
|
|
7041
7313
|
}
|
|
7042
7314
|
if (surfaces.length === 0) {
|
|
@@ -7045,8 +7317,76 @@ function commandProblems(root, command) {
|
|
|
7045
7317
|
}
|
|
7046
7318
|
return problems;
|
|
7047
7319
|
}
|
|
7320
|
+
function tweakProblems(root, spec) {
|
|
7321
|
+
const problems = [];
|
|
7322
|
+
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
7323
|
+
if (!/^\s*confirmed\s*:\s*true\b/im.test(front)) problems.push(`${spec.path} is not confirmed. A tweak changes a page the owner has confirmed; an unconfirmed spec goes through \`spec\`.`);
|
|
7324
|
+
const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
|
|
7325
|
+
if (!/^(approved|skipped)/i.test(mockup)) problems.push(`${spec.path}: \`mockup:\` is ${mockup || "empty"}. A tweak changes a page whose drawing the owner has approved (or skipped); take a new page through \`mockup\` and \`make\`.`);
|
|
7326
|
+
else problems.push(...structureSinceApproval(root, spec, front));
|
|
7327
|
+
const surface = spec.slug;
|
|
7328
|
+
const record = readTweak(join39(root, ".jig", "critique", surface));
|
|
7329
|
+
if (!record) {
|
|
7330
|
+
problems.push(`.jig/critique/${surface}/tweak.json is missing. It names the change in the owner's words (\`change\`), when it was made (\`at\`) and the rule ids and decisions re-judged for it (\`ids\`).`);
|
|
7331
|
+
} else {
|
|
7332
|
+
if (!record.change) problems.push(`.jig/critique/${surface}/tweak.json has no \`change\`: the owner's words for what changed.`);
|
|
7333
|
+
if (!record.ids.length) problems.push(`.jig/critique/${surface}/tweak.json names nothing to re-judge. A change that no rule and no decision could see needs no tweak; name the ones it can.`);
|
|
7334
|
+
const unjudged = unjudgedTweakIds(join39(root, ".jig", "critique", surface), record);
|
|
7335
|
+
if (unjudged.length) {
|
|
7336
|
+
problems.push(
|
|
7337
|
+
`.jig/critique/${surface}: tweak.json names ${unjudged.slice(0, 6).join(", ")}${unjudged.length > 6 ? " and more" : ""} but no verdict for ${unjudged.length === 1 ? "it" : "them"} carries \`"tweak": "${record.at}"\`. Each named verdict is re-judged on the changed page by a reader that did not make the change.`
|
|
7338
|
+
);
|
|
7339
|
+
}
|
|
7340
|
+
}
|
|
7341
|
+
return problems;
|
|
7342
|
+
}
|
|
7343
|
+
function structureSinceApproval(root, spec, front) {
|
|
7344
|
+
const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
7345
|
+
let baseline;
|
|
7346
|
+
let judged = true;
|
|
7347
|
+
try {
|
|
7348
|
+
baseline = git(["log", "-1", "--format=%H", "--", `.jig/critique/${spec.slug}/${LOCK}`]).trim();
|
|
7349
|
+
if (!baseline) {
|
|
7350
|
+
judged = false;
|
|
7351
|
+
baseline = git(["log", "-1", "--format=%H", "-G", "^mockup[[:space:]]*:", "--", spec.path]).trim();
|
|
7352
|
+
}
|
|
7353
|
+
} catch {
|
|
7354
|
+
return [];
|
|
7355
|
+
}
|
|
7356
|
+
if (!baseline) return [];
|
|
7357
|
+
const when = judged ? `when the last critique judged it (${baseline.slice(0, 7)})` : `when the owner approved the mockup (${baseline.slice(0, 7)})`;
|
|
7358
|
+
const problems = [];
|
|
7359
|
+
let thenBody = "";
|
|
7360
|
+
try {
|
|
7361
|
+
thenBody = git(["show", `${baseline}:./${spec.path}`]);
|
|
7362
|
+
} catch {
|
|
7363
|
+
return [];
|
|
7364
|
+
}
|
|
7365
|
+
const shape = (body) => JSON.stringify(Object.entries(specRegions(body)).sort(([a], [b]) => a.localeCompare(b)).map(([size, regions]) => [size, regions.length, regions.map((r) => r.name ?? "")]));
|
|
7366
|
+
if (shape(thenBody) !== shape(spec.body)) {
|
|
7367
|
+
problems.push(`${spec.path}: the regions under \`sizes:\` differ from the ones the page had ${when}. A change to what the page holds is not a tweak: take it through \`spec\` and \`mockup\`.`);
|
|
7368
|
+
}
|
|
7369
|
+
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
7370
|
+
if (at && !/^https?:/i.test(at) && existsSync24(join39(root, at))) {
|
|
7371
|
+
try {
|
|
7372
|
+
if (git(["show", `${baseline}:./${at}`]) !== readFileSync32(join39(root, at), "utf8")) {
|
|
7373
|
+
problems.push(`${at} has changed since ${when.replace(/^when /, "")}. A tweak leaves the drawing as approved; a change the drawing must show goes through \`mockup\`.`);
|
|
7374
|
+
}
|
|
7375
|
+
} catch {
|
|
7376
|
+
}
|
|
7377
|
+
}
|
|
7378
|
+
return problems;
|
|
7379
|
+
}
|
|
7380
|
+
function readTweak(dir) {
|
|
7381
|
+
try {
|
|
7382
|
+
const raw = JSON.parse(readFileSync32(join39(dir, "tweak.json"), "utf8"));
|
|
7383
|
+
return { at: String(raw.at ?? ""), change: String(raw.change ?? "").trim(), ids: Array.isArray(raw.ids) ? raw.ids.map(String) : [] };
|
|
7384
|
+
} catch {
|
|
7385
|
+
return void 0;
|
|
7386
|
+
}
|
|
7387
|
+
}
|
|
7048
7388
|
function surfacePage(projectRoot, surface) {
|
|
7049
|
-
const spec =
|
|
7389
|
+
const spec = specFor(projectRoot, surface);
|
|
7050
7390
|
const front = spec?.body.split(/^---\s*$/m)[1] ?? "";
|
|
7051
7391
|
const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
7052
7392
|
const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
|
|
@@ -7060,9 +7400,10 @@ function gate(opts) {
|
|
|
7060
7400
|
if (!existsSync24(join39(root, "jig.config.json")) && !existsSync24(join39(root, ".jig"))) {
|
|
7061
7401
|
return { block: false, reason: "" };
|
|
7062
7402
|
}
|
|
7063
|
-
const
|
|
7403
|
+
const invocation = lastJigInvocation(opts.input.transcript_path);
|
|
7404
|
+
const command = invocation?.command;
|
|
7064
7405
|
const waiting = command !== void 0 && ASKS_THE_OWNER.has(command) && asksOwner(lastAssistantText(opts.input.transcript_path));
|
|
7065
|
-
const problems = command && !waiting ? commandProblems(root, command).map((p) => `/jig ${command}: ${p}`) : [];
|
|
7406
|
+
const problems = command && !waiting ? commandProblems(root, command, invocation?.surface, sessionStart(opts.input.transcript_path)).map((p) => `/jig ${command}: ${p}`) : [];
|
|
7066
7407
|
const selection = selectFiles(root, false);
|
|
7067
7408
|
const changedUi = selection.mode === "changed" && selection.files.some((f) => isStyleBearing(f) || isReaderText(f));
|
|
7068
7409
|
if (changedUi) {
|
|
@@ -7083,10 +7424,10 @@ function gate(opts) {
|
|
|
7083
7424
|
);
|
|
7084
7425
|
}
|
|
7085
7426
|
}
|
|
7086
|
-
problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path)));
|
|
7427
|
+
problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)));
|
|
7087
7428
|
const critiqueDir = join39(root, ".jig", "critique");
|
|
7088
7429
|
if (existsSync24(critiqueDir)) {
|
|
7089
|
-
for (const surface of surfacesInPlay(root, command, opts.input.transcript_path)) {
|
|
7430
|
+
for (const surface of surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)) {
|
|
7090
7431
|
const dir = join39(critiqueDir, surface);
|
|
7091
7432
|
if (!existsSync24(join39(dir, "screen.json")) && !existsSync24(join39(dir, "code.json"))) continue;
|
|
7092
7433
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
@@ -7135,6 +7476,28 @@ ${problems.join("\n\n")}`
|
|
|
7135
7476
|
}
|
|
7136
7477
|
var VERDICT_FILES = ["screen.json", "code.json", "decisions.json"];
|
|
7137
7478
|
var LOCK = "verdicts.lock";
|
|
7479
|
+
function verdictDigests(dir) {
|
|
7480
|
+
const out = {};
|
|
7481
|
+
for (const f of VERDICT_FILES) {
|
|
7482
|
+
let file;
|
|
7483
|
+
try {
|
|
7484
|
+
file = JSON.parse(readFileSync32(join39(dir, f), "utf8"));
|
|
7485
|
+
} catch {
|
|
7486
|
+
continue;
|
|
7487
|
+
}
|
|
7488
|
+
for (const v of Array.isArray(file.verdicts) ? file.verdicts : []) {
|
|
7489
|
+
const key = String(v.id ?? v.decision ?? "").trim();
|
|
7490
|
+
if (key) out[`${f}:${key}`] = checksum(JSON.stringify(v));
|
|
7491
|
+
}
|
|
7492
|
+
}
|
|
7493
|
+
return out;
|
|
7494
|
+
}
|
|
7495
|
+
function writeLock(lockPath, dir, now) {
|
|
7496
|
+
try {
|
|
7497
|
+
writeFileSync6(lockPath, JSON.stringify({ checksum: now, verdicts: verdictDigests(dir) }) + "\n", "utf8");
|
|
7498
|
+
} catch {
|
|
7499
|
+
}
|
|
7500
|
+
}
|
|
7138
7501
|
function verdictChecksum(dir) {
|
|
7139
7502
|
const parts = VERDICT_FILES.map((f) => {
|
|
7140
7503
|
try {
|
|
@@ -7157,18 +7520,22 @@ function verdictGuard(root, command, inPlay) {
|
|
|
7157
7520
|
if (!now) continue;
|
|
7158
7521
|
const lockPath = join39(dir, LOCK);
|
|
7159
7522
|
if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
|
|
7160
|
-
|
|
7161
|
-
writeFileSync6(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
|
|
7162
|
-
} catch {
|
|
7163
|
-
}
|
|
7523
|
+
writeLock(lockPath, dir, now);
|
|
7164
7524
|
continue;
|
|
7165
7525
|
}
|
|
7166
|
-
let
|
|
7526
|
+
let lock = {};
|
|
7167
7527
|
try {
|
|
7168
|
-
|
|
7528
|
+
lock = JSON.parse(readFileSync32(lockPath, "utf8"));
|
|
7169
7529
|
} catch {
|
|
7170
7530
|
continue;
|
|
7171
7531
|
}
|
|
7532
|
+
const locked = lock.checksum;
|
|
7533
|
+
if (command === "tweak" && (!inPlay || inPlay.includes(surface)) && locked !== now) {
|
|
7534
|
+
const own = tweakVerdictProblems(dir, surface, lock.verdicts);
|
|
7535
|
+
problems.push(...own);
|
|
7536
|
+
if (own.length === 0) writeLock(lockPath, dir, now);
|
|
7537
|
+
continue;
|
|
7538
|
+
}
|
|
7172
7539
|
if (locked && locked !== now) {
|
|
7173
7540
|
problems.push(
|
|
7174
7541
|
`.jig/critique/${surface}: the verdict files changed after \`/jig critique\` wrote them${command ? `, in a session that ran \`/jig ${command}\`` : ""}. Verdicts are the review's, not the builder's: restore them (\`git checkout -- .jig/critique/${surface}\`) and run \`/jig critique\` to judge the fix.`
|
|
@@ -7177,6 +7544,40 @@ function verdictGuard(root, command, inPlay) {
|
|
|
7177
7544
|
}
|
|
7178
7545
|
return problems;
|
|
7179
7546
|
}
|
|
7547
|
+
function tweakVerdictProblems(dir, surface, lockedVerdicts) {
|
|
7548
|
+
const record = readTweak(dir);
|
|
7549
|
+
if (!record) {
|
|
7550
|
+
return [`.jig/critique/${surface}: the verdict files changed in a \`/jig tweak\` session with no tweak.json naming what was re-judged.`];
|
|
7551
|
+
}
|
|
7552
|
+
const problems = [];
|
|
7553
|
+
const named = new Set(record.ids.map((id) => id.toLowerCase()));
|
|
7554
|
+
const nowVerdicts = verdictDigests(dir);
|
|
7555
|
+
if (lockedVerdicts) {
|
|
7556
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(lockedVerdicts), ...Object.keys(nowVerdicts)]);
|
|
7557
|
+
const outside = [...keys].filter((k) => lockedVerdicts[k] !== nowVerdicts[k]).map((k) => k.slice(k.indexOf(":") + 1)).filter((id) => !named.has(id.toLowerCase()));
|
|
7558
|
+
if (outside.length) {
|
|
7559
|
+
problems.push(
|
|
7560
|
+
`.jig/critique/${surface}: a tweak changed verdicts it did not name (${[...new Set(outside)].slice(0, 6).join(", ")}). A tweak re-judges only what its change could affect, listed in tweak.json's \`ids\`; restore the others (\`git checkout -- .jig/critique/${surface}\`).`
|
|
7561
|
+
);
|
|
7562
|
+
}
|
|
7563
|
+
}
|
|
7564
|
+
return problems;
|
|
7565
|
+
}
|
|
7566
|
+
function unjudgedTweakIds(dir, record) {
|
|
7567
|
+
const stamped = /* @__PURE__ */ new Set();
|
|
7568
|
+
for (const f of VERDICT_FILES) {
|
|
7569
|
+
let file;
|
|
7570
|
+
try {
|
|
7571
|
+
file = JSON.parse(readFileSync32(join39(dir, f), "utf8"));
|
|
7572
|
+
} catch {
|
|
7573
|
+
continue;
|
|
7574
|
+
}
|
|
7575
|
+
for (const v of Array.isArray(file.verdicts) ? file.verdicts : []) {
|
|
7576
|
+
if (record.at && v.tweak === record.at) stamped.add(String(v.id ?? v.decision ?? "").toLowerCase());
|
|
7577
|
+
}
|
|
7578
|
+
}
|
|
7579
|
+
return record.ids.filter((id) => !stamped.has(id.toLowerCase()));
|
|
7580
|
+
}
|
|
7180
7581
|
function save(file, state) {
|
|
7181
7582
|
try {
|
|
7182
7583
|
mkdirSync8(join39(file, ".."), { recursive: true });
|
|
@@ -7375,19 +7776,42 @@ program.command("update").description("Update vendored Jig rules, skipping files
|
|
|
7375
7776
|
);
|
|
7376
7777
|
for (const f of result.updated) console.log(` ~ ${f}`);
|
|
7377
7778
|
for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
|
|
7779
|
+
for (const t of result.tokens) {
|
|
7780
|
+
const parts = [t.added.length ? `adds ${t.added.join(", ")}` : "", t.removed.length ? `removes ${t.removed.join(", ")}` : ""].filter(Boolean).join("; ");
|
|
7781
|
+
console.log(t.skipped ? ` ! ${t.file}: this release ${parts}, and did not apply it, because the file is edited locally.` : ` + ${t.file}: ${parts}.`);
|
|
7782
|
+
}
|
|
7783
|
+
if (result.tokens.length) console.log(" Anything that aliases tokens (a Tailwind @theme, a generated utilities file) needs the same change.");
|
|
7378
7784
|
} catch (err) {
|
|
7379
7785
|
console.error(err.message);
|
|
7380
7786
|
process.exit(1);
|
|
7381
7787
|
}
|
|
7382
7788
|
});
|
|
7383
|
-
program.command("verdicts").description("Verify a critique's verdict files and compute its counts.").argument("<surface>", "the surface slug the critique wrote under .jig/critique/").action((surface) => {
|
|
7789
|
+
program.command("verdicts").description("Verify a critique's verdict files and compute its counts.").argument("<surface>", "the surface slug the critique wrote under .jig/critique/").option("--reprobe", "re-take every probe that is missing or was taken on an older version of the page, first (needs a browser)").action(async (surface, opts) => {
|
|
7384
7790
|
const projectRoot = findProjectRoot(process.cwd());
|
|
7385
7791
|
try {
|
|
7792
|
+
if (opts.reprobe) {
|
|
7793
|
+
const page = surfacePage(projectRoot, surface) ?? recordedPage(projectRoot, surface);
|
|
7794
|
+
if (!page) console.error(` \u2717 --reprobe: no probe in .jig/critique/${surface}/ names a page that exists, so there is nothing to render. Run \`jig probe --run <page> --save ${surface}\`.`);
|
|
7795
|
+
else {
|
|
7796
|
+
const { recorded, reason } = await ensureProbes({ projectRoot, surface, page });
|
|
7797
|
+
if (reason) console.error(` \u2717 --reprobe: ${reason}.`);
|
|
7798
|
+
else if (recorded.length) console.log(` Re-probed ${page} at ${recorded.join(", ")}px.`);
|
|
7799
|
+
else console.log(` Every probe is current.`);
|
|
7800
|
+
}
|
|
7801
|
+
}
|
|
7386
7802
|
const result = verifyVerdicts({ projectRoot, surface });
|
|
7387
7803
|
for (const error of result.errors) console.error(` \u2717 ${error}`);
|
|
7804
|
+
if (!opts.reprobe && result.errors.some((e) => /was taken (on|by) an older/.test(e))) {
|
|
7805
|
+
console.error(` The page changed after these probes. \`jig verdicts ${surface} --reprobe\` re-takes them on the page as it is now.`);
|
|
7806
|
+
}
|
|
7388
7807
|
if (result.ok) console.log(` Every rule in both passes has a verdict.`);
|
|
7389
7808
|
const since = result.decisions.since ?? [];
|
|
7390
7809
|
if (since.length) console.log(` ${since.length} decision(s) recorded after this critique, for the next one to judge: ${since.join(", ")}.`);
|
|
7810
|
+
const p = result.previous;
|
|
7811
|
+
if (p) {
|
|
7812
|
+
const list = (ids) => ids.length ? ` (${ids.slice(0, 8).join(", ")}${ids.length > 8 ? ", \u2026" : ""})` : "";
|
|
7813
|
+
console.log(` Since the critique before this one (${p.commit}): ${p.fixed.length} fixed${list(p.fixed)}, ${p.open.length} still open${list(p.open)}, ${p.ruled.length} ruled by the owner${list(p.ruled)}, ${p.added.length} new${list(p.added)}.`);
|
|
7814
|
+
}
|
|
7391
7815
|
console.log(` ${result.line}`);
|
|
7392
7816
|
process.exit(result.ok ? 0 : 1);
|
|
7393
7817
|
} catch (err) {
|
|
@@ -7485,7 +7909,7 @@ program.command("gate").description("Run by the Claude Code Stop hook: block sto
|
|
|
7485
7909
|
try {
|
|
7486
7910
|
const projectRoot = findProjectRoot(cwd);
|
|
7487
7911
|
for (const surface of critiquedSurfaces(projectRoot)) {
|
|
7488
|
-
const page = surfacePage(projectRoot, surface);
|
|
7912
|
+
const page = surfacePage(projectRoot, surface) ?? recordedPage(projectRoot, surface);
|
|
7489
7913
|
if (!page) continue;
|
|
7490
7914
|
try {
|
|
7491
7915
|
await ensureProbes({ projectRoot, surface, page });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jig-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "A design system for coding agents. 143 numbered UI rules, brand x mode design tokens, and an installer for Claude Code, Codex, Cursor and opencode.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/rules/01-modes.md
CHANGED
|
@@ -86,7 +86,7 @@ Resolved values: `02-tokens.md` — the option sets for type, spacing, radius an
|
|
|
86
86
|
- Every page states its subject above the fold in text, not only in an image.
|
|
87
87
|
- Prose blocks are measure-capped even when the container is wide.
|
|
88
88
|
- No horizontal scrolling regions on mobile. Reflow instead.
|
|
89
|
-
-
|
|
89
|
+
- JavaScript: **none by default.** A content page ships no script unless a feature on it needs some: a theme toggle, a search box, a filter. That is a default, not an absolute zero. Each script names, in a comment, the feature and the decision or rule that asks for it, and a review judges whether the feature needs it and whether it is as small as the feature allows, not whether it exists. What the budget forbids is script no feature asks for: a framework runtime to render static text, a library for one effect, tracking bundled into the page. **Behaviour a rule requires is not a feature and is not counted**: a few lines whose only job is something a rule asks for and the browser does not give, such as `Escape` closing a `<details>` menu, or a tap outside closing a menu in a fixed header (`P-14`), are allowed in every mode, under 1 KB, with the rule named in a comment. Nothing else rides along in them.
|
|
90
90
|
|
|
91
91
|
---
|
|
92
92
|
|
package/rules/03-patterns.md
CHANGED
|
@@ -476,11 +476,16 @@ Compose it for the phone first. Mobile navigation is a different control — not
|
|
|
476
476
|
- Style the mark from that attribute — `[aria-current="page"]` in CSS — not from a separate `.active` or `.current` class. One source for both what is seen and what is announced means the two cannot drift apart; a class alone looks marked and tells a screen reader nothing.
|
|
477
477
|
- The visible cue is not colour alone (`C-20`): weight, an underline or bar, or a filled state. Which one is the project's decision.
|
|
478
478
|
- Inside an open menu, the current item is marked the same way. When the menu is closed nothing in the navigation is visible, so the page's `<h1>` is what tells the reader where they are — every page has one, and it names the page.
|
|
479
|
-
- **It works with no JavaScript** (`F-41`). The links are ordinary links in the page and render visibly by default; script, if there is any, only adds the collapse. In `editorial`, where
|
|
479
|
+
- **It works with no JavaScript** (`F-41`). The links are ordinary links in the page and render visibly by default; script, if there is any, only adds the collapse. In `editorial`, where a page has no script by default (`M-01`), use `<details>` with `<summary>Menu</summary>` — a disclosure the browser provides with no script at all — and the few lines above for `Escape`.
|
|
480
480
|
- **Never let a row that does not fit scroll sideways.** Its last items go past the edge where nobody sees them (`E-62`), and `editorial` forbids horizontal scrolling on mobile outright. An open menu is a vertical list.
|
|
481
481
|
- **Same destinations, same order, at every width.** The phone may show fewer at once. It never shows different ones, and never reorders them — `product` fixes navigation position across the app (`M-02`), and a reader who learned the order on one screen should not have to relearn it on another.
|
|
482
482
|
- **Every item is at least `--size-touch-target` tall**, made with padding rather than a larger font. The target grows; the text does not.
|
|
483
483
|
- **An open menu does not cover the page unless it has to.** If it does cover the page, it is a dialog and `P-07` applies: focus moves into it, `Escape` closes it, and focus returns to the button. A menu that opens inline needs none of that — but `Escape` still closes it and returns focus to the button.
|
|
484
|
+
- **In a fixed or sticky header, the open menu lies over the page.** It cannot push the page down, so it covers what is under it, and it is still not a dialog: focus is not trapped and the page is not made inert. What it owes the reader instead:
|
|
485
|
+
- A tap outside the menu closes it, and that tap is spent closing it. It does not also follow the link or press the button that lay under it, so nothing on the page is activated by accident.
|
|
486
|
+
- A control in the header row itself — the theme toggle, a search button, the site name — works on the first tap: the menu closes and the control does its job. Only a tap on the page below is swallowed.
|
|
487
|
+
- When the list is taller than the screen below the header, the menu scrolls inside itself, so its last link is reachable without scrolling the page under it.
|
|
488
|
+
- These need a few lines of script where `<details>` alone is used; they are behaviour this rule requires, counted as `M-01` counts `Escape`.
|
|
484
489
|
- **A sticky header at phone width is one row.** Two sticky rows permanently spend a sixth of a phone's height on chrome.
|
|
485
490
|
- **The wide row appears where the labels fit, not at a device width.** Set the breakpoint from the content — the width at which every destination sits on one line at full touch size — so a longer label moves the breakpoint instead of breaking the row.
|
|
486
491
|
- **`operator`:** the wide screen is the real case. At phone width, one **Menu** button for everything is enough, and keyboard operation of the open menu is mandatory (`M-03`).
|
|
@@ -12,7 +12,7 @@ then do the work below for that subcommand. Read the command's full output —
|
|
|
12
12
|
findings are ordered by severity, not position, so `head`, `tail`, `grep` and
|
|
13
13
|
`jq` drop the ones that matter.
|
|
14
14
|
|
|
15
|
-
**Agent procedures** — `decide`, `spec`, `mockup`, `make`, `critique`. There is no binary. Do not try to run one:
|
|
15
|
+
**Agent procedures** — `decide`, `spec`, `mockup`, `make`, `critique`, `tweak`. There is no binary. Do not try to run one:
|
|
16
16
|
the section below **is** the command. A CLI can check what these produce; it
|
|
17
17
|
cannot do their work, because the work is judgment and authorship.
|
|
18
18
|
|
|
@@ -26,6 +26,9 @@ spec what exactly is being built: its smallest useful version, at every sc
|
|
|
26
26
|
mockup low-fidelity design of that spec, reviewed before any code
|
|
27
27
|
make high-fidelity: the actual page or feature, built from the spec & mockup
|
|
28
28
|
critique scrutinises what was built against the rules, its spec & mockup
|
|
29
|
+
|
|
30
|
+
tweak a small change the approved mockup does not show: decided if it is a
|
|
31
|
+
decision, specced, built, and re-judged where it could matter
|
|
29
32
|
```
|
|
30
33
|
|
|
31
34
|
## init
|
|
@@ -461,6 +464,14 @@ spec is not a weaker spec — it is not a spec, and `make` will refuse it.
|
|
|
461
464
|
If the user changes something, revise and ask again. The run is incomplete until
|
|
462
465
|
they confirm.
|
|
463
466
|
|
|
467
|
+
**Revising a spec whose drawing was approved.** If the revision adds, removes or
|
|
468
|
+
renames a region at any size, the approved drawing no longer shows the page: set
|
|
469
|
+
`mockup: pending`, and `{{command_prefix}}mockup` redraws it for the owner. The gate
|
|
470
|
+
checks it for a drawing in frames: while `mockup: approved`, each region the spec lists must be labelled in
|
|
471
|
+
its size's frame of the drawing at `mockup_at:`. On jig-site the header spec went
|
|
472
|
+
through five confirmed rounds after its drawing was approved, and still said
|
|
473
|
+
`approved` over a drawing of none of them.
|
|
474
|
+
|
|
464
475
|
## mockup
|
|
465
476
|
|
|
466
477
|
**Low-fidelity design.** Draw the confirmed spec in grayscale, at every size it
|
|
@@ -721,6 +732,16 @@ between them yourself.
|
|
|
721
732
|
|
|
722
733
|
If `mockup: skipped`, build from the spec alone.
|
|
723
734
|
|
|
735
|
+
**`make` does not edit `DECISIONS.md`.** The file records what the owner decided,
|
|
736
|
+
and `make` is where decisions are carried out, not taken. When the build shows a
|
|
737
|
+
decision is wrong, missing or in conflict with the spec, stop and say so: the owner
|
|
738
|
+
changes it through `{{command_prefix}}decide`, or `{{command_prefix}}tweak` for a
|
|
739
|
+
small change to a built page. A note that a decision has been carried out belongs in
|
|
740
|
+
the spec, under `deviations:` or its as-built notes, not in the decision. On jig-site
|
|
741
|
+
one make round added a decision and another rewrote one to match the tokens it had
|
|
742
|
+
just switched to; the next critique judged the page against text the build had
|
|
743
|
+
written. The gate stops a `make` session that changed `DECISIONS.md`.
|
|
744
|
+
|
|
724
745
|
Build V1 only. Nothing in `later:`, however little extra it looks.
|
|
725
746
|
|
|
726
747
|
Build the **phone** composition first, then add what `tablet` and `desktop`
|
|
@@ -874,16 +895,38 @@ A and C are kept apart for a narrower reason: an arm that has seen the render
|
|
|
874
895
|
stops reading the source and starts confirming the picture. They answer to
|
|
875
896
|
different evidence and must not share it.
|
|
876
897
|
|
|
898
|
+
**Nor does either arm read the page's history.** Earlier critiques' verdict
|
|
899
|
+
files, their reports, the folders set aside beside them, and the project's git
|
|
900
|
+
log and commit messages all say what a reviewer found before, and an arm that
|
|
901
|
+
has read "the header critique, 17 findings" judges the page it is shown against
|
|
902
|
+
that list. Tell each arm so in its brief: it reads the page, the spec, the
|
|
903
|
+
mockup, `DECISIONS.md` and the corpus, and runs no `git log`, `git show` or
|
|
904
|
+
`git diff`, and opens nothing else under `.jig/critique/`. Which earlier findings
|
|
905
|
+
were fixed is not the arms' question; `verdicts` answers it from git after both
|
|
906
|
+
have written (step 4).
|
|
907
|
+
|
|
877
908
|
### 1a. Arms A and C walk the index. They do not search it.
|
|
878
909
|
|
|
879
910
|
**This is not a style preference. It is the difference between covering the
|
|
880
911
|
corpus and sampling it.**
|
|
881
912
|
|
|
882
913
|
Load `rules.index.json`, filter to the pass the arm owns, and return a verdict
|
|
883
|
-
for **every id in that list**: `ok`, `finding`, or `n/a` with one line
|
|
884
|
-
reason. A rule that does not apply is still answered — `n/a, this page has no
|
|
914
|
+
for **every id in that list**: `ok`, `finding`, `ruled`, or `n/a` with one line
|
|
915
|
+
of reason. A rule that does not apply is still answered — `n/a, this page has no
|
|
885
916
|
form` is a verdict.
|
|
886
917
|
|
|
918
|
+
**`ruled` is a finding the owner has already decided.** The page breaks the rule
|
|
919
|
+
because a decision in `DECISIONS.md` says it should: an icon-only control the
|
|
920
|
+
owner ruled icon-only, a menu the owner put at every phone width. The verdict
|
|
921
|
+
names that decision in `ruling`, exactly as its heading reads, and `verdicts`
|
|
922
|
+
refuses a `ruled` with no ruling or with one the file does not hold. A ruled
|
|
923
|
+
verdict is reported, and counted apart (`ruled=`); it is not a finding, and it
|
|
924
|
+
does not go back to `make`, which could only leave it as it is. On jig-site a
|
|
925
|
+
header critique counted 15 findings, five of them rulings labelled "owner-ruled"
|
|
926
|
+
in prose: every count was inflated, and the real findings were harder to see.
|
|
927
|
+
A rule the page breaks with no decision behind it is a `finding`, however sure
|
|
928
|
+
you are the owner would agree.
|
|
929
|
+
|
|
887
930
|
Searching the corpus is how a *builder* works, and it is the right method there:
|
|
888
931
|
it finds the rules you can already name. It cannot find the rule for the mistake
|
|
889
932
|
you do not know you are making, which is the only kind of rule worth writing
|
|
@@ -945,7 +988,8 @@ Each reader arm writes its own file. The arm reports back only that it wrote it.
|
|
|
945
988
|
"verdicts": [
|
|
946
989
|
{ "id": "D-115", "verdict": "ok", "reason": "no sideways scroll at 360, 768, 1280 or 1600" },
|
|
947
990
|
{ "id": "P-14", "verdict": "finding", "reason": "menu button at 360 does not open; aria-expanded never set" },
|
|
948
|
-
{ "id": "A-60", "verdict": "n/a", "reason": "A-60 is about competing icons; this page has none" }
|
|
991
|
+
{ "id": "A-60", "verdict": "n/a", "reason": "A-60 is about competing icons; this page has none" },
|
|
992
|
+
{ "id": "E-51", "verdict": "ruled", "ruling": "The theme toggle is icon-only", "reason": "the toggle shows only a sun or a moon; the owner ruled it icon-only" }
|
|
949
993
|
]
|
|
950
994
|
}
|
|
951
995
|
```
|
|
@@ -1022,7 +1066,9 @@ was not read, requires `P-14` when the spec has navigation, and refuses
|
|
|
1022
1066
|
`rendered: true` without artefacts that exist. It ends with a `JIG_VERDICTS:` line.
|
|
1023
1067
|
|
|
1024
1068
|
When it fails, **re-run the arm it names**. Do not edit the file to make it pass, and
|
|
1025
|
-
do not report an incomplete arm as having run.
|
|
1069
|
+
do not report an incomplete arm as having run. If it says the probes were taken
|
|
1070
|
+
on an older page, `{{scripts_path}} verdicts <surface> --reprobe` re-takes them
|
|
1071
|
+
on the page as it is now. A live Haiku run of this command
|
|
1026
1072
|
before it existed produced invented rule ids, `screen=ran:1` filed as a review,
|
|
1027
1073
|
`code=ran:97` against 67, `ran:100` against both, and arms that reported to the
|
|
1028
1074
|
wrong agent entirely. Every one of those was forbidden here in prose. Prose did not
|
|
@@ -1111,6 +1157,17 @@ Say what is right as well as what is wrong — two or three things, and why they
|
|
|
1111
1157
|
work. A review that only lists faults tells the reader nothing about what to
|
|
1112
1158
|
preserve while fixing them.
|
|
1113
1159
|
|
|
1160
|
+
**Say what happened to the last round's findings.** `verdicts` compares this
|
|
1161
|
+
critique with the one before it in git and prints `Since the critique before
|
|
1162
|
+
this one: <n> fixed, <n> still open, <n> ruled by the owner, <n> new`, with the
|
|
1163
|
+
ids. Put that line in the report, as `verdicts` printed it, above the findings.
|
|
1164
|
+
The owner should not have to set two reports side by side to learn whether the
|
|
1165
|
+
make round worked. The comparison is made after both arms have written, so it
|
|
1166
|
+
tells the arms nothing.
|
|
1167
|
+
|
|
1168
|
+
List `ruled` verdicts after the findings, under their own heading, each with the
|
|
1169
|
+
decision it cites. They are not findings, and they are not hidden either.
|
|
1170
|
+
|
|
1114
1171
|
Then the attestation:
|
|
1115
1172
|
|
|
1116
1173
|
```text
|
|
@@ -1166,6 +1223,87 @@ Only then suggest `{{command_prefix}}spec` for the next feature. Starting the ne
|
|
|
1166
1223
|
with findings open means the second feature is built on top of the first one's
|
|
1167
1224
|
problems, and nobody goes back for them.
|
|
1168
1225
|
|
|
1226
|
+
## tweak
|
|
1227
|
+
|
|
1228
|
+
**A small change to a page that is built, reviewed and drawn,** one the owner's
|
|
1229
|
+
approved mockup does not show: a word or a sentence, a colour or its contrast, a
|
|
1230
|
+
weight, spacing inside a region, how text wraps, a focus ring. One pass does what
|
|
1231
|
+
the full loop would: record the decision if there is one, bring the spec in line,
|
|
1232
|
+
build it, and re-judge what the change could affect. There is no mockup step,
|
|
1233
|
+
because the drawing stays true, and no full critique, because nothing else moved.
|
|
1234
|
+
|
|
1235
|
+
### Refuse rather than guess
|
|
1236
|
+
|
|
1237
|
+
- **No confirmed spec for the page, or its `mockup:` is still pending** → this is
|
|
1238
|
+
a page being made, not changed. Run `{{command_prefix}}spec`, `mockup` and
|
|
1239
|
+
`make`.
|
|
1240
|
+
- **No critique record for the page yet** (`.jig/critique/<surface>/`) → nothing
|
|
1241
|
+
has been judged, so nothing can be re-judged. Run `{{command_prefix}}critique`.
|
|
1242
|
+
- **The change adds, removes, reorders or regroups a region, changes the
|
|
1243
|
+
navigation at any size, or would make the drawing wrong** → it is not a tweak.
|
|
1244
|
+
Say so and name the route: `spec`, then `mockup`, then `make`. The gate checks
|
|
1245
|
+
this whatever you decide: the regions under `sizes:` must match the ones the
|
|
1246
|
+
page had at its last critique, and the drawing must be untouched since then.
|
|
1247
|
+
`<surface>` is the spec's file name (`.jig/specs/<surface>.spec.md`), not its
|
|
1248
|
+
`surface:` field.
|
|
1249
|
+
- **It is unclear what the owner wants** → ask one question and stop.
|
|
1250
|
+
|
|
1251
|
+
### 1. Decide, if it is a decision
|
|
1252
|
+
|
|
1253
|
+
If the change is a ruling of the owner's, or changes one already recorded (a
|
|
1254
|
+
colour for a mark, how versions are written), record it in `DECISIONS.md` the way
|
|
1255
|
+
`decide` does: the owner's words, dated, and what it changes. If the change
|
|
1256
|
+
applies a rule, or answers a finding the rules already settle (a word stranded on
|
|
1257
|
+
its own line, `B-106`), there is nothing to decide; do not invent a decision.
|
|
1258
|
+
|
|
1259
|
+
### 2. Bring the spec in line, if it disagrees
|
|
1260
|
+
|
|
1261
|
+
If the spec says something the change contradicts, or the page will no longer
|
|
1262
|
+
look as the spec describes, amend those lines and add a dated **Tweak** entry at
|
|
1263
|
+
the end quoting the owner. Their instruction is the confirmation; do not ask for
|
|
1264
|
+
it again. Leave `sizes:` regions and the mockup as they are.
|
|
1265
|
+
|
|
1266
|
+
### 3. Build it
|
|
1267
|
+
|
|
1268
|
+
As `make` builds: the project's tokens and components, inside the structure the
|
|
1269
|
+
page already has, and only what the change needs. Run `{{scripts_path}} check`,
|
|
1270
|
+
then render and record the page (`{{scripts_path}} probe --run`) at every width
|
|
1271
|
+
it records.
|
|
1272
|
+
|
|
1273
|
+
### 4. Re-judge what the change could affect
|
|
1274
|
+
|
|
1275
|
+
Write `.jig/critique/<surface>/tweak.json`:
|
|
1276
|
+
|
|
1277
|
+
```json
|
|
1278
|
+
{
|
|
1279
|
+
"at": "2026-09-26T14:02:00Z",
|
|
1280
|
+
"change": "the owner's words for the change",
|
|
1281
|
+
"ids": ["A-60", "C-19", "The GitHub link is GitHub's own mark"]
|
|
1282
|
+
}
|
|
1283
|
+
```
|
|
1284
|
+
|
|
1285
|
+
`ids` names every rule and decision the change could move: the ones that judged
|
|
1286
|
+
the thing that changed, not the whole index. A colour change names the contrast
|
|
1287
|
+
and emphasis rules and the decision it applies; a reworded label names the copy
|
|
1288
|
+
rules for labels and the decision about naming.
|
|
1289
|
+
|
|
1290
|
+
Then a reader that did not make the change re-judges exactly those. It is a
|
|
1291
|
+
subagent, run in the foreground, given the page, the ids and each rule's text,
|
|
1292
|
+
and not this conversation or your reasons for the change. For each id it replaces
|
|
1293
|
+
that verdict in `screen.json`, `code.json` or `decisions.json` with its own
|
|
1294
|
+
verdict and reason, adding `"tweak": "<at>"`. It changes nothing else. Then run
|
|
1295
|
+
`{{scripts_path}} verdicts <surface>`.
|
|
1296
|
+
|
|
1297
|
+
The gate holds both ends: it refuses a tweak that changed a verdict it did not
|
|
1298
|
+
name, and one that named a verdict it did not re-judge.
|
|
1299
|
+
|
|
1300
|
+
### Finish
|
|
1301
|
+
|
|
1302
|
+
Report what changed, in the owner's words; the decision and spec lines you
|
|
1303
|
+
touched, if any; each re-judged verdict and what it says now; and the
|
|
1304
|
+
`JIG_CHECK` line. A re-judged verdict that is a finding goes to the owner: it is
|
|
1305
|
+
fixed by another tweak, or by `make` if it needs more.
|
|
1306
|
+
|
|
1169
1307
|
## probe
|
|
1170
1308
|
|
|
1171
1309
|
Run `{{scripts_path}} probe`. It prints one JavaScript expression — the render probe —
|
|
@@ -1391,9 +1529,10 @@ The accent appears as a whole field or not at all. A full-bleed section, a solid
|
|
|
1391
1529
|
button, a filled active state — never a 2px underline, never a small icon tint,
|
|
1392
1530
|
never a gradient stop. Its authority comes from arriving in quantity, rarely.
|
|
1393
1531
|
|
|
1394
|
-
**Why:**
|
|
1395
|
-
|
|
1396
|
-
|
|
1532
|
+
**Why:** "Thinly spread, it reads as decoration and stops meaning anything."
|
|
1533
|
+
|
|
1534
|
+
**Why (inferred):** the audit test that follows from it: if a coloured element is
|
|
1535
|
+
smaller than a section band, it is probably wrong.
|
|
1397
1536
|
```
|
|
1398
1537
|
|
|
1399
1538
|
A name gives the team something to cite in review. A reason lets a future agent
|
|
@@ -1408,23 +1547,30 @@ the same shape and judged against every page:
|
|
|
1408
1547
|
A reader leaves knowing which plan fits them and which limit they would hit
|
|
1409
1548
|
first. Nothing else on the page outranks that.
|
|
1410
1549
|
|
|
1411
|
-
**Why:**
|
|
1412
|
-
remove the last doubt, not to sell again.
|
|
1550
|
+
**Why:** "They arrive from the product page already interested. The job is to
|
|
1551
|
+
remove the last doubt, not to sell again."
|
|
1413
1552
|
|
|
1414
1553
|
### When two options conflict
|
|
1415
1554
|
|
|
1416
1555
|
Comprehension wins over persuasion. If a clearer page converts worse, we ship
|
|
1417
1556
|
the clearer page.
|
|
1418
1557
|
|
|
1419
|
-
**Why:**
|
|
1420
|
-
|
|
1558
|
+
**Why:** "Our reader is an engineer who has been sold to badly before, and a
|
|
1559
|
+
wrong-plan signup costs more in support than it brings in."
|
|
1421
1560
|
```
|
|
1422
1561
|
|
|
1423
|
-
**The reason is the owner's, or it is not written
|
|
1424
|
-
|
|
1425
|
-
none, write `**Why:** not given
|
|
1426
|
-
|
|
1427
|
-
|
|
1562
|
+
**The reason is the owner's, in quotation marks, or it is not written as theirs.**
|
|
1563
|
+
`**Why:**` holds the words the user gave, quoted. If they gave none, ask for it. If
|
|
1564
|
+
they still give none, write `**Why:** not given`. A live run invented reasons for
|
|
1565
|
+
two real reversals; an invented reason is worse than none, because the next agent
|
|
1566
|
+
weighs it as the team's and applies the rule where the team never would.
|
|
1567
|
+
|
|
1568
|
+
What you add to explain a reason, work out from it, or carry over from what the
|
|
1569
|
+
owner said elsewhere goes in its own paragraph, `**Why (inferred):**`, which says it
|
|
1570
|
+
is yours. On jig-site, reasons read "given directly by the owner", followed by
|
|
1571
|
+
sentences the agent had written; a later agent could not tell which half to weigh
|
|
1572
|
+
as the team's. The gate checks each decision this session wrote or changed: its
|
|
1573
|
+
`Why` is quoted, `not given`, or labelled inferred.
|
|
1428
1574
|
|
|
1429
1575
|
**Record what is still open in its own section, at the end:**
|
|
1430
1576
|
|
|
@@ -1449,7 +1595,8 @@ round by round, and the section of the file it went to — a named decision, its
|
|
|
1449
1595
|
`Why:`, or **Unresolved**. An answer that went nowhere is either added or shown to
|
|
1450
1596
|
the user as left out on purpose, with the reason. Then go the other way: every
|
|
1451
1597
|
`Why:` in the file must trace to something the user said; delete any that does not,
|
|
1452
|
-
and ask.
|
|
1598
|
+
and ask. A `Why (inferred):` traces to what it was inferred from; say what that was.
|
|
1599
|
+
Include this mapping in your message, below the file.
|
|
1453
1600
|
|
|
1454
1601
|
Show it and ask the user to confirm it is right before you stop. Leave no
|
|
1455
1602
|
`[TODO]` markers — the gate treats them as an unwritten file, correctly, because
|
|
@@ -68,5 +68,10 @@
|
|
|
68
68
|
"description": "Scrutinise what was built against the rules, its spec and its mockup: render it, judge what check cannot, and report by rule id. Needs DECISIONS.md. No CLI — the agent does this.",
|
|
69
69
|
"argumentHint": "<page, feature or functionality, in your own words>",
|
|
70
70
|
"status": "agent"
|
|
71
|
+
},
|
|
72
|
+
"tweak": {
|
|
73
|
+
"description": "A small change to a built page that its approved mockup does not show (wording, a colour, a weight, spacing inside a region, wrapping): records the decision if it is one, brings the spec in line, builds it, and re-judges only the verdicts the change could affect. Refuses a change that moves the page's structure. No CLI — the agent does this.",
|
|
74
|
+
"argumentHint": "<the change, in your own words>",
|
|
75
|
+
"status": "agent"
|
|
71
76
|
}
|
|
72
77
|
}
|