task-pipeline-skill 1.87.1 → 1.89.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +1 -1
  3. package/SKILL-CARD.md +1 -1
  4. package/package.json +3 -3
  5. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  6. package/plugins/task-pipeline/agents/verifier-product.md +3 -1
  7. package/plugins/task-pipeline/agents/verifier-visual.md +115 -0
  8. package/plugins/task-pipeline/agents/verifier.md +2 -1
  9. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +4 -4
  10. package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +22 -1
  11. package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +4 -4
  12. package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +12 -0
  13. package/plugins/task-pipeline/skills/task-pipeline/references/audit.md +14 -8
  14. package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +97 -3
  15. package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +46 -5
  16. package/plugins/task-pipeline/skills/task-pipeline/references/companion-skills.md +28 -0
  17. package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +17 -0
  18. package/plugins/task-pipeline/skills/task-pipeline/references/conventions.md +3 -3
  19. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -1
  20. package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +15 -9
  21. package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +23 -0
  22. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -1
  23. package/plugins/task-pipeline/skills/task-pipeline/references/progress.md +5 -1
  24. package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +27 -3
  25. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +75 -13
  26. package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +2 -2
  27. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +45 -16
  28. package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +292 -0
  29. package/plugins/task-pipeline/skills/task-pipeline/scripts/visual_gate.py +589 -0
  30. package/plugins/task-pipeline/skills/task-pipeline/templates/brief.md +5 -2
  31. package/plugins/task-pipeline/skills/task-pipeline/templates/browser-claims.json +223 -1
  32. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +11 -1
