page-foundry 2.9.1 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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))