page-foundry 2.9.1 → 3.2.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 +82 -25
- package/package.json +1 -1
- package/skills/page-foundry/README.md +3 -3
- package/skills/page-foundry/SKILL.md +227 -59
- package/skills/page-foundry/TESTS.md +57 -2
- package/skills/page-foundry/assets/brief-template.md +15 -1
- package/skills/page-foundry/references/archetypes.md +552 -105
- package/skills/page-foundry/references/conversion-rules.md +2 -2
- package/skills/page-foundry/references/design-direction.md +50 -5
- package/skills/page-foundry/references/handoff.md +208 -34
- package/skills/page-foundry/references/ship-gates.md +115 -27
- package/skills/page-foundry/references/voice.md +6 -1
- package/skills/page-foundry/scripts/run_audit.py +1162 -0
- package/skills/page-foundry/scripts/voice_scan.py +46 -0
- package/skills/page-foundry/tests/build_fixture.sh +214 -0
- package/skills/page-foundry/tests/run_audit_test.sh +92 -0
|
@@ -0,0 +1,1162 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""run_audit.py: mechanical orchestration gate for page-foundry.
|
|
3
|
+
|
|
4
|
+
Audits a single run artifact directory and checks whether the documented
|
|
5
|
+
artifact chain appears intact. This is a file-system audit only: it validates
|
|
6
|
+
presence, basic structure, duplicate-content shortcuts, and mtime ordering.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 run_audit.py [flags] <run-dir>
|
|
10
|
+
|
|
11
|
+
Flags:
|
|
12
|
+
--mode build|explore|handoff required
|
|
13
|
+
--archetype NAME required
|
|
14
|
+
--pages-dir PATH optional; defaults to <workspace>/pages/<product>/
|
|
15
|
+
--handoff-dir PATH optional; defaults to <workspace>/handoff/<product>/
|
|
16
|
+
--brief PATH optional; defaults to:
|
|
17
|
+
<workspace>/.agents/product-marketing.md
|
|
18
|
+
<workspace>/.claude/product-marketing.md
|
|
19
|
+
<run-dir>/product-marketing.md
|
|
20
|
+
--rewrite rewrite run; rewrite-diagnosis.md becomes required
|
|
21
|
+
--multipage multi-page run; site-context.md becomes required
|
|
22
|
+
--closing require a today-dated matching foundry-log entry
|
|
23
|
+
--tolerance SECONDS mtime slack for staleness (default 2)
|
|
24
|
+
--impeccable-critique PATH non-empty dir that satisfies the comp-record gate
|
|
25
|
+
|
|
26
|
+
Exit code 0 = no HARD FAILs, 1 = HARD FAILs found, 2 = usage/environment error.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import sys
|
|
33
|
+
from datetime import date, datetime
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
MODES = {"build", "explore", "handoff"}
|
|
37
|
+
PUBLIC_PRICING_ARCHETYPES = {
|
|
38
|
+
"pricing-page",
|
|
39
|
+
"saas-homepage",
|
|
40
|
+
"course-sales",
|
|
41
|
+
"membership-community",
|
|
42
|
+
"docs-dev-tool-landing",
|
|
43
|
+
}
|
|
44
|
+
FORM_CENTRIC_ARCHETYPES = {
|
|
45
|
+
"newsletter-capture",
|
|
46
|
+
"waitlist-coming-soon",
|
|
47
|
+
"event-webinar",
|
|
48
|
+
"campaign-landing",
|
|
49
|
+
}
|
|
50
|
+
OTHER_ARCHETYPES = {
|
|
51
|
+
"oss-project",
|
|
52
|
+
"mobile-app",
|
|
53
|
+
"personal-home",
|
|
54
|
+
"comparison-alternatives",
|
|
55
|
+
"agency-services",
|
|
56
|
+
"ecommerce-product",
|
|
57
|
+
"changelog-launch-post",
|
|
58
|
+
"thank-you-post-conversion",
|
|
59
|
+
}
|
|
60
|
+
KNOWN_ARCHETYPES = PUBLIC_PRICING_ARCHETYPES | FORM_CENTRIC_ARCHETYPES | OTHER_ARCHETYPES
|
|
61
|
+
REJECT_SOURCE_FIELDS = {
|
|
62
|
+
"customer interviews",
|
|
63
|
+
"interviews",
|
|
64
|
+
"reviews",
|
|
65
|
+
"g2",
|
|
66
|
+
"capterra",
|
|
67
|
+
"users",
|
|
68
|
+
"customers",
|
|
69
|
+
"reddit",
|
|
70
|
+
"forums",
|
|
71
|
+
"the internet",
|
|
72
|
+
"various",
|
|
73
|
+
"misc",
|
|
74
|
+
"survey",
|
|
75
|
+
}
|
|
76
|
+
REQUIRED_FILES = (
|
|
77
|
+
("brief", "brief", 3),
|
|
78
|
+
("run", "voc.md", 5),
|
|
79
|
+
("run", "message-architecture.md", 6),
|
|
80
|
+
("run", "persuasion-map.md", 4),
|
|
81
|
+
("run", "page-spec.md", 6),
|
|
82
|
+
("run", "copy/hero-candidates.md", 2),
|
|
83
|
+
("run", "copy/approved-draft.md", 4),
|
|
84
|
+
("run", "copy/copy-approved.md", 4),
|
|
85
|
+
("run", "conversion-audit.md", 4),
|
|
86
|
+
)
|
|
87
|
+
HANDOFF_FILES = (
|
|
88
|
+
"00-master-prompt.md",
|
|
89
|
+
"01-copy.md",
|
|
90
|
+
"02-page-spec.md",
|
|
91
|
+
"03-design-direction.md",
|
|
92
|
+
"04-acceptance-criteria.md",
|
|
93
|
+
"05-voice-rules.md",
|
|
94
|
+
"06-return-spec.md",
|
|
95
|
+
"DESIGN.md",
|
|
96
|
+
)
|
|
97
|
+
LOG_FIELDS = (
|
|
98
|
+
"headline",
|
|
99
|
+
"skeleton",
|
|
100
|
+
"meclabs",
|
|
101
|
+
"gates",
|
|
102
|
+
"companions",
|
|
103
|
+
"degraded",
|
|
104
|
+
"open items",
|
|
105
|
+
"conversion data",
|
|
106
|
+
"learnings",
|
|
107
|
+
)
|
|
108
|
+
# Which companion owns each required artifact. Used with preflight.md to tell a
|
|
109
|
+
# legitimately-overridden companion (artifact absence is expected) from one that
|
|
110
|
+
# was present but skipped (artifact absence is a FAIL). Artifacts with no entry
|
|
111
|
+
# are page-foundry's own synthesis and are always required.
|
|
112
|
+
CORE_ARTIFACT_COMPANION = {
|
|
113
|
+
"brief": "product-marketing",
|
|
114
|
+
"voc.md": "customer-research",
|
|
115
|
+
"persuasion-map.md": "marketing-psychology",
|
|
116
|
+
"conversion-audit.md": "cro",
|
|
117
|
+
"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).
|
|
121
|
+
}
|
|
122
|
+
POINTER_RE = re.compile(
|
|
123
|
+
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)",
|
|
124
|
+
re.I,
|
|
125
|
+
)
|
|
126
|
+
COMMENT_RE = re.compile(r"^\s*(#|//|<!--)")
|
|
127
|
+
NUMBERED_LINE_RE = re.compile(r"^\s*\d+\.\s+")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def usage_error(message):
|
|
131
|
+
print(message, file=sys.stderr)
|
|
132
|
+
print(__doc__, file=sys.stderr)
|
|
133
|
+
return 2
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def read_text(path):
|
|
137
|
+
try:
|
|
138
|
+
return Path(path).read_text(encoding="utf-8", errors="replace")
|
|
139
|
+
except OSError as exc:
|
|
140
|
+
return exc
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def stat_path(path):
|
|
144
|
+
try:
|
|
145
|
+
return Path(path).stat()
|
|
146
|
+
except OSError as exc:
|
|
147
|
+
return exc
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def iso_mtime(path):
|
|
151
|
+
st = stat_path(path)
|
|
152
|
+
if isinstance(st, OSError):
|
|
153
|
+
return str(st)
|
|
154
|
+
return datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def data_lines(text):
|
|
158
|
+
lines = []
|
|
159
|
+
for raw in text.splitlines():
|
|
160
|
+
line = raw.strip()
|
|
161
|
+
if not line or COMMENT_RE.match(line):
|
|
162
|
+
continue
|
|
163
|
+
if line.startswith("#"):
|
|
164
|
+
continue
|
|
165
|
+
lines.append(line)
|
|
166
|
+
return lines
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def emit(results, status, subject, why=None, ctx=None):
|
|
170
|
+
line = status + " " + str(subject)
|
|
171
|
+
if why:
|
|
172
|
+
line += " " + why
|
|
173
|
+
if ctx:
|
|
174
|
+
line += " | " + ctx
|
|
175
|
+
results.append((status, line))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def resolve_workspace(run_dir):
|
|
179
|
+
run_dir = Path(run_dir).resolve()
|
|
180
|
+
for parent in (run_dir,) + tuple(run_dir.parents):
|
|
181
|
+
if (parent / ".agents").is_dir():
|
|
182
|
+
return parent
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def resolve_brief(args, workspace, run_dir):
|
|
187
|
+
if args["brief"]:
|
|
188
|
+
return Path(args["brief"]), False
|
|
189
|
+
candidates = []
|
|
190
|
+
if workspace:
|
|
191
|
+
candidates.append(workspace / ".agents" / "product-marketing.md")
|
|
192
|
+
candidates.append(workspace / ".claude" / "product-marketing.md")
|
|
193
|
+
candidates.append(run_dir / "product-marketing.md")
|
|
194
|
+
for candidate in candidates:
|
|
195
|
+
if candidate.is_file():
|
|
196
|
+
return candidate, False
|
|
197
|
+
return candidates[0] if candidates else run_dir / "product-marketing.md", True
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def resolve_related_dir(args_value, workspace, product, folder_name):
|
|
201
|
+
if args_value:
|
|
202
|
+
return Path(args_value), False
|
|
203
|
+
if workspace:
|
|
204
|
+
return workspace / folder_name / product, True
|
|
205
|
+
return Path(folder_name) / product, True
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def count_nonblank_files(path):
|
|
209
|
+
count = 0
|
|
210
|
+
for item in sorted(Path(path).rglob("*")):
|
|
211
|
+
if item.is_file():
|
|
212
|
+
st = stat_path(item)
|
|
213
|
+
if not isinstance(st, OSError) and st.st_size > 0:
|
|
214
|
+
count += 1
|
|
215
|
+
return count
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def newest_file(path):
|
|
219
|
+
newest = None
|
|
220
|
+
newest_mtime = None
|
|
221
|
+
for item in sorted(Path(path).rglob("*")):
|
|
222
|
+
if not item.is_file():
|
|
223
|
+
continue
|
|
224
|
+
st = stat_path(item)
|
|
225
|
+
if isinstance(st, OSError):
|
|
226
|
+
continue
|
|
227
|
+
if newest is None or st.st_mtime > newest_mtime or (st.st_mtime == newest_mtime and str(item) < str(newest)):
|
|
228
|
+
newest = item
|
|
229
|
+
newest_mtime = st.st_mtime
|
|
230
|
+
return newest
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def sha256_file(path):
|
|
234
|
+
try:
|
|
235
|
+
h = hashlib.sha256()
|
|
236
|
+
with open(path, "rb") as handle:
|
|
237
|
+
while True:
|
|
238
|
+
chunk = handle.read(65536)
|
|
239
|
+
if not chunk:
|
|
240
|
+
break
|
|
241
|
+
h.update(chunk)
|
|
242
|
+
return h.hexdigest()
|
|
243
|
+
except OSError as exc:
|
|
244
|
+
return exc
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def normalize_text(text):
|
|
248
|
+
return "\n".join(line.strip() for line in text.splitlines() if line.strip())
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def has_form_design(text):
|
|
252
|
+
lines = text.splitlines()
|
|
253
|
+
has_label = False
|
|
254
|
+
has_fields = False
|
|
255
|
+
for index, raw in enumerate(lines):
|
|
256
|
+
line = raw.strip()
|
|
257
|
+
if not line:
|
|
258
|
+
continue
|
|
259
|
+
if re.search(r"\bform\b", line, re.I) and (line.startswith("#") or ":" in line or re.search(r"\bform design\b", line, re.I)):
|
|
260
|
+
has_label = True
|
|
261
|
+
for follow in lines[index + 1:index + 8]:
|
|
262
|
+
probe = follow.strip()
|
|
263
|
+
if not probe:
|
|
264
|
+
continue
|
|
265
|
+
if re.search(r"\b(field|fields|name|email|phone|company|message|submit|cta)\b", probe, re.I):
|
|
266
|
+
has_fields = True
|
|
267
|
+
break
|
|
268
|
+
if has_label and has_fields:
|
|
269
|
+
return True
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def line_has_pointer(field):
|
|
274
|
+
field = field.strip()
|
|
275
|
+
return bool(POINTER_RE.search(field))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def parse_preflight(text):
|
|
279
|
+
# Reads the preflight.md table written at Phase -1 (SKILL.md step 1). Returns
|
|
280
|
+
# {companion: state} and the set of core companions the user overrode. A
|
|
281
|
+
# companion is "available" when its state is present/outdated (outdated still
|
|
282
|
+
# runs as primary) and it is not on the override line; only then is its
|
|
283
|
+
# OUTPUT artifact mandatory.
|
|
284
|
+
states = {}
|
|
285
|
+
override = set()
|
|
286
|
+
for raw in text.splitlines():
|
|
287
|
+
line = raw.strip()
|
|
288
|
+
m = re.match(r"^\|\s*([A-Za-z0-9_-]+)\s*\|\s*(core|enhancer)\s*\|\s*([A-Za-z-]+)\s*\|", line)
|
|
289
|
+
if m and m.group(1).lower() not in ("companion",):
|
|
290
|
+
states[m.group(1).lower()] = m.group(3).lower()
|
|
291
|
+
o = re.match(r"^override:\s*(.+)$", line, re.I)
|
|
292
|
+
if o:
|
|
293
|
+
val = o.group(1).strip()
|
|
294
|
+
if val.lower() not in ("none", "-", ""):
|
|
295
|
+
for name in re.split(r"[,\s]+", val):
|
|
296
|
+
if name.strip():
|
|
297
|
+
override.add(name.strip().lower())
|
|
298
|
+
return states, override
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def companion_available(states, override, companion):
|
|
302
|
+
# No preflight record -> treat as available (strict: artifact required). A
|
|
303
|
+
# recorded state of present/outdated and not overridden -> available.
|
|
304
|
+
if companion in override:
|
|
305
|
+
return False
|
|
306
|
+
state = states.get(companion)
|
|
307
|
+
if state is None:
|
|
308
|
+
return True
|
|
309
|
+
return state in ("present", "outdated")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def parse_voc(text):
|
|
313
|
+
problems = []
|
|
314
|
+
lines = text.splitlines()
|
|
315
|
+
has_verbatim = any(re.match(r"^\s*##\s+Verbatim\b", line, re.I) for line in lines)
|
|
316
|
+
has_themes = any(re.match(r"^\s*##\s+Themes\b", line, re.I) for line in lines)
|
|
317
|
+
if not has_verbatim:
|
|
318
|
+
problems.append(("missing ## Verbatim section", None))
|
|
319
|
+
if not has_themes:
|
|
320
|
+
problems.append(("missing ## Themes section", None))
|
|
321
|
+
quote_count = 0
|
|
322
|
+
for index, raw in enumerate(lines):
|
|
323
|
+
if not raw.lstrip().startswith(">"):
|
|
324
|
+
continue
|
|
325
|
+
quote_count += 1
|
|
326
|
+
ctx = raw.strip()[:120]
|
|
327
|
+
found_attr = None
|
|
328
|
+
for probe in lines[index + 1:]:
|
|
329
|
+
stripped = probe.strip()
|
|
330
|
+
if not stripped:
|
|
331
|
+
continue
|
|
332
|
+
if stripped.startswith(">"):
|
|
333
|
+
break
|
|
334
|
+
if stripped.startswith("—") or stripped.startswith("-"):
|
|
335
|
+
found_attr = stripped.lstrip("—-").strip()
|
|
336
|
+
break
|
|
337
|
+
if stripped.startswith("#"):
|
|
338
|
+
break
|
|
339
|
+
break
|
|
340
|
+
if not found_attr:
|
|
341
|
+
problems.append(("quote missing attribution within next non-blank line", ctx))
|
|
342
|
+
continue
|
|
343
|
+
fields = [part.strip() for part in found_attr.split("·") if part.strip()]
|
|
344
|
+
if len(fields) < 2:
|
|
345
|
+
problems.append(("quote attribution needs at least two ·-separated fields", ctx))
|
|
346
|
+
continue
|
|
347
|
+
first = fields[0].lower()
|
|
348
|
+
if first in REJECT_SOURCE_FIELDS:
|
|
349
|
+
problems.append(("quote attribution uses a genre, not a real source pointer", ctx))
|
|
350
|
+
continue
|
|
351
|
+
if not line_has_pointer(fields[0]):
|
|
352
|
+
problems.append(("quote attribution first field is not a real pointer", ctx))
|
|
353
|
+
if quote_count < 1:
|
|
354
|
+
problems.append(("missing quote lines in ## Verbatim", None))
|
|
355
|
+
ranked = 0
|
|
356
|
+
for raw in lines:
|
|
357
|
+
if NUMBERED_LINE_RE.match(raw) and re.search(r"\b(High|Medium|Low)\b", raw):
|
|
358
|
+
ranked += 1
|
|
359
|
+
if ranked < 1:
|
|
360
|
+
problems.append(("## Themes needs at least one ranked entry with High/Medium/Low confidence", None))
|
|
361
|
+
return problems
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def parse_message_architecture(text):
|
|
365
|
+
lines = text.splitlines()
|
|
366
|
+
has_unlike = any("unlike" in line.lower() for line in lines)
|
|
367
|
+
hierarchy_count = sum(1 for line in lines if re.search(r"\b(claim|tier|hierarchy|primary|secondary|tertiary)\b", line, re.I))
|
|
368
|
+
objections = 0
|
|
369
|
+
in_objections = False
|
|
370
|
+
has_proof = False
|
|
371
|
+
for raw in lines:
|
|
372
|
+
line = raw.strip()
|
|
373
|
+
if re.search(r"\bobjection", line, re.I):
|
|
374
|
+
in_objections = True
|
|
375
|
+
continue
|
|
376
|
+
if in_objections and (line.startswith("#") or re.search(r"\bproof\b", line, re.I)):
|
|
377
|
+
in_objections = False
|
|
378
|
+
if in_objections and line and not line.startswith("#"):
|
|
379
|
+
objections += 1
|
|
380
|
+
if re.search(r"\bproof\b", line, re.I):
|
|
381
|
+
has_proof = True
|
|
382
|
+
problems = []
|
|
383
|
+
if not has_unlike:
|
|
384
|
+
problems.append("missing positioning line with 'unlike'")
|
|
385
|
+
if hierarchy_count < 3:
|
|
386
|
+
problems.append("needs at least three hierarchy claim lines")
|
|
387
|
+
if objections < 3:
|
|
388
|
+
problems.append("objection section needs at least three entries")
|
|
389
|
+
if not has_proof:
|
|
390
|
+
problems.append("missing proof-inventory section")
|
|
391
|
+
return problems
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def parse_persuasion_map(text):
|
|
395
|
+
# Structure floor, not a semantic judge: the documented contract
|
|
396
|
+
# (ship-gates.md:268 — "persuasion-map.md written, levers applied and where")
|
|
397
|
+
# is that the map names multiple levers AND says where each acts. Count the
|
|
398
|
+
# two signals independently and require a floor of each. The earlier version
|
|
399
|
+
# counted a "lever" for any block mentioning the word — intro prose, section
|
|
400
|
+
# headers — then demanded landing >= that inflated count, which the v3.0
|
|
401
|
+
# dogfood failed despite placing every lever with "Lands on:".
|
|
402
|
+
lever_headers = len(re.findall(r"^\s*#{2,4}\s+.*\blever", text, re.I | re.M))
|
|
403
|
+
if lever_headers < 2:
|
|
404
|
+
# Levers titled by name ("### Pratfall effect") rather than with the
|
|
405
|
+
# word "lever": fall back to counting third/fourth-level headers.
|
|
406
|
+
lever_headers = len(re.findall(r"^\s*#{3,4}\s+\S", text, re.M))
|
|
407
|
+
landing = len(re.findall(r"\b(lands? on|where it lands|claim order|placement)\b", text, re.I))
|
|
408
|
+
problems = []
|
|
409
|
+
if lever_headers < 2:
|
|
410
|
+
problems.append("needs at least two named lever entries")
|
|
411
|
+
if landing < 2:
|
|
412
|
+
problems.append("levers need a where-it-lands pointer (found %d)" % landing)
|
|
413
|
+
return problems
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def parse_page_spec(text, archetype):
|
|
417
|
+
problems = []
|
|
418
|
+
if archetype.lower() not in text.lower():
|
|
419
|
+
problems.append("missing declared archetype string")
|
|
420
|
+
numbered = sum(1 for line in text.splitlines() if NUMBERED_LINE_RE.match(line))
|
|
421
|
+
if numbered < 3:
|
|
422
|
+
problems.append("needs at least three numbered sections")
|
|
423
|
+
if not re.search(r"\banswer block\b", text, re.I):
|
|
424
|
+
problems.append("missing answer block entry")
|
|
425
|
+
if not re.search(r"\bmeasurement plan\b", text, re.I):
|
|
426
|
+
problems.append("missing measurement-plan entry")
|
|
427
|
+
return problems
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def parse_copy_approved(text):
|
|
431
|
+
problems = []
|
|
432
|
+
# The label may carry markdown emphasis and backticks: the documented form
|
|
433
|
+
# is a labeled entry, and a real snapshot writes it as **`<title>`:**. Allow
|
|
434
|
+
# leading *, _, `, >, and list bullets before the label. (Matching only a
|
|
435
|
+
# bare line-start "title:" was miscalibration against the v3.0 dogfood.)
|
|
436
|
+
label_prefix = r"^[\s>*_`-]*"
|
|
437
|
+
if not re.search(label_prefix + r"(?:<title>|title)`?\s*:", text, re.I | re.M):
|
|
438
|
+
problems.append("missing labeled <title> entry")
|
|
439
|
+
if not re.search(label_prefix + r"meta[- ]description`?\s*:", text, re.I | re.M):
|
|
440
|
+
problems.append("missing meta-description entry")
|
|
441
|
+
if "[TK" in text:
|
|
442
|
+
problems.append("contains unresolved [TK placeholder")
|
|
443
|
+
return problems
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def extract_snapshot_title(text):
|
|
447
|
+
for raw in text.splitlines():
|
|
448
|
+
# Same markdown-tolerant label as parse_copy_approved; strip trailing
|
|
449
|
+
# emphasis/backticks off the captured value.
|
|
450
|
+
m = re.match(r"^[\s>*_`-]*(?:<title>|title)`?\s*:\s*(.+?)\s*$", raw, re.I)
|
|
451
|
+
if m:
|
|
452
|
+
return m.group(1).strip().strip("*_`").strip()
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def parse_index_html(text, snapshot_title):
|
|
457
|
+
problems = []
|
|
458
|
+
title_match = re.search(r"<title>(.*?)</title>", text, re.I | re.S)
|
|
459
|
+
if not title_match:
|
|
460
|
+
problems.append("missing <title> tag")
|
|
461
|
+
else:
|
|
462
|
+
actual_title = re.sub(r"\s+", " ", title_match.group(1)).strip()
|
|
463
|
+
if snapshot_title and snapshot_title not in actual_title:
|
|
464
|
+
problems.append("title mismatch")
|
|
465
|
+
if "[TK" in text:
|
|
466
|
+
problems.append("contains unresolved [TK placeholder")
|
|
467
|
+
h1_count = len(re.findall(r"<h1\b", text, re.I))
|
|
468
|
+
if h1_count != 1:
|
|
469
|
+
problems.append("must contain exactly one <h1>")
|
|
470
|
+
return problems, title_match.group(1).strip() if title_match else None
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def parse_conversion_audit(text):
|
|
474
|
+
# This file's documented contract is at ship-gates.md:17 — it "records both
|
|
475
|
+
# scores and the divergence." That is the HARD requirement here. The compact
|
|
476
|
+
# `C={n} (M{n} V{n} I{n} F{n} A{n})` breakdown is documented for the
|
|
477
|
+
# foundry-log (SKILL.md:354), and parse_foundry_log enforces it THERE; a
|
|
478
|
+
# per-factor breakdown is not part of this file's spec, so its absence is a
|
|
479
|
+
# WARN, not a FAIL. Requiring an undocumented format was the miscalibration
|
|
480
|
+
# that failed the v3.0 dogfood audit (which scored in a table, not the
|
|
481
|
+
# compact line).
|
|
482
|
+
problems = []
|
|
483
|
+
warns = []
|
|
484
|
+
# Two scores: accept the compact `C=<n>` form OR two plain "score" numbers
|
|
485
|
+
# (the dogfood wrote "Self score: 31. Independent score: 28.").
|
|
486
|
+
c_matches = re.findall(r"\bC\s*=\s*-?\d+\b", text)
|
|
487
|
+
score_matches = re.findall(r"\bscores?\b[^\n]*?-?\d+", text, re.I)
|
|
488
|
+
has_two_scores = len(c_matches) >= 2 or len(score_matches) >= 2
|
|
489
|
+
has_divergence = re.search(r"\bdivergence\b", text, re.I) is not None
|
|
490
|
+
if not (has_two_scores or has_divergence):
|
|
491
|
+
if re.search(r"\bno fresh context was available\b", text, re.I):
|
|
492
|
+
warns.append("records no fresh context was available instead of a second score")
|
|
493
|
+
else:
|
|
494
|
+
problems.append("missing both scores and the divergence note (ship-gates.md:17)")
|
|
495
|
+
missing_factors = [f for f in ("M", "V", "I", "F", "A")
|
|
496
|
+
if not re.search(r"\b" + f + r"\s*=\s*-?\d+\b", text)]
|
|
497
|
+
if missing_factors:
|
|
498
|
+
warns.append("no compact factor breakdown (%s); documented for the log, not this file"
|
|
499
|
+
% ",".join(missing_factors))
|
|
500
|
+
return problems, warns
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def parse_log_entries(text):
|
|
504
|
+
entries = []
|
|
505
|
+
current = None
|
|
506
|
+
for raw in text.splitlines():
|
|
507
|
+
head = re.match(r"^\s*###\s+(\d{4}-\d{2}-\d{2})\s+·\s+(\S+)\s+·\s+(.+?)\s*$", raw)
|
|
508
|
+
if head:
|
|
509
|
+
if current:
|
|
510
|
+
entries.append(current)
|
|
511
|
+
current = {
|
|
512
|
+
"date": head.group(1),
|
|
513
|
+
"mode": head.group(2),
|
|
514
|
+
"archetype": head.group(3).strip(),
|
|
515
|
+
"fields": {},
|
|
516
|
+
}
|
|
517
|
+
continue
|
|
518
|
+
if current is None:
|
|
519
|
+
continue
|
|
520
|
+
# Documented format writes each field as a bullet: "- headline: {shipped H1}".
|
|
521
|
+
field = re.match(r"^\s*[-*]?\s*([A-Za-z ]+):\s*(.*?)\s*$", raw)
|
|
522
|
+
if field:
|
|
523
|
+
current["fields"][field.group(1).strip().lower()] = field.group(2)
|
|
524
|
+
if current:
|
|
525
|
+
entries.append(current)
|
|
526
|
+
return entries
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def parse_foundry_log(text):
|
|
530
|
+
problems = []
|
|
531
|
+
entries = parse_log_entries(text)
|
|
532
|
+
if not entries:
|
|
533
|
+
problems.append(("foundry-log.md", "missing parseable ### YYYY-MM-DD · mode · archetype entries", None))
|
|
534
|
+
return entries, problems
|
|
535
|
+
for entry in entries:
|
|
536
|
+
missing = [field for field in LOG_FIELDS if field not in entry["fields"]]
|
|
537
|
+
if missing:
|
|
538
|
+
problems.append(
|
|
539
|
+
(
|
|
540
|
+
"foundry-log.md:%s" % entry["date"],
|
|
541
|
+
"entry missing required fields",
|
|
542
|
+
", ".join(missing),
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
skeleton = entry["fields"].get("skeleton", "")
|
|
546
|
+
if len([part.strip() for part in skeleton.split("/") if part.strip()]) != 4:
|
|
547
|
+
problems.append(
|
|
548
|
+
(
|
|
549
|
+
"foundry-log.md:%s" % entry["date"],
|
|
550
|
+
"skeleton must carry four /-separated axis settings",
|
|
551
|
+
skeleton,
|
|
552
|
+
)
|
|
553
|
+
)
|
|
554
|
+
meclabs = entry["fields"].get("meclabs", "")
|
|
555
|
+
if not re.search(r"\bC\s*=\s*-?\d+\b", meclabs):
|
|
556
|
+
problems.append(
|
|
557
|
+
(
|
|
558
|
+
"foundry-log.md:%s" % entry["date"],
|
|
559
|
+
"meclabs line must parse numerically",
|
|
560
|
+
meclabs,
|
|
561
|
+
)
|
|
562
|
+
)
|
|
563
|
+
return entries, problems
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def require_file(results, path, label, floor):
|
|
567
|
+
st = stat_path(path)
|
|
568
|
+
if isinstance(st, OSError):
|
|
569
|
+
emit(results, "FAIL", path, "%s unreadable or missing" % label, str(st))
|
|
570
|
+
return None
|
|
571
|
+
if not Path(path).is_file():
|
|
572
|
+
emit(results, "FAIL", path, "%s is not a file" % label)
|
|
573
|
+
return None
|
|
574
|
+
if st.st_size <= 0:
|
|
575
|
+
emit(results, "FAIL", path, "%s is empty" % label)
|
|
576
|
+
return None
|
|
577
|
+
text = read_text(path)
|
|
578
|
+
if isinstance(text, OSError):
|
|
579
|
+
emit(results, "FAIL", path, "%s unreadable" % label, str(text))
|
|
580
|
+
return None
|
|
581
|
+
lines = data_lines(text)
|
|
582
|
+
if len(lines) < floor:
|
|
583
|
+
emit(results, "FAIL", path, "%s is too thin (%d < %d data lines)" % (label, len(lines), floor))
|
|
584
|
+
return None
|
|
585
|
+
emit(results, "PASS", path)
|
|
586
|
+
return {"path": Path(path), "text": text, "lines": lines}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def require_dir(results, path, label, minimum_files):
|
|
590
|
+
st = stat_path(path)
|
|
591
|
+
if isinstance(st, OSError):
|
|
592
|
+
emit(results, "FAIL", path, "%s unreadable or missing" % label, str(st))
|
|
593
|
+
return None
|
|
594
|
+
if not Path(path).is_dir():
|
|
595
|
+
emit(results, "FAIL", path, "%s is not a directory" % label)
|
|
596
|
+
return None
|
|
597
|
+
count = count_nonblank_files(path)
|
|
598
|
+
if count < minimum_files:
|
|
599
|
+
emit(results, "FAIL", path, "%s needs at least %d non-empty file(s)" % (label, minimum_files))
|
|
600
|
+
return None
|
|
601
|
+
emit(results, "PASS", path)
|
|
602
|
+
return {"path": Path(path), "count": count}
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def require_comp_record(results, run_dir, critique_dir):
|
|
606
|
+
if critique_dir:
|
|
607
|
+
info = require_dir(results, critique_dir, "impeccable critique dir", 1)
|
|
608
|
+
return info["path"] if info else None
|
|
609
|
+
record = run_dir / "comp" / "rounds.md"
|
|
610
|
+
info = require_file(results, record, "comp record", 2)
|
|
611
|
+
return info["path"] if info else None
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def optional_file(results, path, label, floor):
|
|
615
|
+
st = stat_path(path)
|
|
616
|
+
if isinstance(st, OSError):
|
|
617
|
+
emit(results, "WARN", path, "%s missing" % label)
|
|
618
|
+
return None
|
|
619
|
+
if not Path(path).is_file():
|
|
620
|
+
emit(results, "WARN", path, "%s is not a file" % label)
|
|
621
|
+
return None
|
|
622
|
+
text = read_text(path)
|
|
623
|
+
if isinstance(text, OSError):
|
|
624
|
+
emit(results, "WARN", path, "%s unreadable" % label, str(text))
|
|
625
|
+
return None
|
|
626
|
+
if len(data_lines(text)) < floor:
|
|
627
|
+
emit(results, "WARN", path, "%s is thin" % label)
|
|
628
|
+
return None
|
|
629
|
+
emit(results, "PASS", path)
|
|
630
|
+
return {"path": Path(path), "text": text}
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def skip(results, subject, why):
|
|
634
|
+
emit(results, "SKIP", subject, why)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def is_projection_pair(first, second, package_dir):
|
|
638
|
+
"""True when exactly one of the two paths lives inside the handoff package.
|
|
639
|
+
|
|
640
|
+
The package is a documented projection of the run directory (01-copy.md IS
|
|
641
|
+
copy/copy-approved.md, 02-page-spec.md IS the spec), so identical content
|
|
642
|
+
across that boundary is the contract being honored, not a chain shortcut.
|
|
643
|
+
Duplicate detection stays in force on both sides of the boundary.
|
|
644
|
+
"""
|
|
645
|
+
if not package_dir:
|
|
646
|
+
return False
|
|
647
|
+
root = str(Path(package_dir).resolve())
|
|
648
|
+
inside_first = str(Path(first).resolve()).startswith(root + os.sep)
|
|
649
|
+
inside_second = str(Path(second).resolve()).startswith(root + os.sep)
|
|
650
|
+
return inside_first != inside_second
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def check_duplicate_hashes(results, infos, package_dir):
|
|
654
|
+
seen = {}
|
|
655
|
+
failures = 0
|
|
656
|
+
for info in infos:
|
|
657
|
+
digest = sha256_file(info["path"])
|
|
658
|
+
if isinstance(digest, OSError):
|
|
659
|
+
emit(results, "FAIL", info["path"], "could not hash artifact", str(digest))
|
|
660
|
+
failures += 1
|
|
661
|
+
continue
|
|
662
|
+
path_str = str(info["path"])
|
|
663
|
+
twin = seen.get(digest)
|
|
664
|
+
if twin and not is_projection_pair(path_str, twin, package_dir):
|
|
665
|
+
emit(results, "FAIL", path_str, "duplicate content hash matches distinct required artifact", twin)
|
|
666
|
+
failures += 1
|
|
667
|
+
else:
|
|
668
|
+
seen[digest] = path_str
|
|
669
|
+
return failures
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def check_stale(results, target, source, tolerance):
|
|
673
|
+
if not Path(target).exists() or not Path(source).exists():
|
|
674
|
+
return 0
|
|
675
|
+
target_stat = stat_path(target)
|
|
676
|
+
source_stat = stat_path(source)
|
|
677
|
+
if isinstance(target_stat, OSError) or isinstance(source_stat, OSError):
|
|
678
|
+
return 0
|
|
679
|
+
gap = source_stat.st_mtime - target_stat.st_mtime
|
|
680
|
+
if gap > tolerance:
|
|
681
|
+
# WARN, never FAIL. An mtime inversion has two innocent causes that are
|
|
682
|
+
# indistinguishable from a genuinely stale derivation: the source was
|
|
683
|
+
# legitimately edited AFTER the derived artifact (a provenance note
|
|
684
|
+
# appended to voc.md, a late brief correction), or mtimes were reordered
|
|
685
|
+
# by a copy/checkout. mtime cannot answer "was this derived from THIS
|
|
686
|
+
# version of the source", so it must not block a ship. It is surfaced for
|
|
687
|
+
# a human to read: an inversion is worth a look (it did catch a real
|
|
688
|
+
# not-re-scored conversion audit in the v3.0 dogfood), it just is not
|
|
689
|
+
# proof. See the limits block at the foot of this file.
|
|
690
|
+
emit(
|
|
691
|
+
results,
|
|
692
|
+
"WARN",
|
|
693
|
+
"%s <- %s" % (target, source),
|
|
694
|
+
"derived artifact older than source by %.1fs (mtime ordering only; review by hand)" % gap,
|
|
695
|
+
"target=%s source=%s" % (
|
|
696
|
+
datetime.fromtimestamp(target_stat.st_mtime).isoformat(timespec="seconds"),
|
|
697
|
+
datetime.fromtimestamp(source_stat.st_mtime).isoformat(timespec="seconds"),
|
|
698
|
+
),
|
|
699
|
+
)
|
|
700
|
+
return 0
|
|
701
|
+
emit(results, "PASS", "%s <- %s" % (target, source))
|
|
702
|
+
return 0
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def build_arg_state(argv):
|
|
706
|
+
args = {
|
|
707
|
+
"mode": None,
|
|
708
|
+
"archetype": None,
|
|
709
|
+
"pages_dir": None,
|
|
710
|
+
"handoff_dir": None,
|
|
711
|
+
"brief": None,
|
|
712
|
+
"rewrite": False,
|
|
713
|
+
"multipage": False,
|
|
714
|
+
"closing": False,
|
|
715
|
+
"tolerance": 2,
|
|
716
|
+
"impeccable_critique": None,
|
|
717
|
+
"run_dir": None,
|
|
718
|
+
}
|
|
719
|
+
flags_with_values = {
|
|
720
|
+
"--mode": "mode",
|
|
721
|
+
"--archetype": "archetype",
|
|
722
|
+
"--pages-dir": "pages_dir",
|
|
723
|
+
"--handoff-dir": "handoff_dir",
|
|
724
|
+
"--brief": "brief",
|
|
725
|
+
"--tolerance": "tolerance",
|
|
726
|
+
"--impeccable-critique": "impeccable_critique",
|
|
727
|
+
}
|
|
728
|
+
bool_flags = {"--rewrite": "rewrite", "--multipage": "multipage", "--closing": "closing"}
|
|
729
|
+
items = list(argv[1:])
|
|
730
|
+
if not items:
|
|
731
|
+
print(__doc__)
|
|
732
|
+
return None
|
|
733
|
+
index = 0
|
|
734
|
+
positionals = []
|
|
735
|
+
while index < len(items):
|
|
736
|
+
item = items[index]
|
|
737
|
+
if item in flags_with_values:
|
|
738
|
+
if index + 1 >= len(items) or items[index + 1].startswith("--"):
|
|
739
|
+
return usage_error("%s requires a value" % item)
|
|
740
|
+
args[flags_with_values[item]] = items[index + 1]
|
|
741
|
+
index += 2
|
|
742
|
+
continue
|
|
743
|
+
if item in bool_flags:
|
|
744
|
+
args[bool_flags[item]] = True
|
|
745
|
+
index += 1
|
|
746
|
+
continue
|
|
747
|
+
if item.startswith("--"):
|
|
748
|
+
return usage_error("unknown flag: %s" % item)
|
|
749
|
+
positionals.append(item)
|
|
750
|
+
index += 1
|
|
751
|
+
if len(positionals) != 1:
|
|
752
|
+
return usage_error("expected exactly one <run-dir> positional argument")
|
|
753
|
+
args["run_dir"] = Path(positionals[0])
|
|
754
|
+
if args["mode"] not in MODES:
|
|
755
|
+
return usage_error("--mode must be one of build, explore, handoff")
|
|
756
|
+
if not args["archetype"]:
|
|
757
|
+
return usage_error("--archetype is required")
|
|
758
|
+
try:
|
|
759
|
+
args["tolerance"] = int(args["tolerance"])
|
|
760
|
+
except (TypeError, ValueError):
|
|
761
|
+
return usage_error("--tolerance must be an integer number of seconds")
|
|
762
|
+
if args["tolerance"] < 0:
|
|
763
|
+
return usage_error("--tolerance must be >= 0")
|
|
764
|
+
if not args["run_dir"].is_dir():
|
|
765
|
+
return usage_error("run directory does not exist: %s" % args["run_dir"])
|
|
766
|
+
return args
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def audit_run(args):
|
|
770
|
+
results = []
|
|
771
|
+
run_dir = args["run_dir"].resolve()
|
|
772
|
+
workspace = resolve_workspace(run_dir)
|
|
773
|
+
product = run_dir.name
|
|
774
|
+
brief_path, brief_defaulted = resolve_brief(args, workspace, run_dir)
|
|
775
|
+
pages_dir, pages_defaulted = resolve_related_dir(args["pages_dir"], workspace, product, "pages")
|
|
776
|
+
handoff_dir, handoff_defaulted = resolve_related_dir(args["handoff_dir"], workspace, product, "handoff")
|
|
777
|
+
critique_dir = Path(args["impeccable_critique"]) if args["impeccable_critique"] else None
|
|
778
|
+
token_dir = run_dir / "tokens"
|
|
779
|
+
token_count = count_nonblank_files(token_dir) if token_dir.is_dir() else 0
|
|
780
|
+
print(
|
|
781
|
+
"run: %s mode=%s archetype=%s tokens=%d pages=%s%s handoff=%s%s brief=%s%s"
|
|
782
|
+
% (
|
|
783
|
+
run_dir,
|
|
784
|
+
args["mode"],
|
|
785
|
+
args["archetype"],
|
|
786
|
+
token_count,
|
|
787
|
+
pages_dir,
|
|
788
|
+
" [default]" if pages_defaulted else "",
|
|
789
|
+
handoff_dir,
|
|
790
|
+
" [default]" if handoff_defaulted else "",
|
|
791
|
+
brief_path,
|
|
792
|
+
" [default]" if brief_defaulted else "",
|
|
793
|
+
)
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
# Preflight record: what was actually available this run. Absent -> strict
|
|
797
|
+
# (every core artifact required); present -> a core companion the user
|
|
798
|
+
# overrode has its artifact demoted to advisory. This is what lets an
|
|
799
|
+
# honestly-declared PARTIAL run pass while a present-but-skipped companion
|
|
800
|
+
# still fails on its missing artifact.
|
|
801
|
+
preflight_path = run_dir / "preflight.md"
|
|
802
|
+
if preflight_path.is_file():
|
|
803
|
+
pf_text = read_text(preflight_path)
|
|
804
|
+
pf_states, pf_override = parse_preflight(pf_text) if isinstance(pf_text, str) else ({}, set())
|
|
805
|
+
emit(results, "PASS", preflight_path, "preflight recorded (%d companions, override: %s)"
|
|
806
|
+
% (len(pf_states), ", ".join(sorted(pf_override)) or "none"))
|
|
807
|
+
else:
|
|
808
|
+
pf_states, pf_override = {}, set()
|
|
809
|
+
emit(results, "WARN", preflight_path,
|
|
810
|
+
"preflight.md missing; degraded-claim cross-check unavailable, core artifacts held strict (SKILL.md Phase -1)")
|
|
811
|
+
|
|
812
|
+
required_infos = []
|
|
813
|
+
brief_info = require_file(results, brief_path, "brief", 3)
|
|
814
|
+
if brief_info:
|
|
815
|
+
required_infos.append(brief_info)
|
|
816
|
+
for origin, rel, floor in REQUIRED_FILES[1:]:
|
|
817
|
+
path = run_dir / rel
|
|
818
|
+
companion = CORE_ARTIFACT_COMPANION.get(rel)
|
|
819
|
+
if companion and not companion_available(pf_states, pf_override, companion):
|
|
820
|
+
# Companion overridden/declined at preflight: its artifact is expected
|
|
821
|
+
# to be absent. Advisory only, so an honest PARTIAL run is not failed.
|
|
822
|
+
optional_file(results, path, "%s (%s declined at preflight)" % (rel, companion), floor)
|
|
823
|
+
continue
|
|
824
|
+
info = require_file(results, path, rel, floor)
|
|
825
|
+
if info:
|
|
826
|
+
required_infos.append(info)
|
|
827
|
+
|
|
828
|
+
tokens_info = require_dir(results, token_dir, "tokens dir", 1)
|
|
829
|
+
if tokens_info:
|
|
830
|
+
newest_token = newest_file(token_dir)
|
|
831
|
+
if args["mode"] == "explore":
|
|
832
|
+
if tokens_info["count"] < 2:
|
|
833
|
+
emit(results, "FAIL", token_dir, "explore mode needs at least two token files")
|
|
834
|
+
elif tokens_info["count"] == 2:
|
|
835
|
+
emit(results, "WARN", token_dir, "explore mode has only two token files; documented default is >=3")
|
|
836
|
+
required_infos.extend({"path": item} for item in sorted(token_dir.rglob("*")) if item.is_file())
|
|
837
|
+
else:
|
|
838
|
+
newest_token = None
|
|
839
|
+
|
|
840
|
+
if args["rewrite"]:
|
|
841
|
+
rewrite_info = require_file(results, run_dir / "rewrite-diagnosis.md", "rewrite-diagnosis.md", 3)
|
|
842
|
+
if rewrite_info:
|
|
843
|
+
required_infos.append(rewrite_info)
|
|
844
|
+
else:
|
|
845
|
+
optional_file(results, run_dir / "rewrite-diagnosis.md", "rewrite-diagnosis.md", 3)
|
|
846
|
+
|
|
847
|
+
if args["multipage"]:
|
|
848
|
+
site_context_info = require_file(results, run_dir / "site-context.md", "site-context.md", 3)
|
|
849
|
+
if site_context_info:
|
|
850
|
+
required_infos.append(site_context_info)
|
|
851
|
+
else:
|
|
852
|
+
site_context_info = optional_file(results, run_dir / "site-context.md", "site-context.md", 3)
|
|
853
|
+
|
|
854
|
+
optional_file(results, run_dir / "experiments.md", "experiments.md", 2)
|
|
855
|
+
|
|
856
|
+
known_archetype = args["archetype"] in KNOWN_ARCHETYPES
|
|
857
|
+
if not known_archetype:
|
|
858
|
+
emit(results, "WARN", args["archetype"], "unknown archetype string; conditional checks skipped")
|
|
859
|
+
|
|
860
|
+
if args["mode"] in ("build", "explore", "handoff"):
|
|
861
|
+
# Phase 4 design outputs: imagery and interactive-state plans. Required in
|
|
862
|
+
# every mode because the design decisions they record must be made even
|
|
863
|
+
# when an external tool does the building (handoff carries them in the
|
|
864
|
+
# package). A page that skips them defaults to a text wall and browser
|
|
865
|
+
# states, the two strongest remaining AI tells.
|
|
866
|
+
img_info = require_file(results, run_dir / "imagery-plan.md", "imagery-plan.md", 3)
|
|
867
|
+
if img_info:
|
|
868
|
+
required_infos.append(img_info)
|
|
869
|
+
sm_info = require_file(results, run_dir / "states-motion.md", "states-motion.md", 3)
|
|
870
|
+
if sm_info:
|
|
871
|
+
required_infos.append(sm_info)
|
|
872
|
+
|
|
873
|
+
# DESIGN.md: impeccable's design-system artifact (Phase 4 step 8), impeccable
|
|
874
|
+
# being a CORE companion. In build/explore it lives at the product repo root
|
|
875
|
+
# (workspace) or the run dir; in handoff it is the package projection, checked
|
|
876
|
+
# via HANDOFF_FILES. Required unless impeccable was overridden at preflight,
|
|
877
|
+
# in which case its absence is advisory (an honest PARTIAL run).
|
|
878
|
+
if args["mode"] in ("build", "explore"):
|
|
879
|
+
design_candidates = [workspace / "DESIGN.md", run_dir / "DESIGN.md"]
|
|
880
|
+
design_path = next((p for p in design_candidates if p.is_file()), design_candidates[0])
|
|
881
|
+
if companion_available(pf_states, pf_override, "impeccable"):
|
|
882
|
+
d_info = require_file(results, design_path, "DESIGN.md (impeccable, core)", 5)
|
|
883
|
+
if d_info:
|
|
884
|
+
required_infos.append(d_info)
|
|
885
|
+
else:
|
|
886
|
+
optional_file(results, design_path, "DESIGN.md (impeccable declined at preflight)", 5)
|
|
887
|
+
|
|
888
|
+
if args["mode"] in ("build", "explore"):
|
|
889
|
+
comp_info = require_file(results, run_dir / "comp" / "index.html", "comp/index.html", 3)
|
|
890
|
+
if comp_info:
|
|
891
|
+
required_infos.append(comp_info)
|
|
892
|
+
comp_record = require_comp_record(results, run_dir, critique_dir)
|
|
893
|
+
page_index_info = require_file(results, pages_dir / "index.html", "pages index", 3)
|
|
894
|
+
if page_index_info:
|
|
895
|
+
required_infos.append(page_index_info)
|
|
896
|
+
theme_info = require_file(results, pages_dir / "theme.css", "theme.css", 1)
|
|
897
|
+
if theme_info:
|
|
898
|
+
required_infos.append(theme_info)
|
|
899
|
+
llms_info = require_file(results, pages_dir / "llms.txt", "llms.txt", 1)
|
|
900
|
+
if llms_info:
|
|
901
|
+
required_infos.append(llms_info)
|
|
902
|
+
optional_file(results, run_dir / "og.html", "og.html", 1)
|
|
903
|
+
assets_dir = pages_dir / "assets"
|
|
904
|
+
og_found = False
|
|
905
|
+
if assets_dir.is_dir():
|
|
906
|
+
for candidate in sorted(assets_dir.rglob("*")):
|
|
907
|
+
if candidate.is_file() and re.search(r"\b(?:og|open-graph)\b", candidate.name, re.I):
|
|
908
|
+
og_found = True
|
|
909
|
+
break
|
|
910
|
+
if og_found:
|
|
911
|
+
emit(results, "PASS", assets_dir)
|
|
912
|
+
else:
|
|
913
|
+
emit(results, "WARN", assets_dir, "OG image missing")
|
|
914
|
+
if known_archetype and args["archetype"] in PUBLIC_PRICING_ARCHETYPES:
|
|
915
|
+
pricing_info = require_file(results, pages_dir / "pricing.md", "pricing.md", 2)
|
|
916
|
+
if pricing_info:
|
|
917
|
+
required_infos.append(pricing_info)
|
|
918
|
+
elif args["archetype"] not in PUBLIC_PRICING_ARCHETYPES:
|
|
919
|
+
skip(results, pages_dir / "pricing.md", "archetype does not require pricing.md")
|
|
920
|
+
else:
|
|
921
|
+
skip(results, run_dir / "comp/index.html", "handoff mode: the design tool owns the build")
|
|
922
|
+
skip(results, run_dir / "comp/rounds.md", "handoff mode: the design tool owns the build")
|
|
923
|
+
skip(results, pages_dir / "index.html", "handoff mode: the design tool owns the build")
|
|
924
|
+
skip(results, pages_dir / "theme.css", "handoff mode: the design tool owns the build")
|
|
925
|
+
skip(results, pages_dir / "llms.txt", "handoff mode: the design tool owns the build")
|
|
926
|
+
for rel in HANDOFF_FILES:
|
|
927
|
+
info = require_file(results, handoff_dir / rel, rel, 1)
|
|
928
|
+
if info:
|
|
929
|
+
required_infos.append(info)
|
|
930
|
+
assets_info = require_dir(results, handoff_dir / "assets", "handoff assets", 1)
|
|
931
|
+
if assets_info:
|
|
932
|
+
required_infos.append({"path": handoff_dir / "assets"})
|
|
933
|
+
|
|
934
|
+
if known_archetype and args["archetype"] in FORM_CENTRIC_ARCHETYPES:
|
|
935
|
+
page_spec_text = read_text(run_dir / "page-spec.md")
|
|
936
|
+
if isinstance(page_spec_text, OSError):
|
|
937
|
+
pass
|
|
938
|
+
elif has_form_design(page_spec_text):
|
|
939
|
+
emit(results, "PASS", run_dir / "page-spec.md:form", "form design block present")
|
|
940
|
+
else:
|
|
941
|
+
emit(results, "FAIL", run_dir / "page-spec.md:form", "form-centric archetype requires a form-design block")
|
|
942
|
+
elif args["archetype"] not in FORM_CENTRIC_ARCHETYPES:
|
|
943
|
+
skip(results, run_dir / "page-spec.md:form", "archetype is not form-centric")
|
|
944
|
+
|
|
945
|
+
for info in list(required_infos):
|
|
946
|
+
path = info["path"]
|
|
947
|
+
if not path.is_file():
|
|
948
|
+
continue
|
|
949
|
+
text = info.get("text")
|
|
950
|
+
if text is None:
|
|
951
|
+
text = read_text(path)
|
|
952
|
+
if isinstance(text, OSError):
|
|
953
|
+
emit(results, "FAIL", path, "unreadable required artifact", str(text))
|
|
954
|
+
continue
|
|
955
|
+
if path == run_dir / "voc.md":
|
|
956
|
+
for why, ctx in parse_voc(text):
|
|
957
|
+
emit(results, "FAIL", "%s" % path, why, ctx)
|
|
958
|
+
elif path == run_dir / "message-architecture.md":
|
|
959
|
+
problems = parse_message_architecture(text)
|
|
960
|
+
for why in problems:
|
|
961
|
+
emit(results, "FAIL", path, why)
|
|
962
|
+
elif path == run_dir / "persuasion-map.md":
|
|
963
|
+
for why in parse_persuasion_map(text):
|
|
964
|
+
emit(results, "FAIL", path, why)
|
|
965
|
+
elif path == run_dir / "page-spec.md":
|
|
966
|
+
for why in parse_page_spec(text, args["archetype"]):
|
|
967
|
+
emit(results, "FAIL", path, why)
|
|
968
|
+
elif path == run_dir / "copy" / "copy-approved.md":
|
|
969
|
+
for why in parse_copy_approved(text):
|
|
970
|
+
emit(results, "FAIL", path, why)
|
|
971
|
+
elif path == run_dir / "conversion-audit.md":
|
|
972
|
+
problems, warns = parse_conversion_audit(text)
|
|
973
|
+
for why in problems:
|
|
974
|
+
emit(results, "FAIL", path, why)
|
|
975
|
+
for why in warns:
|
|
976
|
+
emit(results, "WARN", path, why)
|
|
977
|
+
|
|
978
|
+
snapshot_text = read_text(run_dir / "copy" / "copy-approved.md")
|
|
979
|
+
snapshot_title = None
|
|
980
|
+
if not isinstance(snapshot_text, OSError):
|
|
981
|
+
snapshot_title = extract_snapshot_title(snapshot_text)
|
|
982
|
+
|
|
983
|
+
if args["mode"] in ("build", "explore"):
|
|
984
|
+
index_text = read_text(pages_dir / "index.html")
|
|
985
|
+
if not isinstance(index_text, OSError):
|
|
986
|
+
problems, actual_title = parse_index_html(index_text, snapshot_title)
|
|
987
|
+
for why in problems:
|
|
988
|
+
if why == "title mismatch":
|
|
989
|
+
emit(
|
|
990
|
+
results,
|
|
991
|
+
"FAIL",
|
|
992
|
+
pages_dir / "index.html",
|
|
993
|
+
why,
|
|
994
|
+
"snapshot=%s built=%s" % (snapshot_title, actual_title),
|
|
995
|
+
)
|
|
996
|
+
else:
|
|
997
|
+
emit(results, "FAIL", pages_dir / "index.html", why)
|
|
998
|
+
|
|
999
|
+
log_path = run_dir / "foundry-log.md"
|
|
1000
|
+
log_text = read_text(log_path)
|
|
1001
|
+
log_entries = []
|
|
1002
|
+
if isinstance(log_text, OSError):
|
|
1003
|
+
if args["closing"]:
|
|
1004
|
+
emit(results, "FAIL", log_path, "foundry-log.md required under --closing", str(log_text))
|
|
1005
|
+
else:
|
|
1006
|
+
emit(results, "WARN", log_path, "foundry-log.md missing on first run", str(log_text))
|
|
1007
|
+
else:
|
|
1008
|
+
emit(results, "PASS", log_path)
|
|
1009
|
+
log_entries, problems = parse_foundry_log(log_text)
|
|
1010
|
+
for subject, why, ctx in problems:
|
|
1011
|
+
emit(results, "FAIL", subject, why, ctx)
|
|
1012
|
+
# Degraded-claim cross-check (Gate 0, third bullet): a phase reported
|
|
1013
|
+
# degraded because its companion was "missing" is a FAIL if that
|
|
1014
|
+
# companion resolves PRESENT in preflight.md and was not overridden.
|
|
1015
|
+
for entry in log_entries:
|
|
1016
|
+
degraded = entry["fields"].get("degraded", "")
|
|
1017
|
+
if not degraded or degraded.strip().lower() in ("none", "-"):
|
|
1018
|
+
continue
|
|
1019
|
+
for companion in re.findall(r"[A-Za-z][A-Za-z0-9_-]+", degraded):
|
|
1020
|
+
c = companion.lower()
|
|
1021
|
+
if c in pf_states and companion_available(pf_states, pf_override, c):
|
|
1022
|
+
emit(results, "FAIL", "%s:%s" % (log_path, entry["date"]),
|
|
1023
|
+
"degraded claim names '%s' but preflight recorded it PRESENT and un-overridden" % c,
|
|
1024
|
+
degraded[:80])
|
|
1025
|
+
if args["closing"]:
|
|
1026
|
+
today = date.today().isoformat()
|
|
1027
|
+
matched = False
|
|
1028
|
+
for entry in log_entries:
|
|
1029
|
+
if entry["date"] == today and entry["mode"] == args["mode"] and entry["archetype"] == args["archetype"]:
|
|
1030
|
+
matched = True
|
|
1031
|
+
break
|
|
1032
|
+
if matched:
|
|
1033
|
+
emit(results, "PASS", "%s:%s" % (log_path, today), "matching closing entry present")
|
|
1034
|
+
else:
|
|
1035
|
+
emit(results, "FAIL", log_path, "missing today-dated closing entry for mode/archetype", today)
|
|
1036
|
+
|
|
1037
|
+
if args["mode"] == "handoff":
|
|
1038
|
+
copy_text = read_text(run_dir / "copy" / "copy-approved.md")
|
|
1039
|
+
shipped_copy = read_text(handoff_dir / "01-copy.md")
|
|
1040
|
+
if not isinstance(copy_text, OSError) and not isinstance(shipped_copy, OSError):
|
|
1041
|
+
if normalize_text(copy_text) == normalize_text(shipped_copy):
|
|
1042
|
+
emit(results, "PASS", handoff_dir / "01-copy.md", "content matches copy-approved.md")
|
|
1043
|
+
else:
|
|
1044
|
+
missing_lines = []
|
|
1045
|
+
for line in normalize_text(copy_text).splitlines():
|
|
1046
|
+
if line not in normalize_text(shipped_copy):
|
|
1047
|
+
missing_lines.append(line[:80])
|
|
1048
|
+
if missing_lines:
|
|
1049
|
+
emit(results, "FAIL", handoff_dir / "01-copy.md", "diverges from copy/copy-approved.md", missing_lines[0])
|
|
1050
|
+
|
|
1051
|
+
package_dir = handoff_dir if args["mode"] == "handoff" else None
|
|
1052
|
+
check_duplicate_hashes(results, [info for info in required_infos if Path(info["path"]).is_file()], package_dir)
|
|
1053
|
+
|
|
1054
|
+
stale_edges = []
|
|
1055
|
+
if newest_token:
|
|
1056
|
+
token_source = newest_token
|
|
1057
|
+
else:
|
|
1058
|
+
token_source = None
|
|
1059
|
+
stale_edges.extend(
|
|
1060
|
+
[
|
|
1061
|
+
(run_dir / "message-architecture.md", brief_path),
|
|
1062
|
+
(run_dir / "message-architecture.md", run_dir / "voc.md"),
|
|
1063
|
+
(run_dir / "persuasion-map.md", brief_path),
|
|
1064
|
+
(run_dir / "persuasion-map.md", run_dir / "voc.md"),
|
|
1065
|
+
(run_dir / "page-spec.md", run_dir / "message-architecture.md"),
|
|
1066
|
+
(run_dir / "page-spec.md", run_dir / "persuasion-map.md"),
|
|
1067
|
+
(run_dir / "copy" / "approved-draft.md", run_dir / "page-spec.md"),
|
|
1068
|
+
(run_dir / "copy" / "approved-draft.md", run_dir / "message-architecture.md"),
|
|
1069
|
+
(run_dir / "copy" / "copy-approved.md", run_dir / "copy" / "approved-draft.md"),
|
|
1070
|
+
(run_dir / "copy" / "copy-approved.md", run_dir / "copy" / "hero-candidates.md"),
|
|
1071
|
+
]
|
|
1072
|
+
)
|
|
1073
|
+
if site_context_info:
|
|
1074
|
+
stale_edges.append((run_dir / "page-spec.md", run_dir / "site-context.md"))
|
|
1075
|
+
if token_source:
|
|
1076
|
+
stale_edges.append((run_dir / "comp" / "index.html", token_source))
|
|
1077
|
+
stale_edges.append((run_dir / "copy" / "copy-approved.md", token_source))
|
|
1078
|
+
if args["mode"] in ("build", "explore"):
|
|
1079
|
+
stale_edges.append((pages_dir / "theme.css", token_source))
|
|
1080
|
+
if args["mode"] == "handoff":
|
|
1081
|
+
stale_edges.append((handoff_dir / "03-design-direction.md", token_source))
|
|
1082
|
+
if args["mode"] in ("build", "explore"):
|
|
1083
|
+
stale_edges.extend(
|
|
1084
|
+
[
|
|
1085
|
+
(run_dir / "comp" / "index.html", run_dir / "copy" / "approved-draft.md"),
|
|
1086
|
+
(run_dir / "comp" / "index.html", run_dir / "page-spec.md"),
|
|
1087
|
+
(run_dir / "copy" / "copy-approved.md", run_dir / "comp" / "index.html"),
|
|
1088
|
+
(pages_dir / "index.html", run_dir / "copy" / "copy-approved.md"),
|
|
1089
|
+
(pages_dir / "index.html", run_dir / "page-spec.md"),
|
|
1090
|
+
(run_dir / "conversion-audit.md", pages_dir / "index.html"),
|
|
1091
|
+
]
|
|
1092
|
+
)
|
|
1093
|
+
else:
|
|
1094
|
+
stale_edges.extend(
|
|
1095
|
+
[
|
|
1096
|
+
(handoff_dir / "01-copy.md", run_dir / "copy" / "copy-approved.md"),
|
|
1097
|
+
(handoff_dir / "02-page-spec.md", run_dir / "page-spec.md"),
|
|
1098
|
+
(run_dir / "conversion-audit.md", handoff_dir / "01-copy.md"),
|
|
1099
|
+
]
|
|
1100
|
+
)
|
|
1101
|
+
if args["closing"]:
|
|
1102
|
+
stale_edges.append((run_dir / "foundry-log.md", run_dir / "conversion-audit.md"))
|
|
1103
|
+
for target, source in stale_edges:
|
|
1104
|
+
if not Path(source).exists():
|
|
1105
|
+
continue
|
|
1106
|
+
check_stale(results, target, source, args["tolerance"])
|
|
1107
|
+
|
|
1108
|
+
fail_count = 0
|
|
1109
|
+
for status, line in results:
|
|
1110
|
+
print(line)
|
|
1111
|
+
if status == "FAIL":
|
|
1112
|
+
fail_count += 1
|
|
1113
|
+
return fail_count
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def main(argv):
|
|
1117
|
+
try:
|
|
1118
|
+
args = build_arg_state(argv)
|
|
1119
|
+
if args is None:
|
|
1120
|
+
return 2
|
|
1121
|
+
if isinstance(args, int):
|
|
1122
|
+
return args
|
|
1123
|
+
fail_count = audit_run(args)
|
|
1124
|
+
if fail_count:
|
|
1125
|
+
print("\n%d hard failure(s). Review WARN lines by hand; the chain is not mechanically intact." % fail_count)
|
|
1126
|
+
return 1
|
|
1127
|
+
print("\nChain intact. Orchestration gate passed (review any WARN lines by hand).")
|
|
1128
|
+
return 0
|
|
1129
|
+
except Exception as exc:
|
|
1130
|
+
print("ERROR %s" % exc, file=sys.stderr)
|
|
1131
|
+
return 2
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
# Limits:
|
|
1135
|
+
# - This cannot detect an artifact whose structure looks valid but whose content is
|
|
1136
|
+
# still garbage, lorem ipsum, or strategically empty.
|
|
1137
|
+
# - This cannot prove whether a companion skill was truly invoked versus an agent
|
|
1138
|
+
# hand-writing the artifact; no tool-call ledger exists in the audited files.
|
|
1139
|
+
# - This cannot verify whether a VOC source pointer is genuine or reachable; that
|
|
1140
|
+
# would require network access, which this script does not use.
|
|
1141
|
+
# - This cannot prove that a MECLABS score was produced cold in a fresh context.
|
|
1142
|
+
# - This cannot prove that humanizer, red-team, or pattern passes actually ran.
|
|
1143
|
+
# - This cannot prove deep semantic consistency between artifacts, such as whether
|
|
1144
|
+
# the built sections actually carry the message-architecture claims forward.
|
|
1145
|
+
# - This cannot treat mtime as tamper-proof: mtimes are forgeable with touch-like
|
|
1146
|
+
# tools and are often destroyed by copy or checkout operations, so ordering is a
|
|
1147
|
+
# defect detector for honest runs, not an anti-adversary control. As of the #38
|
|
1148
|
+
# calibration, mtime inversion is a WARN, never a HARD FAIL: a source edited
|
|
1149
|
+
# after its derived artifact (a late provenance note, a brief correction) is
|
|
1150
|
+
# indistinguishable by mtime alone from a genuinely stale derivation, so it
|
|
1151
|
+
# cannot gate a ship. It is surfaced for a human to read.
|
|
1152
|
+
#
|
|
1153
|
+
# HARD FAIL (blocks the chain) = presence, non-triviality, declared-mode
|
|
1154
|
+
# completeness, and unambiguous integrity (unresolved [TK, foundry-log that
|
|
1155
|
+
# does not parse in the SKILL.md:351 format, a required artifact missing).
|
|
1156
|
+
# These are deterministic and do not false-positive on honest runs.
|
|
1157
|
+
# WARN (human reads) = mtime ordering, optional/enhancer artifacts, and format
|
|
1158
|
+
# breakdowns documented for a different file than the one being read.
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
if __name__ == "__main__":
|
|
1162
|
+
sys.exit(main(sys.argv))
|