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.
- package/README.md +8 -6
- package/package.json +2 -2
- package/skills/page-foundry/SKILL.md +41 -11
- package/skills/page-foundry/TESTS.md +12 -0
- package/skills/page-foundry/references/design-direction.md +9 -6
- package/skills/page-foundry/references/ship-gates.md +11 -5
- package/skills/page-foundry/references/voice.md +6 -0
- package/skills/page-foundry/scripts/run_audit.py +455 -6
- package/skills/page-foundry/tests/build_fixture.sh +74 -1
- package/skills/page-foundry/tests/run_audit_test.sh +197 -0
|
@@ -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
|
-
|
|
119
|
-
#
|
|
120
|
-
#
|
|
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
|
-
|
|
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'
|