@@ -0,0 +1,589 @@
1
+ #!/usr/bin/env python3
2
+ """The visual half of the pipeline's gates, as commands with an exit code. Stdlib only.
3
+
4
+ visual_gate.py record <director-record.md> --class <surface_class>
5
+ [--validator CMD | --validator none] [--json]
6
+ stage 3: the VISUAL track left a director record, and the record carries the
7
+ fields its surface class owes — not merely "the track ran"
8
+ visual_gate.py sheet <contact-sheet.json> --class <surface_class>
9
+ [--artifact-root DIR] [--states a,b,…] [--require-approval] [--json]
10
+ stage 6 (and stage 10 with --require-approval): the contact sheet, a filled copy of
11
+ `templates/browser-claims.json` (browser-claims/1) whose look rows carry the
12
+ state × axes matrix, the capture record, the diff and the rubric
13
+ visual_gate.py lint <dir> [--linter CMD | --linter none] [--json]
14
+ stages 5–6: the project linter of sheleg-design (`--lint`), run where it exists
15
+ visual_gate.py filekeys --record <foundation.md or brief> --screens <screens.md> [--json]
16
+ stage 3 with Figma on: every frame link's file key is one of the files the project
17
+ recorded — one per surface (App, Web, ASO), never a file nobody recorded
18
+
19
+ `<surface_class>` is the brief's stage-0 answer: flagship | product | internal | ad. It
20
+ selects what each check owes (`references/stages.md` → stage 0, *The surface class*).
21
+
22
+ **A check that could not run says NOT_RUN, and NOT_RUN is never PASS.** The director-record
23
+ validator and the project linter belong to sheleg-design; where that package is absent, or
24
+ older than the flag, the command says so in words. `record` still checks the required
25
+ headings itself — that floor needs nothing installed — and reports the validator NOT_RUN
26
+ beside its own verdict, so a reader can tell the floor from the full check.
27
+
28
+ Exit codes: 0 PASS · 1 FAIL · 2 usage, or an input that cannot be read · 3 NOT_RUN (the check
29
+ could not run, or every row it would judge is still NOT_RUN — never a pass).
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import itertools
35
+ import json
36
+ import os
37
+ import re
38
+ import shlex
39
+ import subprocess
40
+ import sys
41
+ from datetime import datetime
42
+
43
+ SURFACE_CLASSES = ("flagship", "product", "internal", "ad")
44
+ # Where the visual look is a GATE. `internal` gets the deterministic floor (the linter) and
45
+ # a recommended look; a sheet there is checked for honesty, its coverage only reported.
46
+ GATED = ("flagship", "product", "ad")
47
+
48
+ # The director record's fields — `## <Field>` headings — and which class owes which. The
49
+ # record's own contract lives with sheleg-design; this is the floor the pipeline can check
50
+ # without it. Absent for `internal`: no record is owed there.
51
+ FULL_RECORD = ("Brief", "Mode", "Taste", "References", "Cast", "Fork", "Rubric", "Critique",
52
+ "Markers", "Alignment", "Quality", "Signature", "Surfaces", "Haptics", "ADA",
53
+ "Open")
54
+ RECORD_FIELDS = {
55
+ "flagship": FULL_RECORD,
56
+ "product": ("Brief", "Mode", "References", "Markers", "Open"),
57
+ "ad": ("Brief", "Mode", "References", "Markers", "ADA", "Open"),
58
+ "internal": (),
59
+ }
60
+ MODES = ("new", "redesign", "update", "audit", "declined")
61
+ # A heading whose body is one of these was never filled in. `n/a` is a valid answer only
62
+ # where the contract allows a bare one (Haptics on a surface that is not native); anywhere
63
+ # else it owes its reason, and a reason makes the body longer than the bare token.
64
+ PLACEHOLDER = re.compile(r"^(?:tbd|tba|todo|\.\.\.|…|-|—|n/?a|none|\?|<[^>]*>)\.?$", re.I)
65
+ BARE_NA_OK = ("Haptics",)
66
+
67
+ DEFAULT_TOOL = "npx --no-install sheleg-design-skill"
68
+
69
+ AXES = ("viewport", "theme", "text", "locale")
70
+ CAPTURE = ("revision", "route", "motion", "captured_at", "source")
71
+ RUBRIC_TYPES = ("G", "J", "H")
72
+ RUBRIC_STATUS = ("PASS", "FAIL", "NOT_ASSESSED", "uncertain", "unresolved")
73
+ DIFF_STATUS = ("PASS", "FAIL", "NOT_RUN")
74
+ KINDS = ("look", "suite", "library")
75
+ STATUSES = ("PASS", "FAIL", "NOT_RUN", "BLOCKED")
76
+ DEFAULT_TEXT = ("default", "base", "normal", "100%", "1x", "m", "medium", "large-off")
77
+ RTL = re.compile(r"(?:^|[^a-z])(?:rtl|ar|he|fa|ur|yi)(?:$|[^a-z])", re.I)
78
+ NARROW_PX = 400
79
+ FIGMA_KEY = re.compile(r"figma\.com/(?:design|file|proto|board|make|slides)/([A-Za-z0-9]{10,})")
80
+
81
+
82
+ # --- browser claims (browser-claims/1) ----------------------------------------
83
+ # The rules `references/browser.md` states for every claim, look or not. Kept here so a host
84
+ # project can run them; this repository's `test/browser_claims_test.py` imports them.
85
+
86
+ def claim_problems(claim, artifact_root=None):
87
+ """Every reason this claim must not be believed. Empty list = valid."""
88
+ out = []
89
+ cid = claim.get("id", "?")
90
+ for field in ("id", "req", "scenario", "state", "kind", "status"):
91
+ if not claim.get(field):
92
+ out.append(f"{cid}: {field} missing")
93
+ if claim.get("kind") not in KINDS:
94
+ out.append(f"{cid}: kind {claim.get('kind')!r} is not one of "
95
+ f"{sorted(KINDS)} — the look/suite/library split is the contract")
96
+ if claim.get("status") not in STATUSES:
97
+ out.append(f"{cid}: status {claim.get('status')!r} unknown")
98
+ if claim.get("status") == "NOT_RUN" and not claim.get("reason"):
99
+ out.append(f"{cid}: NOT_RUN carries its reason, always")
100
+ if claim.get("status") == "PASS" and claim.get("kind") == "look":
101
+ if not claim.get("artifact"):
102
+ out.append(f"{cid}: a visual PASS with no artifact is a functional "
103
+ "claim wearing a visual verdict")
104
+ else:
105
+ if artifact_root is not None and \
106
+ not os.path.isfile(os.path.join(artifact_root, claim["artifact"])):
107
+ out.append(f"{cid}: artifact {claim['artifact']} does not exist "
108
+ "— a named file that is not there proves nothing")
109
+ if claim.get("artifact_state") != claim.get("state"):
110
+ out.append(f"{cid}: artifact captured in state "
111
+ f"{claim.get('artifact_state')!r} cannot close a claim about "
112
+ f"{claim.get('state')!r} — the initial screenshot does not "
113
+ "close an opened/error state")
114
+ return out
115
+
116
+
117
+ def toggle_cycle_gaps(claims, component):
118
+ """Which of the full cycle's states the component's PASSing look claims miss."""
119
+ need = {"initial", "opened", "closed-again"}
120
+ have = {c["state"] for c in claims
121
+ if c.get("component") == component and c.get("kind") == "look"
122
+ and c.get("status") == "PASS"}
123
+ return sorted(need - have)
124
+
125
+
126
+ def suite_pass_closes_no_look(claims):
127
+ """Look claims that would be wrongly closed by a suite PASS: none may be."""
128
+ suite_green = any(c.get("kind") == "suite" and c.get("status") == "PASS"
129
+ for c in claims)
130
+ if not suite_green:
131
+ return []
132
+ return [c["id"] for c in claims
133
+ if c.get("kind") == "look" and c.get("status") == "PASS"
134
+ and not c.get("artifact")]
135
+
136
+
137
+ # --- the contact sheet (the visual look's rows) --------------------------------
138
+
139
+ def _filled(v):
140
+ return isinstance(v, str) and bool(v.strip())
141
+
142
+
143
+ def _iso(v):
144
+ if not _filled(v):
145
+ return False
146
+ try:
147
+ datetime.fromisoformat(v.replace("Z", "+00:00"))
148
+ return True
149
+ except ValueError:
150
+ return False
151
+
152
+
153
+ def is_visual_row(c):
154
+ return c.get("kind") == "look" and "axes" in c
155
+
156
+
157
+ def _large_text(v):
158
+ return _filled(v) and v.strip().lower() not in DEFAULT_TEXT
159
+
160
+
161
+ def _narrow(v):
162
+ if not _filled(v):
163
+ return False
164
+ s = v.lower()
165
+ if any(w in s for w in ("narrow", "compact", " se", "iphone se", "small")):
166
+ return True
167
+ m = re.match(r"\s*(\d{2,5})\s*[x×]", s)
168
+ return bool(m) and int(m.group(1)) <= NARROW_PX
169
+
170
+
171
+ def visual_row_problems(c, revision, approved):
172
+ """Everything wrong with one state × axes row, beyond the rules every claim obeys."""
173
+ out = []
174
+ cid = c.get("id", "?")
175
+ axes = c.get("axes")
176
+ if not isinstance(axes, dict):
177
+ return [f"{cid}: `axes` must be an object of {', '.join(AXES)}"]
178
+ for a in AXES:
179
+ if not _filled(axes.get(a)):
180
+ out.append(f"{cid}: axes.{a} is missing — a frame whose {a} nobody recorded "
181
+ "cannot answer a claim about any one of them")
182
+ for k in ("figma_frame", "baseline"):
183
+ if c.get(k) is not None and not _filled(c.get(k)):
184
+ out.append(f"{cid}: `{k}` is a path or URL, or null")
185
+ diff = c.get("diff")
186
+ if diff is not None:
187
+ if not isinstance(diff, dict):
188
+ out.append(f"{cid}: `diff` is an object or null")
189
+ diff = None
190
+ else:
191
+ if diff.get("against") not in ("figma", "baseline"):
192
+ out.append(f"{cid}: diff.against is {diff.get('against')!r} — it compares "
193
+ "against `figma` or `baseline`")
194
+ if diff.get("status") not in DIFF_STATUS:
195
+ out.append(f"{cid}: diff.status {diff.get('status')!r} is not one of "
196
+ f"{', '.join(DIFF_STATUS)}")
197
+ if diff.get("status") == "NOT_RUN" and not _filled(diff.get("reason")):
198
+ out.append(f"{cid}: a diff that did not run carries its reason")
199
+ rubric = c.get("rubric", [])
200
+ if not isinstance(rubric, list):
201
+ out.append(f"{cid}: `rubric` must be a list")
202
+ rubric = []
203
+ g_fail = []
204
+ for i, r in enumerate(rubric):
205
+ if not isinstance(r, dict):
206
+ out.append(f"{cid}: rubric[{i}] is not an object")
207
+ continue
208
+ rid = r.get("id") or f"rubric[{i}]"
209
+ if not _filled(r.get("id")):
210
+ out.append(f"{cid}: rubric[{i}] has no id")
211
+ if r.get("type") not in RUBRIC_TYPES:
212
+ out.append(f"{cid}: {rid}.type {r.get('type')!r} is not G, J or H")
213
+ st = r.get("status")
214
+ if st not in RUBRIC_STATUS:
215
+ out.append(f"{cid}: {rid}.status {st!r} is not one of {', '.join(RUBRIC_STATUS)}")
216
+ if st in ("FAIL", "unresolved"):
217
+ t = r.get("triple")
218
+ if not (isinstance(t, dict) and all(_filled(t.get(k))
219
+ for k in ("region", "defect", "fix"))):
220
+ out.append(f"{cid}: {rid} is {st} without its triple — region, defect, fix. "
221
+ "A finding nobody can locate and act on is an impression")
222
+ if r.get("type") == "J" and st in ("PASS", "FAIL") and not _filled(r.get("calibration")):
223
+ out.append(f"{cid}: {rid} is a judge item reported {st} with no `calibration` — "
224
+ "until a labelled set exists and the judge's agreement with it is "
225
+ "measured, a J item is NOT_ASSESSED, not a verdict")
226
+ if r.get("type") == "H" and st == "PASS" and not approved:
227
+ out.append(f"{cid}: {rid} is a human item reported PASS on a sheet nobody "
228
+ "approved — only the person who reviewed the sheet can pass it")
229
+ if r.get("type") == "G" and st == "FAIL":
230
+ g_fail.append(rid)
231
+ if c.get("status") == "PASS":
232
+ cap = c.get("capture")
233
+ if not isinstance(cap, dict):
234
+ out.append(f"{cid}: a PASS frame carries its capture record "
235
+ f"({', '.join(CAPTURE)}) — a screenshot with no record is an image, "
236
+ "not evidence")
237
+ else:
238
+ for k in CAPTURE:
239
+ if not _filled(cap.get(k)):
240
+ out.append(f"{cid}: capture.{k} is missing")
241
+ if _filled(cap.get("captured_at")) and not _iso(cap["captured_at"]):
242
+ out.append(f"{cid}: capture.captured_at {cap['captured_at']!r} is not "
243
+ "ISO-8601, so its staleness cannot be computed")
244
+ if _filled(revision) and _filled(cap.get("revision")) and \
245
+ cap["revision"] != revision:
246
+ out.append(f"{cid}: captured at {cap['revision']!r} while the sheet is for "
247
+ f"{revision!r} — a stale frame proves the revision before")
248
+ if (c.get("figma_frame") or c.get("baseline")) and diff is None:
249
+ out.append(f"{cid}: a frame with a reference ({'figma_frame' if c.get('figma_frame') else 'baseline'}) "
250
+ "and no `diff` — the comparison is the point of having the reference")
251
+ if diff is not None and diff.get("status") == "FAIL":
252
+ out.append(f"{cid}: PASS over a failing diff against {diff.get('against')} — "
253
+ "either the frame drifted, or a person approves a new baseline; "
254
+ "neither is a PASS written by the run")
255
+ if g_fail:
256
+ out.append(f"{cid}: PASS while gate item(s) {', '.join(g_fail)} FAIL — a "
257
+ "deterministic gate item is a floor the judge never overrides")
258
+ return out
259
+
260
+
261
+ def coverage_gaps(rows, states):
262
+ """Holes in the state × axes matrix. Pairwise over the axes, plus the mandatory pairs."""
263
+ gaps = []
264
+ seen_states = {c.get("state") for c in rows}
265
+ for s in states:
266
+ if s not in seen_states:
267
+ gaps.append(f"state {s!r} has no frame — a hole in the matrix")
268
+ ax = [c.get("axes") for c in rows if isinstance(c.get("axes"), dict)]
269
+ if not ax:
270
+ return gaps + ["no state × axes row at all"]
271
+ if not any(_large_text(a.get("text")) for a in ax):
272
+ gaps.append("the text axis never leaves its default — large text (200% / AX5) is a "
273
+ "mandatory column")
274
+ darks = [a for a in ax if "dark" in str(a.get("theme", "")).lower()]
275
+ if darks and any(_large_text(a.get("text")) for a in ax) and \
276
+ not any(_large_text(a.get("text")) for a in darks):
277
+ gaps.append("no frame is dark × large text — a mandatory pair")
278
+ rtls = [a for a in ax if RTL.search(str(a.get("locale", "")))]
279
+ if rtls and not any(_narrow(a.get("viewport")) for a in rtls):
280
+ gaps.append("no frame is RTL × narrow — a mandatory pair")
281
+ # Pairwise coverage: every value of one axis meets every value of every other axis in
282
+ # SOME frame. The full cross product is what makes a sheet unreadable; pairs are the
283
+ # smallest set that still catches a defect that appears only where one axis meets another.
284
+ values = {k: sorted({str(a.get(k)) for a in ax if _filled(a.get(k))}) for k in AXES}
285
+ for x, y in itertools.combinations(AXES, 2):
286
+ have = {(str(a.get(x)), str(a.get(y))) for a in ax}
287
+ for vx in values[x]:
288
+ for vy in values[y]:
289
+ if (vx, vy) not in have:
290
+ gaps.append(f"pairwise: no frame is {x}={vx} × {y}={vy}")
291
+ return gaps
292
+
293
+
294
+ def sheet_report(doc, surface_class, artifact_root=None, states=(), require_approval=False):
295
+ """(verdict, problems, notes) for a contact sheet at one surface class."""
296
+ problems, notes = [], []
297
+ if not isinstance(doc, dict) or doc.get("schema_version") != "browser-claims/1":
298
+ return "FAIL", ["not a browser-claims/1 document — the contact sheet extends that "
299
+ "file, there is no second schema"], notes
300
+ claims = doc.get("claims")
301
+ if not isinstance(claims, list):
302
+ return "FAIL", ["`claims` must be a list"], notes
303
+ for c in claims:
304
+ problems += claim_problems(c, artifact_root)
305
+ for cid in suite_pass_closes_no_look(claims):
306
+ problems.append(f"{cid}: a suite PASS cannot close a look claim with no artifact")
307
+ rows = [c for c in claims if is_visual_row(c)]
308
+ approved = _filled(doc.get("approved_by")) or _filled(doc.get("approved_at"))
309
+ if rows or surface_class in GATED:
310
+ for k in ("surface", "revision"):
311
+ if not _filled(doc.get(k)):
312
+ problems.append(f"the sheet names no `{k}` — a contact sheet is for one "
313
+ "surface at one revision")
314
+ rr = doc.get("review_rounds")
315
+ if not isinstance(rr, int) or isinstance(rr, bool) or rr < 0:
316
+ problems.append("`review_rounds` must be a whole number — it is how passes are "
317
+ "measured, and a sheet that cannot say how many returns it took "
318
+ "cannot show the count falling")
319
+ if approved and not (_filled(doc.get("approved_by")) and _iso(doc.get("approved_at"))):
320
+ problems.append("an approval names who (`approved_by`) and when (`approved_at`, "
321
+ "ISO-8601) — half an approval is not one")
322
+ if require_approval and not approved:
323
+ problems.append("the sheet is not approved — at acceptance the contact sheet is the "
324
+ "human's one pass, and an unapproved sheet is a pass that did not "
325
+ "happen")
326
+ if surface_class in GATED and not rows:
327
+ problems.append(f"a {surface_class} surface with no state × axes row — the visual "
328
+ "look did not run, and on this class it is a gate")
329
+ j_fail = set()
330
+ pending = []
331
+ for c in rows:
332
+ problems += visual_row_problems(c, doc.get("revision"), approved)
333
+ for r in c.get("rubric") or []:
334
+ if isinstance(r, dict):
335
+ if r.get("type") == "J" and r.get("status") == "FAIL":
336
+ j_fail.add(r.get("id"))
337
+ if r.get("status") in ("uncertain", "unresolved"):
338
+ pending.append(f"{c.get('id')}:{r.get('id')} {r.get('status')}")
339
+ if c.get("status") in ("NOT_RUN", "BLOCKED"):
340
+ pending.append(f"{c.get('id')} {c.get('status')}")
341
+ if surface_class == "flagship" and len(j_fail) > 2:
342
+ problems.append(f"{len(j_fail)} judge items FAIL ({', '.join(sorted(map(str, j_fail)))}) "
343
+ "— the flagship profile admits at most two, each with its triple")
344
+ if rows:
345
+ gaps = coverage_gaps(rows, states)
346
+ if surface_class == "flagship":
347
+ problems += gaps
348
+ elif surface_class in ("product", "ad"):
349
+ hard = [g for g in gaps if not g.startswith("pairwise:")]
350
+ problems += hard
351
+ notes += [g for g in gaps if g.startswith("pairwise:")]
352
+ else:
353
+ notes += gaps
354
+ if isinstance(doc.get("review_rounds"), int) and doc["review_rounds"] > 2 and not approved:
355
+ notes.append(f"review_rounds is {doc['review_rounds']}: past the budget of two — the "
356
+ "open items go to the person as `unresolved`, not into another round")
357
+ if pending:
358
+ notes.append("for the person on the sheet: " + "; ".join(pending))
359
+ if problems:
360
+ return "FAIL", problems, notes
361
+ # Honest on every class: a frame that did not run is NOT_RUN in the verdict as well as
362
+ # in its row. Whether that blocks the stage is the class's business — on `internal`
363
+ # the look is recommended, so the stage reads NOT_RUN and goes on, saying so.
364
+ if any(c.get("status") in ("NOT_RUN", "BLOCKED") for c in rows):
365
+ return "NOT_RUN", problems, notes
366
+ return "PASS", problems, notes
367
+
368
+
369
+ # --- the director record --------------------------------------------------------
370
+
371
+ def _sections(text):
372
+ """`## Heading` -> body, keyed by the heading's first word."""
373
+ out = {}
374
+ parts = re.split(r"^##[ \t]+(.+?)[ \t]*$", text, flags=re.M)
375
+ for i in range(1, len(parts), 2):
376
+ m = re.match(r"([A-Za-z]+)", parts[i].strip())
377
+ if m:
378
+ body = re.sub(r"<!--.*?-->", "", parts[i + 1], flags=re.S).strip()
379
+ out.setdefault(m.group(1).lower(), body)
380
+ return out
381
+
382
+
383
+ def _declared_class(text):
384
+ head = text.split("\n## ", 1)[0]
385
+ m = re.search(r"surface_class\**\s*:\s*`?\s*([a-z]+)", head, re.I)
386
+ return m.group(1).lower() if m else None
387
+
388
+
389
+ def record_floor(text, surface_class):
390
+ """(problems, declined) — the headings the class owes, present and filled."""
391
+ problems = []
392
+ sec = _sections(text)
393
+ declared = _declared_class(text)
394
+ if declared is None:
395
+ problems.append("the record declares no `surface_class:` in its header")
396
+ elif declared not in SURFACE_CLASSES:
397
+ problems.append(f"the record's surface_class {declared!r} is not one of "
398
+ f"{', '.join(SURFACE_CLASSES)}")
399
+ elif declared != surface_class:
400
+ problems.append(f"the record says surface_class {declared!r} and the brief says "
401
+ f"{surface_class!r} — one of them is stale, and the gate profile "
402
+ "depends on which")
403
+ mode_body = sec.get("mode", "")
404
+ mode = (re.match(r"[`*_\s]*([A-Za-z]+)", mode_body) or [None, ""])[1].lower()
405
+ if "mode" not in sec or not mode:
406
+ problems.append("## Mode is missing or empty")
407
+ return problems, False
408
+ if mode not in MODES:
409
+ problems.append(f"## Mode is {mode!r} — one of {', '.join(MODES)}")
410
+ if mode == "declined":
411
+ reason = re.sub(r"^[`*_\s]*declined[`*_\s:—-]*", "", mode_body, flags=re.I).strip()
412
+ if len(reason.split()) < 3:
413
+ problems.append("## Mode: declined carries no reason — a refusal passes the gate "
414
+ "only when it says why, in words a reader can disagree with")
415
+ return problems, True
416
+ for field in RECORD_FIELDS[surface_class]:
417
+ body = sec.get(field.lower())
418
+ if body is None:
419
+ problems.append(f"## {field} is missing — the {surface_class} profile owes it")
420
+ elif not body or (PLACEHOLDER.match(body) and
421
+ not (field in BARE_NA_OK and re.match(r"^n/?a\.?$", body, re.I))):
422
+ problems.append(f"## {field} is empty ({body!r}) — a heading with nothing under "
423
+ "it is the fact of the track, not its trace")
424
+ return problems, False
425
+
426
+
427
+ def _run_tool(cmd, flag, args, timeout=180):
428
+ """("PASS"|"FAIL"|"NOT_RUN", detail). The tool is sheleg-design's CLI."""
429
+ if cmd.strip().lower() == "none":
430
+ return "NOT_RUN", "disabled with `none`"
431
+ argv = shlex.split(cmd)
432
+ try:
433
+ h = subprocess.run(argv + ["--help"], capture_output=True, text=True, timeout=timeout)
434
+ except FileNotFoundError:
435
+ return "NOT_RUN", f"`{argv[0]}` is not installed"
436
+ except (OSError, subprocess.SubprocessError) as e:
437
+ return "NOT_RUN", f"`{cmd} --help` could not run ({type(e).__name__})"
438
+ if flag not in (h.stdout + h.stderr):
439
+ return "NOT_RUN", (f"`{cmd}` does not offer {flag} — sheleg-design is not installed, "
440
+ "or older than that flag")
441
+ try:
442
+ r = subprocess.run(argv + [flag] + args, capture_output=True, text=True,
443
+ timeout=timeout)
444
+ except (OSError, subprocess.SubprocessError) as e:
445
+ return "NOT_RUN", f"`{cmd} {flag}` could not run ({type(e).__name__})"
446
+ tail = (r.stdout + r.stderr).strip()[-1500:]
447
+ if r.returncode == 0:
448
+ return "PASS", tail
449
+ if r.returncode == 1:
450
+ return "FAIL", tail
451
+ return "NOT_RUN", f"`{cmd} {flag}` exited {r.returncode}: {tail[-300:]}"
452
+
453
+
454
+ def cmd_record(a):
455
+ if a.surface_class == "internal":
456
+ return _emit(a, "record", "PASS", [], ["no director record is owed by an internal "
457
+ "surface; the floor there is the linter"])
458
+ try:
459
+ text = open(a.path, encoding="utf-8").read()
460
+ except OSError:
461
+ return _emit(a, "record", "FAIL", [f"{a.path} does not exist — the VISUAL track "
462
+ "leaves a director record, or a recorded refusal"],
463
+ [])
464
+ problems, declined = record_floor(text, a.surface_class)
465
+ status, detail = _run_tool(a.validator, "--check-record", [a.path])
466
+ notes = [f"validator: {status}" + (f" — {detail}" if status != "PASS" else "")]
467
+ if declined and not problems:
468
+ notes.insert(0, "the visual track was declined, with its reason — a recorded "
469
+ "refusal passes")
470
+ floor = "FAIL" if problems else "PASS"
471
+ if status == "FAIL":
472
+ problems.append("the record validator refused it: " + detail)
473
+ verdict = "FAIL" if problems else "PASS"
474
+ return _emit(a, "record", verdict, problems, notes,
475
+ extra={"floor": floor, "validator": status})
476
+
477
+
478
+ def cmd_sheet(a):
479
+ try:
480
+ doc = json.load(open(a.path, encoding="utf-8"))
481
+ except (OSError, ValueError) as e:
482
+ print(f"visual_gate: cannot read {a.path} ({type(e).__name__})", file=sys.stderr)
483
+ return 2
484
+ states = [s for s in (a.states or "").split(",") if s.strip()]
485
+ verdict, problems, notes = sheet_report(doc, a.surface_class, a.artifact_root, states,
486
+ a.require_approval)
487
+ return _emit(a, "sheet", verdict, problems, notes)
488
+
489
+
490
+ def cmd_lint(a):
491
+ status, detail = _run_tool(a.linter, "--lint", [a.dir, "--json"])
492
+ if status == "NOT_RUN":
493
+ return _emit(a, "lint", "NOT_RUN", [], [detail])
494
+ counts = {}
495
+ try:
496
+ for f in json.loads(detail[detail.index("["):]):
497
+ counts[f.get("severity", "?")] = counts.get(f.get("severity", "?"), 0) + 1
498
+ except (ValueError, AttributeError, TypeError):
499
+ pass
500
+ summary = ", ".join(f"{k} {v}" for k, v in sorted(counts.items())) or "no findings parsed"
501
+ if status == "FAIL":
502
+ return _emit(a, "lint", "FAIL", [f"the project linter exited 1 ({summary})"], [])
503
+ return _emit(a, "lint", "PASS", [], [f"findings by severity: {summary}"])
504
+
505
+
506
+ def _section(text, title):
507
+ m = re.search(r"^(#{1,6})[ \t]+[^\n]*" + re.escape(title) + r"[^\n]*$", text, re.M | re.I)
508
+ if not m:
509
+ return None
510
+ level = len(m.group(1))
511
+ rest = text[m.end():]
512
+ nxt = re.search(r"^#{1,%d}[ \t]" % level, rest, re.M)
513
+ return rest[:nxt.start()] if nxt else rest
514
+
515
+
516
+ def cmd_filekeys(a):
517
+ try:
518
+ rec = open(a.record, encoding="utf-8").read()
519
+ scr = open(a.screens, encoding="utf-8").read()
520
+ except OSError as e:
521
+ print(f"visual_gate: cannot read an input ({e.filename})", file=sys.stderr)
522
+ return 2
523
+ body = _section(rec, "Design tooling")
524
+ recorded = sorted(set(FIGMA_KEY.findall(body if body is not None else rec)))
525
+ used = sorted(set(FIGMA_KEY.findall(scr)))
526
+ problems = []
527
+ if used and not recorded:
528
+ problems.append("frames link to Figma and the record names no file — the "
529
+ "destination was never decided, or was decided somewhere else")
530
+ for k in used:
531
+ if recorded and k not in recorded:
532
+ problems.append(f"frame file key {k} is not a recorded file — a second file "
533
+ "nobody will open, holding real work")
534
+ notes = [f"recorded: {', '.join(recorded) or 'none'}",
535
+ f"linked from screens: {', '.join(used) or 'none'}"]
536
+ return _emit(a, "filekeys", "FAIL" if problems else "PASS", problems, notes)
537
+
538
+
539
+ EXIT = {"PASS": 0, "FAIL": 1, "NOT_RUN": 3}
540
+
541
+
542
+ def _emit(a, check, verdict, problems, notes, extra=None):
543
+ if getattr(a, "json", False):
544
+ out = {"check": check, "verdict": verdict, "problems": problems, "notes": notes}
545
+ out.update(extra or {})
546
+ print(json.dumps(out, ensure_ascii=False))
547
+ else:
548
+ cls = getattr(a, "surface_class", None)
549
+ print(f"{check}: {verdict}" + (f" — class {cls}" if cls else ""))
550
+ for p in problems:
551
+ print(f" ✗ {p}")
552
+ for n in notes:
553
+ print(f" · {n}")
554
+ return EXIT[verdict]
555
+
556
+
557
+ def main(argv):
558
+ p = argparse.ArgumentParser(prog="visual_gate.py",
559
+ description="The visual half of the gates; NOT_RUN is never PASS.")
560
+ sub = p.add_subparsers(dest="cmd", required=True)
561
+ r = sub.add_parser("record", help="stage 3: the director record carries its fields")
562
+ r.add_argument("path")
563
+ r.add_argument("--class", dest="surface_class", required=True, choices=SURFACE_CLASSES)
564
+ r.add_argument("--validator", default=DEFAULT_TOOL,
565
+ help=f"the record validator's command (default `{DEFAULT_TOOL}`), or none")
566
+ r.add_argument("--json", action="store_true")
567
+ s = sub.add_parser("sheet", help="stages 6 and 10: the contact sheet")
568
+ s.add_argument("path")
569
+ s.add_argument("--class", dest="surface_class", required=True, choices=SURFACE_CLASSES)
570
+ s.add_argument("--artifact-root", help="where the frames live, so a named file is checked")
571
+ s.add_argument("--states", help="comma-separated SCR states the matrix must cover")
572
+ s.add_argument("--require-approval", action="store_true",
573
+ help="stage 10: the sheet carries the person's approval")
574
+ s.add_argument("--json", action="store_true")
575
+ li = sub.add_parser("lint", help="stages 5–6: sheleg-design's project linter")
576
+ li.add_argument("dir")
577
+ li.add_argument("--linter", default=DEFAULT_TOOL)
578
+ li.add_argument("--json", action="store_true")
579
+ f = sub.add_parser("filekeys", help="stage 3: frame links stay in the recorded files")
580
+ f.add_argument("--record", required=True)
581
+ f.add_argument("--screens", required=True)
582
+ f.add_argument("--json", action="store_true")
583
+ a = p.parse_args(argv)
584
+ return {"record": cmd_record, "sheet": cmd_sheet, "lint": cmd_lint,
585
+ "filekeys": cmd_filekeys}[a.cmd](a)
586
+
587
+
588
+ if __name__ == "__main__":
589
+ sys.exit(main(sys.argv[1:]))
@@ -8,6 +8,9 @@
8
8
  - **Task (one line):** <what the operator asked for, restated>
