page-foundry 3.2.0 → 3.4.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.
@@ -27,12 +27,23 @@ Exit code 0 = no HARD FAILs, 1 = HARD FAILs found, 2 = usage/environment error.
27
27
  """
28
28
 
29
29
  import hashlib
30
+ import json
30
31
  import os
31
32
  import re
32
33
  import sys
33
34
  from datetime import date, datetime
34
35
  from pathlib import Path
35
36
 
37
+ # Sibling stdlib script, imported in-process (no subprocess, no third-party dep)
38
+ # so Gate 0 can RE-RUN the deterministic voice scan and catch a copy that never
39
+ # went through the Gate 2 scan or shipped over its failures (#42 tier 1). Import
40
+ # is side-effect-free: voice_scan only runs under `if __name__ == "__main__"`.
41
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
42
+ try:
43
+ import voice_scan # noqa: E402
44
+ except Exception: # pragma: no cover - defensive: sibling missing/broken
45
+ voice_scan = None
46
+
36
47
  MODES = {"build", "explore", "handoff"}
37
48
  PUBLIC_PRICING_ARCHETYPES = {
38
49
  "pricing-page",
@@ -82,6 +93,12 @@ REQUIRED_FILES = (
82
93
  ("run", "copy/hero-candidates.md", 2),
83
94
  ("run", "copy/approved-draft.md", 4),
84
95
  ("run", "copy/copy-approved.md", 4),
96
+ # #42 tier 2: the LLM judgment passes leave first-class, auditable artifacts
97
+ # that carry specifics only a real run produces, so backfill costs more than
98
+ # "write plausible prose". humanizer.md must show the before/after diffs it
99
+ # made; red-team.md must carry the per-segment reader walks and bounce points.
100
+ ("run", "copy/humanizer.md", 4),
101
+ ("run", "red-team.md", 5),
85
102
  ("run", "conversion-audit.md", 4),
86
103
  )
87
104
  HANDOFF_FILES = (
@@ -115,9 +132,11 @@ CORE_ARTIFACT_COMPANION = {
115
132
  "persuasion-map.md": "marketing-psychology",
116
133
  "conversion-audit.md": "cro",
117
134
  "copy/hero-candidates.md": "copywriting",
118
- # frontend-design (theme.css) and humanizer (voice pass) are core too but
119
- # their outputs are checked elsewhere; impeccable's DESIGN.md is handled by a
120
- # dedicated workspace-root check in audit_run (it is not run-dir-relative).
135
+ "copy/humanizer.md": "humanizer",
136
+ # frontend-design (theme.css) is core too but checked elsewhere; impeccable's
137
+ # DESIGN.md and detect scan are handled by dedicated workspace-root checks in
138
+ # audit_run (not run-dir-relative). red-team.md has no single companion owner
139
+ # (it is page-foundry's own Phase 3 / Gate 1 walk) and so is always required.
121
140
  }
122
141
  POINTER_RE = re.compile(
123
142
  r"(https?://|[A-Za-z0-9_./-]+:\d+|#[A-Za-z][\w:-]*|\b(?:PR|MR|GH-|JIRA-|PF-|BUG-|ISSUE-|TASK-|TICKET-)?[A-Z]+-\d+\b|\b[a-f0-9]{7,40}\b)",
@@ -283,6 +302,7 @@ def parse_preflight(text):
283
302
  # OUTPUT artifact mandatory.
284
303
  states = {}
285
304
  override = set()
305
+ taste = {} # voice / design-reference status (the external taste inputs, #41)
286
306
  for raw in text.splitlines():
287
307
  line = raw.strip()
288
308
  m = re.match(r"^\|\s*([A-Za-z0-9_-]+)\s*\|\s*(core|enhancer)\s*\|\s*([A-Za-z-]+)\s*\|", line)
@@ -295,7 +315,13 @@ def parse_preflight(text):
295
315
  for name in re.split(r"[,\s]+", val):
296
316
  if name.strip():
297
317
  override.add(name.strip().lower())
298
- return states, override
318
+ tv = re.match(r"^voice:\s*(.+)$", line, re.I)
319
+ if tv:
320
+ taste["voice"] = tv.group(1).strip().lower()
321
+ td = re.match(r"^design-reference:\s*(.+)$", line, re.I)
322
+ if td:
323
+ taste["design-reference"] = td.group(1).strip().lower()
324
+ return states, override, taste
299
325
 
300
326
 
301
327
  def companion_available(states, override, companion):
@@ -309,6 +335,127 @@ def companion_available(states, override, companion):
309
335
  return state in ("present", "outdated")
310
336
 
311
337
 
338
+ # Aliases the override line may use to waive each external taste input (#41).
339
+ TASTE_OVERRIDE_ALIASES = {
340
+ "voice": {"voice"},
341
+ "design-reference": {"design-reference", "design", "design-ref", "reference"},
342
+ }
343
+
344
+
345
+ def check_taste_inputs(results, preflight_path, taste, override):
346
+ # #41: the two external taste inputs are what keep the page from being
347
+ # invented out of same-family model priors (the AI-default look). A run may
348
+ # legitimately proceed without one, but only as an honest PARTIAL: the
349
+ # absence is surfaced here as a WARN so the gate report and the banner say
350
+ # distinctiveness is not guaranteed. It is NOT a hard FAIL -- declining a
351
+ # taste input is a valid, user-approved state, not a broken chain. What IS a
352
+ # FAIL (a false claim) is handled by the degraded-claim cross-check; here we
353
+ # only read what preflight recorded.
354
+ #
355
+ # A missing line is treated as missing input, not as absent evidence: the
356
+ # preflight format (SKILL.md Phase -1) requires both lines, so an omitted
357
+ # line means the run never established the input.
358
+ voice = taste.get("voice", "").strip().lower()
359
+ design = taste.get("design-reference", "").strip().lower()
360
+
361
+ def overridden(key):
362
+ return bool(TASTE_OVERRIDE_ALIASES[key] & override)
363
+
364
+ # Voice: `default` (or absent) with no operator samples == no human register.
365
+ voice_present = voice in ("configured", "samples-supplied")
366
+ if not voice_present and not overridden("voice"):
367
+ emit(results, "WARN", preflight_path,
368
+ "voice taste input missing (voice: %s); run is PARTIAL, copy has no human "
369
+ "register to match -- gate report and banner must say distinctiveness is not "
370
+ "guaranteed (SKILL.md Phase -1 step 6, Phase 3)" % (voice or "unset"))
371
+
372
+ # Design reference: `none` (or absent) == taste invented from model priors.
373
+ design_present = bool(design) and design != "none"
374
+ if not design_present and not overridden("design-reference"):
375
+ emit(results, "WARN", preflight_path,
376
+ "design reference missing (design-reference: %s); run is PARTIAL, the "
377
+ "aesthetic is derived from model priors -- gate report and banner must say "
378
+ "distinctiveness is not guaranteed (SKILL.md Phase -1 step 6, Phase 4)"
379
+ % (design or "unset"))
380
+
381
+
382
+ def rescan_voice(results, run_dir):
383
+ # #42 tier 1 (deterministic re-run): re-run the voice_scan tool IN-PROCESS
384
+ # over the frozen copy and fail if it finds FAIL-level violations. This is the
385
+ # un-fakeable half of the anti-backfill teeth -- a deterministic tool's output
386
+ # cannot be convincingly hand-authored, and here we do not even trust a stored
387
+ # report: we recompute the verdict from the shipped copy. A copy that carries
388
+ # banned phrases or em dashes means the Gate 2 scan never ran or shipped over
389
+ # its failures, whatever any evidence line claims. No subprocess, no network:
390
+ # voice_scan is a sibling stdlib module.
391
+ copy_path = run_dir / "copy" / "copy-approved.md"
392
+ if not copy_path.is_file():
393
+ return # its absence is already a required-file FAIL elsewhere
394
+ if voice_scan is None:
395
+ emit(results, "WARN", copy_path,
396
+ "voice_scan module unavailable; could not re-run the Gate 2 scan (#42)")
397
+ return
398
+ try:
399
+ banned, judgment, patterns, cfg, _src = voice_scan.load_rules(None)
400
+ fails, _warns = voice_scan.scan_file(copy_path, banned, judgment, patterns, cfg)
401
+ except Exception as exc: # pragma: no cover - defensive
402
+ emit(results, "WARN", copy_path, "voice re-scan errored (#42)", str(exc))
403
+ return
404
+ if fails:
405
+ first = fails[0]
406
+ emit(results, "FAIL", "%s:%s" % (copy_path, first[0]),
407
+ "shipped copy fails a fresh voice scan: %s -- the Gate 2 scan did not run "
408
+ "or shipped over its failures (#42 re-run)" % first[1],
409
+ (first[2] or "")[:80])
410
+ else:
411
+ emit(results, "PASS", copy_path, "fresh voice re-scan clean (#42)")
412
+
413
+
414
+ def check_detect_scan(results, run_dir, workspace, pf_states, pf_override):
415
+ # #42 tier 1 for the impeccable detector. detect.mjs is a deterministic Node
416
+ # tool; its findings JSON is the artifact. run_audit is advertised stdlib-only
417
+ # with no subprocess (README.md:142, SECURITY.md), so it does NOT shell out to
418
+ # node -- it validates the stored scan's shape (proving the detector ran and
419
+ # produced parseable output, which a "never ran it" backfill will not have) and
420
+ # leaves the full node re-run+diff to Gate 5, which the skill performs with the
421
+ # tool itself. When impeccable was overridden at preflight, the scan's absence
422
+ # is advisory (honest PARTIAL), mirroring the DESIGN.md handling.
423
+ available = companion_available(pf_states, pf_override, "impeccable")
424
+ candidates = []
425
+ for base in ([workspace] if workspace else []) + [run_dir]:
426
+ candidates.append(base / ".impeccable" / "detect-scan.json")
427
+ candidates.append(base / "detect-scan.json")
428
+ scan_path = next((p for p in candidates if p.is_file()), None)
429
+ if scan_path is None:
430
+ if available:
431
+ emit(results, "FAIL", candidates[0],
432
+ "impeccable is core and un-overridden, but no detect-scan.json on disk: "
433
+ "Gate 5 must persist detect.mjs --json output as the design-gate artifact (#42)")
434
+ else:
435
+ emit(results, "WARN", candidates[0],
436
+ "detect-scan.json absent (impeccable declined at preflight); design gate is degraded")
437
+ return
438
+ text = read_text(scan_path)
439
+ if isinstance(text, OSError):
440
+ emit(results, "FAIL", scan_path, "detect-scan.json unreadable", str(text))
441
+ return
442
+ try:
443
+ data = json.loads(text)
444
+ except ValueError as exc:
445
+ emit(results, "FAIL", scan_path,
446
+ "detect-scan.json is not valid JSON; a real detect.mjs --json run emits a "
447
+ "JSON array (empty = clean) (#42)", str(exc)[:80])
448
+ return
449
+ # detect.mjs --json emits an array of findings; empty array = clean (exit 0).
450
+ findings = data if isinstance(data, list) else data.get("findings") if isinstance(data, dict) else None
451
+ if findings is None:
452
+ emit(results, "FAIL", scan_path,
453
+ "detect-scan.json shape unexpected: detect.mjs --json emits a findings array (#42)")
454
+ return
455
+ emit(results, "PASS", scan_path,
456
+ "detect scan present and parseable (%d finding(s)) (#42)" % len(findings))
457
+
458
+
312
459
  def parse_voc(text):
313
460
  problems = []
314
461
  lines = text.splitlines()
@@ -413,6 +560,235 @@ def parse_persuasion_map(text):
413
560
  return problems
414
561
 
415
562
 
563
+ def parse_humanizer(text):
564
+ # #42 tier 2: the humanizer artifact must show WHAT it changed, not merely
565
+ # that it ran. A diff-less file (prose saying "humanizer pass complete, reads
566
+ # clean") is the exact backfill this closes: it is cheap to fabricate and
567
+ # proves nothing. Require before/after diff structure -- labeled before/after
568
+ # pairs, or unified-diff -/+ line pairs, or a two-column before|after table.
569
+ # A pass that genuinely changed nothing still records the specific tells it
570
+ # inspected and cleared, which reads as before/after "kept" lines; a file with
571
+ # no diff structure at all fails.
572
+ problems = []
573
+ labeled = len(re.findall(r"(?im)^[\s>*_`|-]*before\s*:", text))
574
+ after = len(re.findall(r"(?im)^[\s>*_`|-]*after\s*:", text))
575
+ diff_pairs = min(labeled, after)
576
+ # Unified-diff style: a '-' line followed within a few lines by a '+' line.
577
+ minus = len(re.findall(r"(?m)^\s*-\s+\S", text))
578
+ plus = len(re.findall(r"(?m)^\s*\+\s+\S", text))
579
+ diff_pairs = max(diff_pairs, min(minus, plus))
580
+ # Two-column table: a header row naming before and after.
581
+ if re.search(r"(?im)^\s*\|?[^|\n]*\bbefore\b[^|\n]*\|[^|\n]*\bafter\b", text):
582
+ table_rows = len([r for r in text.splitlines()
583
+ if r.count("|") >= 2 and not re.match(r"^\s*\|?[\s:|-]+\|?\s*$", r)])
584
+ diff_pairs = max(diff_pairs, table_rows - 1) # minus the header row
585
+ if diff_pairs < 2:
586
+ problems.append("no before/after diffs; a humanizer artifact must show what it "
587
+ "changed (labeled before/after, -/+ lines, or a before|after table), "
588
+ "not just that it ran (#42)")
589
+ return problems
590
+
591
+
592
+ def parse_red_team(text):
593
+ # #42 tier 2: the red-team read (Phase 3 / Gate 1) must carry the per-segment
594
+ # reader walks and where each reader bounces or converts -- the specifics a
595
+ # real walk produces, per rule 10 and TESTS #50 (including the anti-persona
596
+ # reader whose correct ending is self-disqualification). Structure floor: at
597
+ # least two reader/segment walks and at least one bounce/ending disposition.
598
+ problems = []
599
+ walks = len(re.findall(r"(?im)^[\s>*_#-]*(reader|segment|persona|entry[ -]?state)\b", text))
600
+ if walks < 2:
601
+ walks = max(walks, len(re.findall(r"(?i)\b(reader|segment|persona|entry[ -]?state)\b", text)) // 2)
602
+ endings = len(re.findall(r"(?i)\b(bounce|drops? off|leaves|disqualif|self-disqualif|converts?|reaches the cta|ending)\b", text))
603
+ if walks < 2:
604
+ problems.append("needs at least two per-segment reader walks (found ~%d)" % walks)
605
+ if endings < 1:
606
+ problems.append("needs a bounce/ending disposition per reader (found %d)" % endings)
607
+ return problems
608
+
609
+
610
+ TASTE_AXIS_FAIL = {
611
+ "visual-engagement": {"flat", "wall", "text-wall", "text-document", "monotonous",
612
+ "weak", "poor", "boring", "undifferentiated", "fail", "no"},
613
+ "property-specificity": {"generic", "skeleton", "average", "averaged", "template",
614
+ "ai", "ai-generated", "interchangeable", "fail", "no"},
615
+ }
616
+ TASTE_AXIS_PASS = {
617
+ "visual-engagement": {"engaged", "structured", "strong", "rich", "varied", "scannable",
618
+ "dynamic", "pass", "yes", "good"},
619
+ "property-specificity": {"specific", "distinctive", "property-specific", "only-this",
620
+ "singular", "pass", "yes", "good"},
621
+ }
622
+
623
+
624
+ def parse_taste_judge(text):
625
+ # #44 established this artifact; #48 gives it teeth. It records a FRESH-context
626
+ # judge -- a cross-family model or a human, never a same-family pass (Claude
627
+ # blessing Claude's slop). The judge scores TWO named axes on the rendered
628
+ # page, each as a structured line:
629
+ # visual-engagement: engaged | flat (is the reading experience
630
+ # structured, or a flat text wall?)
631
+ # property-specificity: specific | generic (could this only belong to this
632
+ # product, or is it a skeleton?)
633
+ # A FAIL on either axis is a BLOCKING Gate 5 failure (#48): the flat wall of
634
+ # text that shipped on the 2026-07-27 dogfood had a cross-family "flat/skeleton"
635
+ # verdict and shipped anyway because the judge was advisory. The only escape is
636
+ # an explicit operator acceptance line (`accepted: <reason>`), the user's call
637
+ # in chat, never the builder's own. The honest "no independent judge available"
638
+ # clause still stands -- do not fake independence.
639
+ no_judge = re.search(
640
+ r"\b(no (cross-family|independent|fresh|human)\b.*\b(judge|context|review|check)"
641
+ r"|independence (was )?not (available|possible)"
642
+ r"|could not (be )?(judged|reviewed) (independently|cross-family))",
643
+ text, re.I)
644
+ if no_judge:
645
+ return [] # honest absence declared; nothing more to require
646
+ problems = []
647
+ accepted = re.search(r"(?im)^[\s>*_`-]*accepted\s*:\s*\S", text)
648
+ for axis in ("visual-engagement", "property-specificity"):
649
+ m = re.search(r"(?im)^[\s>*_`-]*" + axis + r"\s*:\s*([A-Za-z][\w-]*)", text)
650
+ if not m:
651
+ problems.append("taste-judge.md must carry a `%s:` verdict line (#48); a page "
652
+ "with no verdict on this axis was not judged" % axis)
653
+ continue
654
+ verdict = m.group(1).strip().lower()
655
+ if verdict in TASTE_AXIS_FAIL[axis] and not accepted:
656
+ problems.append("taste judge FAILs on %s (verdict: %s); this is a blocking Gate 5 "
657
+ "failure unless the operator accepts it in chat (add `accepted: "
658
+ "<reason>`) (#48)" % (axis, verdict))
659
+ elif verdict not in TASTE_AXIS_PASS[axis] and verdict not in TASTE_AXIS_FAIL[axis]:
660
+ problems.append("taste-judge.md `%s:` verdict '%s' is not a recognized pass/fail "
661
+ "value; say plainly whether the page passes this axis (#48)"
662
+ % (axis, verdict))
663
+ has_tells = re.search(r"\b(tell|tells|no tells|giveaway|reads ai|reads generated|"
664
+ r"specific to|only this)\b", text, re.I)
665
+ if not has_tells:
666
+ problems.append("taste-judge.md must name the tells behind the verdict (#44)")
667
+ return problems
668
+
669
+
670
+ # Process/mechanics vocabulary for the #45 soft check: the pipeline talking about
671
+ # its own plumbing rather than the buyer's outcome. Deliberately narrow -- generic
672
+ # verbs are excluded so the flag fires on self-referential process language.
673
+ MECHANICS_TELLS = re.compile(
674
+ r"\b(\d+\s+gates?|the gates?|ship gates?|clears? the gate|every phase|the pipeline|"
675
+ r"the spec(?:ification)?|MECLABS|voice[- ]scan|companion skills?|orchestrat\w*|"
676
+ r"the foundry|passes? (?:the|all|\d+) gates?)\b", re.I)
677
+
678
+
679
+ def parse_mechanics_framing(copy_text, mechanics_is_value):
680
+ # #45 SOFT check (WARN, human-reviewed -- never an auto-fail). A defining AI
681
+ # tell is the tool selling its own MECHANICS (gates, phases, "9 gates", the
682
+ # spec, companion names) as the value prop instead of the buyer's outcome.
683
+ # But some products ARE their mechanics (a testing/security tool), which is
684
+ # why this is not a hard "never" rule: the per-product call is recorded in the
685
+ # Design-Strategy phase as `mechanics-as-value: yes`, and when that is set the
686
+ # check stays quiet. Absent that declaration the default is that outcomes are
687
+ # the value, so prevalent process language is surfaced for a human read.
688
+ if mechanics_is_value:
689
+ return None
690
+ hits = MECHANICS_TELLS.findall(copy_text)
691
+ # Threshold: one mention is fine ("passed the gates" as color); the PATTERN is
692
+ # the tell. Flag at 3+ so an incidental reference does not nag.
693
+ if len(hits) < 3:
694
+ return None
695
+ sample = ", ".join(sorted({h if isinstance(h, str) else h[0] for h in hits})[:4])
696
+ return ("%d process/mechanics references in the copy (e.g. %s); value claims may be "
697
+ "selling the pipeline's process, not the buyer's outcome -- confirm against the "
698
+ "Design-Strategy mechanics-as-value decision or reframe (#45, soft)" % (len(hits), sample))
699
+
700
+
701
+ def parse_design_strategy(text):
702
+ # #46: the Design-Strategy artifact exists ONLY if it gates downstream phases;
703
+ # otherwise it is Phase 4 renamed. It is written before copy and design, is
704
+ # grounded in the EXTERNAL taste input (#41, not model priors), and both Phase
705
+ # 3 and Phase 4 consume it. Structure floor that proves it did the job it is
706
+ # for: the per-product mechanics-vs-outcome decision (consumed by the #45 soft
707
+ # check), a grounding in the named external reference (so the directions come
708
+ # from the reference, not the model's priors or the killed trend list), an
709
+ # anti-trope kill-list, and at least one named candidate direction.
710
+ problems = []
711
+ if not re.search(r"(?im)^[\s>*_`-]*mechanics-as-value\s*:", text):
712
+ problems.append("missing `mechanics-as-value: yes|no` decision (the #45 soft check reads it)")
713
+ grounded = re.search(r"\b(reference|grounded in|drawn from|anchor(ed)?|inspired by|"
714
+ r"the .* site|the .* brand|committed theme)\b", text, re.I)
715
+ if not grounded:
716
+ problems.append("no grounding in the external taste input (#41): the directions must "
717
+ "trace to the supplied reference, not model priors")
718
+ kill = re.search(r"\b(anti-trope|kill[- ]?list|avoid|reject|not this|tropes? to|do not use|"
719
+ r"stay away from)\b", text, re.I)
720
+ if not kill:
721
+ problems.append("no anti-trope kill-list; an LLM told only to 'avoid tropes' rotates to "
722
+ "a different predictable set")
723
+ directions = len(re.findall(r"(?im)^[\s>*_#-]*(direction|option|candidate|approach)\b", text))
724
+ if directions < 1 and not re.search(r"\bdirection\b", text, re.I):
725
+ problems.append("names no candidate design direction")
726
+ return problems
727
+
728
+
729
+ def parse_copy_craft(text):
730
+ # #51: Gate 2 checks voice-CLEAN (no banned words, no AI patterns) -- both are
731
+ # ABSENCE checks. A page can be perfectly clean and still be generic and flat,
732
+ # which is what the dogfood shipped. This is the PRESENCE check: the copy is
733
+ # scored for craft, in the property's register (dry for a skeptical dev audience,
734
+ # never hype). Require the artifact to address at least three of the four craft
735
+ # dimensions, each with a specific on the page (not just the label).
736
+ dims = {
737
+ "specificity": r"\b(specific|concrete|real number|named result|exact|five seconds|"
738
+ r"one command|no network|no subprocess)\b",
739
+ "through-line": r"\b(through[- ]line|model[- ]phrase|motif|refrain|recurring|tagline|"
740
+ r"spec before pixels|the case before the page)\b",
741
+ "pacing": r"\b(pacing|short.*long|punchy|rhythm|cadence|varied|sentence length)\b",
742
+ "objection": r"\b(objection|pre[- ]?empt|anticipat|skeptic|doubt|answered where)\b",
743
+ }
744
+ present = [k for k, rx in dims.items() if re.search(rx, text, re.I)]
745
+ problems = []
746
+ if len(present) < 3:
747
+ problems.append("copy-craft.md must score at least three craft dimensions with a "
748
+ "specific each (specificity, a memorable through-line, varied pacing, "
749
+ "objection pre-emption); voice-clean is not voice-compelling (#51). "
750
+ "Found: %s" % (", ".join(present) or "none"))
751
+ return problems
752
+
753
+
754
+ def parse_imagery_plan(text):
755
+ # #49: "image-light by choice" was the loophole that produced the flat wall of
756
+ # text on the 2026-07-27 dogfood -- a section could declare itself text-only and
757
+ # pass with nothing carrying the visual load. Text is not the enemy; flat text
758
+ # is. So a section declared image-light/text-only must NAME the engagement
759
+ # mechanism that carries it instead of imagery (typographic hierarchy, emphasis,
760
+ # pull-quotes/callouts, numbered rhythm, visual breaks, varied pacing). A bare
761
+ # "no image by choice" with no mechanism anywhere fails.
762
+ declares_light = re.search(r"\b(image-light|no image|text-only|image-free|without imagery|"
763
+ r"by choice|carries (?:its|the) (?:visual )?weight in type|"
764
+ r"typographic(?:ally)? carries|type carries)\b", text, re.I)
765
+ if not declares_light:
766
+ return []
767
+ has_mechanism = re.search(r"\b(emphasis|bold(?:ed)?|italic|pull[- ]?quote|callout|"
768
+ r"rhythm|hierarchy|cadence|numbered|visual break|scannab|"
769
+ r"varied pacing|whitespace|type treatment|section marker|"
770
+ r"typographic treatment|display type|oversized)\b", text, re.I)
771
+ if not has_mechanism:
772
+ return ["imagery-plan.md declares a section image-light but names no engagement "
773
+ "mechanism to carry it (emphasis, pull-quote, numbered rhythm, hierarchy, "
774
+ "visual break); 'no image by choice' is not a free pass (#49)"]
775
+ return []
776
+
777
+
778
+ def read_mechanics_decision(run_dir):
779
+ # The per-product mechanics-as-value call lives in the Design-Strategy artifact
780
+ # (#46). Default False (outcomes are the value) when the file or line is absent,
781
+ # so the #45 soft check errs toward surfacing process language for a human.
782
+ for name in ("design-strategy.md", "design-strategy.md".upper()):
783
+ text = read_text(run_dir / name)
784
+ if isinstance(text, OSError):
785
+ continue
786
+ m = re.search(r"(?im)^[\s>*_`-]*mechanics-as-value\s*:\s*(\w+)", text)
787
+ if m:
788
+ return m.group(1).strip().lower() in ("yes", "true", "value", "on")
789
+ return False
790
+
791
+
416
792
  def parse_page_spec(text, archetype):
417
793
  problems = []
418
794
  if archetype.lower() not in text.lower():
@@ -497,6 +873,18 @@ def parse_conversion_audit(text):
497
873
  if missing_factors:
498
874
  warns.append("no compact factor breakdown (%s); documented for the log, not this file"
499
875
  % ",".join(missing_factors))
876
+ # #50: the audit must score the READING EXPERIENCE, not just the argument. A
877
+ # wall of text is high friction to actually read; on the 2026-07-27 dogfood the
878
+ # audit scored Friction low on a page nobody would scan and passed it. `voc.md`
879
+ # says design decides whether the copy is read at all. Require an explicit
880
+ # scannability / reading-friction assessment against the rendered page.
881
+ reads = re.search(r"\b(scannab|reading friction|five[- ]second scan|5[- ]second scan|"
882
+ r"scan the (rendered )?page|wall of text|skim|cold reader.*(scan|continue|"
883
+ r"keep going)|visual(?:ly)? scan)\b", text, re.I)
884
+ if not reads:
885
+ problems.append("no reading-experience assessment: the audit must score whether a cold "
886
+ "reader would scan the rendered page and want to continue (a flat text "
887
+ "wall is a Friction finding), not just the argument (#50)")
500
888
  return problems, warns
501
889
 
502
890
 
@@ -801,11 +1189,12 @@ def audit_run(args):
801
1189
  preflight_path = run_dir / "preflight.md"
802
1190
  if preflight_path.is_file():
803
1191
  pf_text = read_text(preflight_path)
804
- pf_states, pf_override = parse_preflight(pf_text) if isinstance(pf_text, str) else ({}, set())
1192
+ pf_states, pf_override, pf_taste = parse_preflight(pf_text) if isinstance(pf_text, str) else ({}, set(), {})
805
1193
  emit(results, "PASS", preflight_path, "preflight recorded (%d companions, override: %s)"
806
1194
  % (len(pf_states), ", ".join(sorted(pf_override)) or "none"))
1195
+ check_taste_inputs(results, preflight_path, pf_taste, pf_override)
807
1196
  else:
808
- pf_states, pf_override = {}, set()
1197
+ pf_states, pf_override, pf_taste = {}, set(), {}
809
1198
  emit(results, "WARN", preflight_path,
810
1199
  "preflight.md missing; degraded-claim cross-check unavailable, core artifacts held strict (SKILL.md Phase -1)")
811
1200
 
@@ -884,6 +1273,33 @@ def audit_run(args):
884
1273
  required_infos.append(d_info)
885
1274
  else:
886
1275
  optional_file(results, design_path, "DESIGN.md (impeccable declined at preflight)", 5)
1276
+ # #42 tier 1: the deterministic detector's stored JSON output is the
1277
+ # design-gate artifact; validate its shape (node re-run stays a Gate 5 job).
1278
+ check_detect_scan(results, run_dir, workspace, pf_states, pf_override)
1279
+ # #44: the fresh/cross-family taste judge, mirroring Gate 1's cold score.
1280
+ # Required in build/explore (a rendered page exists to judge); the verdict
1281
+ # is advisory, the artifact's presence and honesty are enforced.
1282
+ taste_info = require_file(results, run_dir / "taste-judge.md", "taste-judge.md", 3)
1283
+ if taste_info:
1284
+ required_infos.append(taste_info)
1285
+ # #46: the Design-Strategy artifact, consumed by Phase 3 and Phase 4.
1286
+ # Default-on; a user may decline it in chat, recorded on the preflight
1287
+ # override line as `design-strategy`, in which case its absence is advisory.
1288
+ if "design-strategy" in pf_override:
1289
+ optional_file(results, run_dir / "design-strategy.md",
1290
+ "design-strategy.md (declined at preflight)", 4)
1291
+ else:
1292
+ ds_info = require_file(results, run_dir / "design-strategy.md", "design-strategy.md", 4)
1293
+ if ds_info:
1294
+ required_infos.append(ds_info)
1295
+ # #51: the copy-craft judgment -- voice-compelling, not just voice-clean.
1296
+ cc_info = require_file(results, run_dir / "copy-craft.md", "copy-craft.md", 3)
1297
+ if cc_info:
1298
+ required_infos.append(cc_info)
1299
+
1300
+ # #42 tier 1: re-run the voice scan in-process over the frozen copy. Runs in
1301
+ # every mode -- the copy is written in Phase 3, which runs in handoff too.
1302
+ rescan_voice(results, run_dir)
887
1303
 
888
1304
  if args["mode"] in ("build", "explore"):
889
1305
  comp_info = require_file(results, run_dir / "comp" / "index.html", "comp/index.html", 3)
@@ -962,6 +1378,24 @@ def audit_run(args):
962
1378
  elif path == run_dir / "persuasion-map.md":
963
1379
  for why in parse_persuasion_map(text):
964
1380
  emit(results, "FAIL", path, why)
1381
+ elif path == run_dir / "copy" / "humanizer.md":
1382
+ for why in parse_humanizer(text):
1383
+ emit(results, "FAIL", path, why)
1384
+ elif path == run_dir / "red-team.md":
1385
+ for why in parse_red_team(text):
1386
+ emit(results, "FAIL", path, why)
1387
+ elif path == run_dir / "taste-judge.md":
1388
+ for why in parse_taste_judge(text):
1389
+ emit(results, "FAIL", path, why)
1390
+ elif path == run_dir / "design-strategy.md":
1391
+ for why in parse_design_strategy(text):
1392
+ emit(results, "FAIL", path, why)
1393
+ elif path == run_dir / "imagery-plan.md":
1394
+ for why in parse_imagery_plan(text):
1395
+ emit(results, "FAIL", path, why)
1396
+ elif path == run_dir / "copy-craft.md":
1397
+ for why in parse_copy_craft(text):
1398
+ emit(results, "FAIL", path, why)
965
1399
  elif path == run_dir / "page-spec.md":
966
1400
  for why in parse_page_spec(text, args["archetype"]):
967
1401
  emit(results, "FAIL", path, why)
@@ -979,6 +1413,13 @@ def audit_run(args):
979
1413
  snapshot_title = None
980
1414
  if not isinstance(snapshot_text, OSError):
981
1415
  snapshot_title = extract_snapshot_title(snapshot_text)
1416
+ # #45 soft check: is the copy selling the pipeline's mechanics instead of
1417
+ # the buyer's outcome? WARN only, and silent when the Design-Strategy
1418
+ # decision says the mechanics ARE the value (mechanics-as-value: yes).
1419
+ mechanics_is_value = read_mechanics_decision(run_dir)
1420
+ framing_warn = parse_mechanics_framing(snapshot_text, mechanics_is_value)
1421
+ if framing_warn:
1422
+ emit(results, "WARN", run_dir / "copy" / "copy-approved.md", framing_warn)
982
1423
 
983
1424
  if args["mode"] in ("build", "explore"):
984
1425
  index_text = read_text(pages_dir / "index.html")
@@ -1072,6 +1513,14 @@ def audit_run(args):
1072
1513
  )
1073
1514
  if site_context_info:
1074
1515
  stale_edges.append((run_dir / "page-spec.md", run_dir / "site-context.md"))
1516
+ # #46 consumption evidence: design-strategy is written BEFORE copy and design and
1517
+ # steers both, so the copy draft (Phase 3) and the theme (Phase 4) postdate it.
1518
+ # WARN-level, like every mtime edge -- an inversion means the strategy may have
1519
+ # been written after the fact (referenced, not consumed), which a human reads.
1520
+ if (run_dir / "design-strategy.md").is_file():
1521
+ stale_edges.append((run_dir / "copy" / "approved-draft.md", run_dir / "design-strategy.md"))
1522
+ if args["mode"] in ("build", "explore"):
1523
+ stale_edges.append((pages_dir / "theme.css", run_dir / "design-strategy.md"))
1075
1524
  if token_source:
1076
1525
  stale_edges.append((run_dir / "comp" / "index.html", token_source))
1077
1526
  stale_edges.append((run_dir / "copy" / "copy-approved.md", token_source))
@@ -29,9 +29,24 @@ cat > "$D/preflight.md" <<'E'
29
29
  | frontend-design | core | present | npx skills list |
30
30
  | humanizer | core | present | filesystem |
31
31
  | impeccable | core | present | npx impeccable |
32
+ voice: configured
33
+ design-reference: supplied: a real terminal instrument panel the owner named
32
34
  override: none
33
35
  E
34
36
 
37
+ cat > "$D/design-strategy.md" <<'E'
38
+ # Design strategy
39
+ Written before copy and design; Phase 3 and Phase 4 both consume it.
40
+ Grounded in the external reference the operator named (a real terminal instrument panel),
41
+ not model priors and not a trend list.
42
+ mechanics-as-value: no
43
+ The buyer's outcome is the value here (a page that converts), not the gate count.
44
+ ## Candidate direction
45
+ Direction A: instrument-panel restraint — mono display, hairline rules, one hot accent.
46
+ ## Anti-trope kill-list (avoid)
47
+ - no brutalist-terminal cosplay, no quirky-serif pivot, no glassmorphism, no dark-by-default.
48
+ E
49
+
35
50
  cat > "$D/voc.md" <<'E'
36
51
  # voc.md
37
52
  ## Verbatim
@@ -115,11 +130,61 @@ How: it writes the spec first, then builds from it.
115
130
  Install: one command.
116
131
  E
117
132
 
133
+ cat > "$D/copy/humanizer.md" <<'E'
134
+ # Humanizer pass — before/after
135
+ The pass ran on approved-draft.md. Diffs it made, and the WARNs kept with reasons.
136
+ before: In today's landscape, our robust platform seamlessly streamlines your workflow.
137
+ after: You already know what an AI page looks like.
138
+ before: We leverage cutting-edge orchestration to unlock synergy across every page.
139
+ after: nobody can tell what it does.
140
+ ## Accepted WARNs
141
+ - fragment cadence in the install steps: kept; the three commands are genuinely parallel.
142
+ E
143
+
144
+ cat > "$D/red-team.md" <<'E'
145
+ # Red-team read (Phase 3 / Gate 1)
146
+ Two entry states from voc.md, walked to their bounce or conversion point.
147
+ ## Reader: the skeptical solo builder (organic search, high intent)
148
+ Lands on the hero, reads the case, reaches the CTA and converts on the one command.
149
+ Bounce risk: the proof section is thin; if adoption stays TK he may drop off there.
150
+ ## Reader: the agency dev sent by a client (low intent, anti-persona)
151
+ Correct ending is self-disqualification: this ships one page, not a site, so he leaves
152
+ at the scope note rather than converting. That is the intended bounce, not a leak.
153
+ E
154
+
155
+ cat > "$D/copy-craft.md" <<'E'
156
+ # Copy-craft judgment (voice-compelling, not just voice-clean)
157
+ Scored on the built page in the property's dry register, no hype.
158
+ ## Specificity
159
+ Concrete, checkable: "one command", "no network, no subprocess", the real artifact
160
+ filenames on the page rather than vague claims.
161
+ ## Through-line
162
+ A model-phrase recurs: "the spec before the page" opens the hero and closes the page.
163
+ ## Pacing
164
+ Varied: short punchy openers next to longer explanation, not uniform paragraphs.
165
+ ## Objection pre-emption
166
+ The safety doubt is answered where a skeptic raises it, not buried in a FAQ.
167
+ E
168
+
169
+ cat > "$D/taste-judge.md" <<'E'
170
+ # Taste judge (fresh / cross-family)
171
+ Judged by a cross-family model (codex/GPT) given only the rendered page and the
172
+ design reference, never the author. Mirrors Gate 1's cold conversion score.
173
+ ## Verdict
174
+ visual-engagement: engaged
175
+ property-specificity: specific
176
+ The instrument-panel motif could only belong to a terminal-native dev tool, and the
177
+ reading experience is structured with real sections, not a flat wall of text.
178
+ ## Tells found
179
+ - one weak tell: the eyebrow kicker cadence is slightly template-ish; noted, not blocking.
180
+ No same-family self-review substituted.
181
+ E
182
+
118
183
  cat > "$D/imagery-plan.md" <<'E'
119
184
  # Imagery plan
120
185
  Consistency rules: one grade (cool, high-contrast), 16:9 crops, hairline frames.
121
186
  - hero: real product screenshot, browser chrome cropped out, 16:9, cool grade.
122
- - proof: no image by choice; the gate report card carries the visual weight.
187
+ - proof: no image by choice; the gate report card carries the visual weight through a bordered callout, numbered rhythm, and emphasis on the two scores.
123
188
  - how-it-works: a diagram of the real component flow, on-token line weights.
124
189
  Every image traces to a slot here; no product screenshot is a mockup.
125
190
  E
@@ -171,6 +236,11 @@ cat > "$ROOT/pages/demo/llms.txt" <<'E'
171
236
  The spec your page never got. Open source, MIT.
172
237
  E
173
238
 
239
+ mkdir -p "$ROOT/.impeccable"
240
+ cat > "$ROOT/.impeccable/detect-scan.json" <<'E'
241
+ []
242
+ E
243
+
174
244
  cat > "$ROOT/DESIGN.md" <<'E'
175
245
  ---
176
246
  name: demo
@@ -194,6 +264,9 @@ The heuristic scored twice: the builder self-score from full context, then the
194
264
  score of record from a fresh agent given only the page and the brief.
195
265
  Self score: 31. Independent score: 27. Divergence: 4 points, concentrated on the
196
266
  install CTA which is the strongest lever and currently thinnest.
267
+ Reading experience: a cold reader scans the rendered page in five seconds and the
268
+ hero, the numbered stations, and the proof are all identifiable, so reading friction
269
+ is low; this is not a flat wall of text. Scannability holds at 390 and 1440.
197
270
  E
198
271
 
199
272
  cat > "$D/foundry-log.md" <<'E'