9
9
  - **UI verdict:** yes / no — does this touch a user-facing surface (web/mobile/CLI/TUI)?
10
10
  If yes, the stage-3 super-ux UX track is armed.
11
+ - **surface_class:** flagship / product / internal / ad — UI tasks only. Selects the
12
+ visual gate profile: the director record's fields at stage 3, whether the visual half
13
+ of the look gates stage 6, and whether stage 10 needs the approved contact sheet.
11
14
 
12
15
  ## Contents
13
16
 
@@ -129,9 +132,9 @@ is not neutral — it is a scheduled interruption.
129
132
  | 0 Docs regime | Where settled things live (register or ADR set — one home, never both); who may write it; lease mechanism present, or is this run `ungated`? Gate command + ratchet floors; may this run raise a floor? | … |
130
133
  | 1 Docs | External libs/APIs/SDKs in play; any context7 can't resolve → where their docs live | … |
131
134
  | 2 Decompose | Platform (several capabilities/surfaces) or one module? If platform — deploy cadence: per module, or once at the end | … |
132
- | 2–3 Spec | UI verdict (arms super-ux); scenario-tracing waiver, if any | … |
135
+ | 2–3 Spec | UI verdict (arms super-ux) and `surface_class`; scenario-tracing waiver, if any | … |
133
136
  | 3 Design surface | UI only: Figma on or text-only (check `docs/ux/foundation.md` → Design tooling first); Figma MCP connected? **If not — ship text-only, or stop and connect it?** | … (super-ux never blocks on a missing MCP, so an unanswered row here ships the feature without mockups) |
134
- | 3 Design file | Figma on only: **which team/org + which file** — existing URL, or "create one in team `<name>`" **with creation authorized**. Canonical record: `docs/ux/foundation.md` → Design tooling | … (team: `<name>` · file: `<url>` \| `create in <team>, authorized` — never create when a recorded file resolves) |
137
+ | 3 Design file | Figma on only: **which team/org + which file per surface** (App, Web, ASO) — existing URL, or "create one in team `<name>`" **with creation authorized**. Canonical record: `docs/ux/foundation.md` → Design tooling | … (team: `<name>` · App: `<url>` · Web: `<url>` · ASO: `<url>` \| `create in <team>, authorized` — never create when a recorded file resolves) |
135
138
  | 4–5 Dev | Base branch; worktree/branch policy; is `main` off-limits; commit convention; task tracker | … |
136
139
  | 5 Integration | How the branch lands — direct merge, PR (who approves), or "leave it, I'll merge"; is parallel fan-out (one worktree per implementer) wanted? | … |
137
140
  | 6 Tests | Test command; what "green" means; known-red baseline; coverage expectation | … |