@antoneeo/mkt-agentic-sdlc-skill 0.12.0 → 0.14.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.
@@ -1,734 +1,718 @@
1
- #!/usr/bin/env python3
2
- """mkt_check.py -- the MARKETING domain entry point.
3
-
4
- The family's shared spine lives in `sdlc_core.py`, byte-identical in every
5
- distribution; this file is the marketing OVERLAY on top of it. It is deliberately
6
- not thin: a marketing plan is not a set of ANALYSIS documents, so the document
7
- model (`vision/ strategy/ tactics/ deliverables/`, the evidence ledger, the budget
8
- and funnel arithmetic) is genuinely this domain's own and stays here. What
9
- converges is the spine, not the paperwork.
10
-
11
- From the overlay (unchanged behaviour, unchanged exit codes):
12
- check [--root R] [--strict] run validate + ledger + budget + funnel + trace
13
- validate [--root R] [--strict] frontmatter + index freshness on canonical docs
14
- ledger [--root R] evidence ledger integrity + [EV-nn] reference resolution
15
- budget [--root R] [FILE] budget allocation sums to Total budget (+/-1%)
16
- funnel [--root R] [FILE] funnel model rows recompute (+/-5%)
17
- trace [--root R] objective -> tactic -> KPI chain
18
- index [--root R] (re)generate INDEX.md
19
-
20
- From the shared core (new to this distribution):
21
- stale / mark / gate / orient / benefit / plan / migrate -- the spine commands, identical everywhere
22
-
23
- Both files must sit in the same directory. Copying only this one fails at import,
24
- loudly, which is the intended failure.
25
-
26
- Docs root: `mkt_docs/` by default. `--docs-dir ai_docs` reaches a migrated project,
27
- which is what makes the move to the family's single tree possible at all.
28
-
29
- Exit codes: 0 clean (warnings allowed unless --strict), 1 errors (or warnings
30
- under --strict), 2 usage / structural problem (no docs root).
31
-
32
- Pure stdlib. ASCII output only (Windows-console safe).
33
- """
34
-
35
- import argparse
36
- import re
37
- import sys
38
- from pathlib import Path
39
-
40
- sys.path.insert(0, str(Path(__file__).resolve().parent))
41
-
42
- try:
43
- import sdlc_core
44
- except ImportError as exc: # pragma: no cover - exercised by the copied-file test
45
- sys.stderr.write(
46
- "[ERROR] mkt_check.py cannot find sdlc_core.py next to it: " + str(exc) + "\n"
47
- " The validator ships as TWO files since the multi-domain core.\n"
48
- " Copy both, or run sdlc_core.py directly.\n")
49
- sys.exit(1)
50
-
51
- # This distribution IS the marketing lens. The default docs root is its own; the
52
- # core still resolves `--docs-dir` and the env seam on top of it, so a project that
53
- # has migrated to the family's single `ai_docs/` tree is reachable from here too.
54
- DOMAIN = "marketing"
55
- MKT_DOCS_DIR = "mkt_docs"
56
- sdlc_core.set_entry_point(DOMAIN, provides=("marketing",), script="mkt_check.py")
57
- sdlc_core.set_docs_dir(MKT_DOCS_DIR)
58
-
59
- sdlc_core.set_profile(
60
- skill_name="mkt-agentic-sdlc",
61
- unit_noun="engagement",
62
- support_files=("templates.md", "frameworks.md", "research.md", "elicitation.md",
63
- "review.md", "routing.md", "guides.md", "vision.md", "hybrid.md", "dispatch.md",
64
- "ENFORCEMENT.md"),
65
- capabilities=(
66
- # spine
67
- "triage", "write_triggers", "workstream_registry", "vision_gate",
68
- "design_review_gate", "guide_router", "worktree_hygiene",
69
- # marketing overlay
70
- "subagent_dispatch", "question_discipline",
71
- ),
72
- remind_line=(
73
- "first decide if this skill governs the work; if yes, LOAD it once -- an "
74
- "unread protocol cannot be applied. Declare the engagement level and the "
75
- "guide-router verdict before acting. Every claim about the market, the "
76
- "audience or a competitor carries its evidence -- an unsourced assertion "
77
- "is a finding, not a premise. Replay governing text -- a brief, a "
78
- "positioning line, a template -- against the case that motivated it."
79
- ),
80
- # The strategy is reviewed before any tactic is executed: nine phases, but the
81
- # ordering the gate cares about is the same one every domain owes.
82
- design_gate_between=("### 6. Strategy", "### 8. Action"),
83
- )
84
-
85
- # The subcommands this overlay implements itself. Anything else on the command
86
- # line is handed straight to the shared core, so a spine command this file never
87
- # heard of (as `migrate` once was) cannot be silently dropped by a stale list here.
88
- OVERLAY_COMMANDS = ("check", "validate", "ledger", "budget", "funnel", "trace", "index")
89
-
90
- CANONICAL_DIRS = ("vision", "strategy", "tactics", "deliverables")
91
- VALID_STATUS = ("CURRENT", "SUPERSEDED", "DRAFT", "DEPRECATED")
92
- LEDGER_CLASSES = ("FACT", "BENCHMARK", "ASSUMPTION")
93
- CONFIDENCE_VALUES = ("HIGH", "MED", "MEDIUM", "LOW")
94
- EV_REF = re.compile(r"\[(EV-\d+)\]")
95
- # A source that points INSIDE this engagement instead of at the world: a document of
96
- # OUR OWN under a canonical dir, or the "see X" form. Only FACT can carry it today,
97
- # because FACT is the one class the URL rule does not reach.
98
- INTERNAL_SOURCE_RE = re.compile(
99
- r"(?:\b(?:mkt_docs|ai_docs|research|strategy|tactics|deliverables|vision)/\S*\.md\b"
100
- r"|\b(?:see|vedi|cfr\.?)\s)", re.IGNORECASE)
101
- # The two origins research.md sanctions for a FACT. A cell that opens by naming the
102
- # client is exempt: their own primary data may well be a Markdown file they handed
103
- # over, and telling its author to "reclassify as BENCHMARK and add a URL" would be
104
- # advice to misclassify.
105
- CLIENT_ORIGIN_RE = re.compile(
106
- r"^\s*(?:user|client|owner|utente|cliente|titolare|proprietario)\b", re.IGNORECASE)
107
- OBJECTIVE_HEADING = re.compile(r"^###\s+(O\d+)\b", re.MULTILINE)
108
- OBJECTIVE_TOKEN = re.compile(r"\bO\d+\b")
109
- EXAMPLE_MARKER = "(example)"
110
-
111
- BUDGET_TOLERANCE = 0.01 # ±1% on allocation sum
112
- FUNNEL_TOLERANCE = 0.05 # ±5% on recomputed funnel cells
113
-
114
-
115
- class Report:
116
- def __init__(self):
117
- self.errors = []
118
- self.warnings = []
119
-
120
- def error(self, msg):
121
- self.errors.append(msg)
122
-
123
- def warn(self, msg):
124
- self.warnings.append(msg)
125
-
126
- def merge(self, other):
127
- self.errors.extend(other.errors)
128
- self.warnings.extend(other.warnings)
129
-
130
- def print(self, title):
131
- for w in self.warnings:
132
- print(f"[WARN] {title}: {w}")
133
- for e in self.errors:
134
- print(f"[ERROR] {title}: {e}")
135
-
136
- def exit_code(self, strict=False):
137
- if self.errors:
138
- return 1
139
- if strict and self.warnings:
140
- return 1
141
- return 0
142
-
143
-
144
- # ---------------------------------------------------------------- helpers
145
-
146
- def find_root(root_arg, docs_dir=None):
147
- """Resolve the project root: --root wins, else walk up from cwd.
148
-
149
- The docs-root NAME comes from the core (`--docs-dir`, then the env seam, then
150
- discovery, then this distribution's `mkt_docs`), so one project mid-migration
151
- cannot be validated half under one root and half under the other.
152
- """
153
- if docs_dir:
154
- sdlc_core.set_docs_dir(docs_dir)
155
- name = sdlc_core.docs_dir()
156
- if root_arg:
157
- root = Path(root_arg).resolve()
158
- return root if (root / name).is_dir() else None
159
- cur = Path.cwd().resolve()
160
- for candidate in (cur, *cur.parents):
161
- if (candidate / name).is_dir():
162
- return candidate
163
- return None
164
-
165
-
166
- def read_text(path):
167
- return path.read_text(encoding="utf-8", errors="replace")
168
-
169
-
170
- def load_frontmatter(text):
171
- """Return (dict, body). Tolerant: missing frontmatter -> ({}, text)."""
172
- if not text.startswith("---"):
173
- return {}, text
174
- lines = text.split("\n")
175
- fm = {}
176
- for i, line in enumerate(lines[1:], start=1):
177
- if line.strip() == "---":
178
- return fm, "\n".join(lines[i + 1:])
179
- m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
180
- if m:
181
- fm[m.group(1).strip()] = m.group(2).strip()
182
- return {}, text # unterminated frontmatter: treat as body
183
-
184
-
185
- def parse_num(cell):
186
- """Parse a table-cell number: strips currency symbols, spaces, commas,
187
- percent signs and [EV-nn] refs; supports k/M suffix. Returns float or None."""
188
- if cell is None:
189
- return None
190
- s = EV_REF.sub("", cell)
191
- s = re.sub(r"[^\dkKmM.,%-]", "", s.strip())
192
- s = s.replace(",", "").replace("%", "")
193
- if not s:
194
- return None
195
- mult = 1.0
196
- if s and s[-1] in "kK":
197
- mult, s = 1_000.0, s[:-1]
198
- elif s and s[-1] in "mM":
199
- mult, s = 1_000_000.0, s[:-1]
200
- try:
201
- return float(s) * mult
202
- except ValueError:
203
- return None
204
-
205
-
206
- def parse_tables(text):
207
- """Return every markdown table as (header_cells_lower, rows) where each
208
- row is a dict {header_lower: cell}."""
209
- tables = []
210
- lines = text.split("\n")
211
- i = 0
212
- while i < len(lines):
213
- line = lines[i].strip()
214
- if line.startswith("|") and i + 1 < len(lines) \
215
- and re.match(r"^\|[\s\-:|]+\|$", lines[i + 1].strip()):
216
- headers = [h.strip().lower() for h in line.strip("|").split("|")]
217
- rows = []
218
- j = i + 2
219
- while j < len(lines) and lines[j].strip().startswith("|"):
220
- cells = [c.strip() for c in lines[j].strip().strip("|").split("|")]
221
- if len(cells) >= len(headers) - 1: # tolerate ragged tail
222
- row = {}
223
- for k, h in enumerate(headers):
224
- row[h] = cells[k] if k < len(cells) else ""
225
- rows.append(row)
226
- j += 1
227
- tables.append((headers, rows))
228
- i = j
229
- else:
230
- i += 1
231
- return tables
232
-
233
-
234
- def find_table(text, required_cols):
235
- """First table whose header contains every required column (contains-match)."""
236
- req = [c.lower() for c in required_cols]
237
- for headers, rows in parse_tables(text):
238
- if all(any(r in h for h in headers) for r in req):
239
- return headers, rows
240
- # exact-name pass for short names like 'kpi'
241
- if all(r in headers for r in req):
242
- return headers, rows
243
- return None, None
244
-
245
-
246
- def col(row, name):
247
- """Fetch a cell by contains-match on the header name."""
248
- name = name.lower()
249
- if name in row:
250
- return row[name]
251
- for h, v in row.items():
252
- if name in h:
253
- return v
254
- return None
255
-
256
-
257
- def is_example_row(row):
258
- return any(EXAMPLE_MARKER in (v or "") for v in row.values())
259
-
260
-
261
- def close_enough(stated, computed, tol):
262
- if stated is None or computed is None:
263
- return True # missing cells reported separately
264
- if computed == 0:
265
- return abs(stated) < 1e-9
266
- return abs(stated - computed) / abs(computed) <= tol
267
-
268
-
269
- def canonical_files(root):
270
- docs = sdlc_core.ai_path(root)
271
- for d in CANONICAL_DIRS:
272
- base = docs / d
273
- if base.is_dir():
274
- for p in sorted(base.rglob("*.md")):
275
- yield p
276
-
277
-
278
- def all_md_files(root):
279
- docs = sdlc_core.ai_path(root)
280
- for p in sorted(docs.rglob("*.md")):
281
- yield p
282
-
283
-
284
- # ---------------------------------------------------------------- index
285
-
286
- def build_index(root):
287
- docs = sdlc_core.ai_path(root)
288
- lines = [
289
- "# mkt_docs INDEX (generated by mkt_check.py index -- do not edit by hand)",
290
- "",
291
- "| Path | Description | Status |",
292
- "|---|---|---|",
293
- ]
294
- for p in canonical_files(root):
295
- if p.name == "INDEX.md":
296
- continue
297
- fm, body = load_frontmatter(read_text(p))
298
- desc = fm.get("description", "")
299
- status = fm.get("status", "")
300
- if not desc:
301
- h1 = re.search(r"^#\s+(.+)$", body, re.MULTILINE)
302
- desc = h1.group(1).strip() if h1 else ""
303
- rel = p.relative_to(docs).as_posix()
304
- lines.append(f"| {rel} | {desc} | {status} |")
305
- return "\n".join(lines) + "\n"
306
-
307
-
308
- def cmd_index(root):
309
- content = build_index(root)
310
- out = sdlc_core.ai_path(root) / "INDEX.md"
311
- out.write_text(content, encoding="utf-8")
312
- print(f"[OK] wrote {out.relative_to(root).as_posix()}")
313
- # F-028: the workstream registry is generated here too. This entry point does
314
- # not reuse the spine's cmd_index, so wiring it explicitly is what keeps the
315
- # doctrine ("generated by mkt_check.py index") from being false in this lens.
316
- return sdlc_core.rc_registry(root)
317
-
318
-
319
- # ---------------------------------------------------------------- validate
320
-
321
- def run_validate(root):
322
- rep = Report()
323
- docs = sdlc_core.ai_path(root)
324
- # F-028: the generated registry against its HANDOFF_*.md sources. Gated on
325
- # source presence, so a project that has not converted sees nothing new.
326
- workstreams = sdlc_core.list_workstreams(root)
327
- if workstreams:
328
- hand = docs / "audit" / "handoff.md"
329
- if not hand.is_file():
330
- rep.error("audit/handoff.md missing while HANDOFF_*.md sources exist: "
331
- "run 'mkt_check.py index'")
332
- elif sdlc_core.norm_text(read_text(hand)) != sdlc_core.norm_text(sdlc_core.build_registry(root)):
333
- rep.error("audit/handoff.md not aligned with its HANDOFF_*.md sources: "
334
- "run 'mkt_check.py index'. A merge resolved by hand is exactly "
335
- "what this catches")
336
- if len(workstreams) > sdlc_core.REGISTRY_CAP:
337
- rep.warn(f"{len(workstreams)} open workstreams: the registry is meant to "
338
- f"stay under {sdlc_core.REGISTRY_CAP}")
339
- known = {p.name for p in docs.rglob("*.md")}
340
- for p in canonical_files(root):
341
- if p.name == "INDEX.md":
342
- continue
343
- rel = p.relative_to(docs).as_posix()
344
- fm, _ = load_frontmatter(read_text(p))
345
- status = fm.get("status", "")
346
- if not status:
347
- rep.warn(f"{rel}: missing frontmatter 'status'")
348
- elif status.upper() not in VALID_STATUS:
349
- rep.warn(f"{rel}: invalid status '{status}' (expected {'|'.join(VALID_STATUS)})")
350
- sup = fm.get("supersedes", "")
351
- if sup and Path(sup).name not in known:
352
- rep.warn(f"{rel}: supersedes '{sup}' which does not exist under mkt_docs/")
353
- index_path = docs / "INDEX.md"
354
- if not index_path.exists():
355
- rep.warn("mkt_docs/INDEX.md missing -- generate with 'mkt_check.py index'")
356
- elif read_text(index_path) != build_index(root):
357
- rep.warn("mkt_docs/INDEX.md stale -- regenerate with 'mkt_check.py index'")
358
- return rep
359
-
360
-
361
- # ---------------------------------------------------------------- ledger
362
-
363
- def load_ledger(root):
364
- """Return (rows, report). Rows: list of dicts with id/claim/class/... keys."""
365
- rep = Report()
366
- path = sdlc_core.ai_path(root) / "research" / "evidence_ledger.md"
367
- if not path.exists():
368
- rep.error("research/evidence_ledger.md missing")
369
- return [], rep
370
- headers, rows = find_table(read_text(path), ["id", "claim", "class", "source"])
371
- if rows is None:
372
- rep.error("evidence_ledger.md: ledger table not found "
373
- "(need columns ID | Claim | Class | ... | Source | ...)")
374
- return [], rep
375
- entries = []
376
- seen = set()
377
- for row in rows:
378
- rid = (col(row, "id") or "").strip()
379
- if not rid:
380
- continue
381
- if is_example_row(row):
382
- continue
383
- if not re.fullmatch(r"EV-\d+", rid):
384
- rep.error(f"ledger: bad ID '{rid}' (expected EV-<n>)")
385
- continue
386
- if rid in seen:
387
- rep.error(f"ledger: duplicate ID {rid}")
388
- seen.add(rid)
389
- entries.append(row)
390
- return entries, rep
391
-
392
-
393
- def run_ledger(root):
394
- entries, rep = load_ledger(root)
395
- ids = set()
396
- for row in entries:
397
- rid = col(row, "id").strip()
398
- ids.add(rid)
399
- cls = (col(row, "class") or "").strip().upper()
400
- source = (col(row, "source") or "").strip()
401
- date = (col(row, "date") or "").strip()
402
- confidence = (col(row, "confidence") or "").strip().upper()
403
- if cls not in LEDGER_CLASSES:
404
- rep.error(f"ledger {rid}: class '{cls}' invalid (FACT|BENCHMARK|ASSUMPTION)")
405
- continue
406
- if cls == "BENCHMARK":
407
- if "http" not in source.lower():
408
- rep.error(f"ledger {rid}: BENCHMARK without a source URL")
409
- if not date:
410
- rep.error(f"ledger {rid}: BENCHMARK without a date")
411
- if cls == "FACT" and not source:
412
- rep.error(f"ledger {rid}: FACT without a source -- name the origin "
413
- "('user, Wave 1' or the client's data file)")
414
- if (cls == "FACT" and source and not CLIENT_ORIGIN_RE.match(source)
415
- and INTERNAL_SOURCE_RE.search(source)):
416
- # FACT means "the client told me" or "the client's own primary data" — the
417
- # one class that legitimately has no URL. Pointing it at another document
418
- # of this engagement is how a researched observation gets in without the
419
- # URL its class would owe: the row cites a file that cites the source, the
420
- # validator resolves nothing, and "see VOC.md" is exactly the form the
421
- # BENCHMARK rule rejects. Observed in a cold-agent field test, 2026-08-02.
422
- rep.error(f"ledger {rid}: FACT sourced to an internal document ('{source}') -- "
423
- "a FACT is what the client stated or their own primary data; an "
424
- "observation gathered by research is a BENCHMARK and owes its URL")
425
- if cls == "ASSUMPTION":
426
- if confidence not in CONFIDENCE_VALUES:
427
- rep.error(f"ledger {rid}: ASSUMPTION without confidence (HIGH|MED|LOW)")
428
- value = (col(row, "value") or "").strip()
429
- if value and not re.search(r"\d\s*[-–—]\s*\d|\bto\b", value):
430
- rep.warn(f"ledger {rid}: ASSUMPTION value looks like a point estimate, expected a range")
431
- # reference resolution across every md file (ledger file itself excluded)
432
- referenced = set()
433
- ledger_path = sdlc_core.ai_path(root) / "research" / "evidence_ledger.md"
434
- for p in all_md_files(root):
435
- if p == ledger_path:
436
- continue
437
- text = read_text(p)
438
- for line in text.split("\n"):
439
- if EXAMPLE_MARKER in line:
440
- continue
441
- for m in EV_REF.finditer(line):
442
- referenced.add(m.group(1))
443
- if m.group(1) not in ids:
444
- rel = p.relative_to(sdlc_core.ai_path(root)).as_posix()
445
- rep.error(f"{rel}: reference [{m.group(1)}] not found in the ledger")
446
- for rid in sorted(ids - referenced, key=lambda x: int(x.split("-")[1])):
447
- rep.warn(f"ledger {rid}: never referenced by any document")
448
- return rep
449
-
450
-
451
- # ---------------------------------------------------------------- budget
452
-
453
- def default_tactical_files(root, file_arg):
454
- if file_arg:
455
- p = Path(file_arg)
456
- if not p.is_absolute():
457
- p = root / file_arg
458
- return [p] if p.exists() else []
459
- tactics = sdlc_core.ai_path(root) / "tactics"
460
- files = []
461
- if (tactics / "TACTICAL_PLAN.md").exists():
462
- files.append(tactics / "TACTICAL_PLAN.md")
463
- if tactics.is_dir():
464
- files.extend(sorted(tactics.glob("CAMPAIGN_*.md")))
465
- return files
466
-
467
-
468
- def run_budget(root, file_arg=None):
469
- rep = Report()
470
- files = default_tactical_files(root, file_arg)
471
- if not files:
472
- rep.warn("no tactical plan found (tactics/TACTICAL_PLAN.md) -- budget check skipped")
473
- return rep
474
- for path in files:
475
- rel = path.name
476
- text = read_text(path)
477
- m = re.search(r"^Total budget:\s*(.+)$", text, re.MULTILINE | re.IGNORECASE)
478
- if not m:
479
- rep.error(f"{rel}: 'Total budget:' line missing")
480
- continue
481
- total = parse_num(m.group(1))
482
- if total is None or total <= 0:
483
- rep.error(f"{rel}: cannot parse Total budget '{m.group(1).strip()}'")
484
- continue
485
- headers, rows = find_table(text, ["channel", "budget", "share"])
486
- if rows is None:
487
- rep.error(f"{rel}: Budget Allocation table not found (Channel | Budget | Share)")
488
- continue
489
- rows = [r for r in rows if not is_example_row(r)]
490
- if not rows:
491
- rep.error(f"{rel}: Budget Allocation table has no rows")
492
- continue
493
- alloc = 0.0
494
- for r in rows:
495
- v = parse_num(col(r, "budget"))
496
- if v is None:
497
- rep.error(f"{rel}: unparseable budget cell '{col(r, 'budget')}' "
498
- f"for channel '{col(r, 'channel')}'")
499
- else:
500
- alloc += v
501
- if not close_enough(alloc, total, BUDGET_TOLERANCE):
502
- rep.error(f"{rel}: allocations sum to {alloc:g}, Total budget is {total:g} "
503
- f"(tolerance 1%)")
504
- return rep
505
-
506
-
507
- # ---------------------------------------------------------------- funnel
508
-
509
- FUNNEL_CHAIN = (
510
- # (stated_col, compute, label)
511
- ("clicks", lambda b, cpc, cvr, close, r: b / cpc if cpc else None, "Budget/CPC"),
512
- ("leads", lambda b, cpc, cvr, close, r: (r["clicks"] * cvr / 100.0)
513
- if (r["clicks"] is not None and cvr is not None) else None, "Clicks*CVR%"),
514
- ("customers", lambda b, cpc, cvr, close, r: (r["leads"] * close / 100.0)
515
- if (r["leads"] is not None and close is not None) else None, "Leads*Close%"),
516
- ("cac", lambda b, cpc, cvr, close, r: (b / r["customers"])
517
- if (r["customers"] not in (None, 0) and b is not None) else None, "Budget/Customers"),
518
- )
519
-
520
-
521
- def run_funnel(root, file_arg=None):
522
- rep = Report()
523
- files = default_tactical_files(root, file_arg)
524
- if not files:
525
- rep.warn("no tactical plan found -- funnel check skipped")
526
- return rep
527
- for path in files:
528
- rel = path.name
529
- text = read_text(path)
530
- headers, rows = find_table(text, ["channel", "budget", "cpc", "cvr", "customers", "cac"])
531
- if rows is None:
532
- rep.error(f"{rel}: Funnel Model table not found "
533
- "(Channel | Budget | CPC | Clicks | CVR % | Leads | Close % | Customers | CAC)")
534
- continue
535
- rows = [r for r in rows if not is_example_row(r)]
536
- if not rows:
537
- rep.error(f"{rel}: Funnel Model table has no rows")
538
- continue
539
- for r in rows:
540
- channel = col(r, "channel") or "?"
541
- budget = parse_num(col(r, "budget"))
542
- cpc = parse_num(col(r, "cpc"))
543
- cvr = parse_num(col(r, "cvr"))
544
- close = parse_num(col(r, "close"))
545
- stated = {
546
- "clicks": parse_num(col(r, "clicks")),
547
- "leads": parse_num(col(r, "leads")),
548
- "customers": parse_num(col(r, "customers")),
549
- "cac": parse_num(col(r, "cac")),
550
- }
551
- if budget is None or cpc is None:
552
- rep.error(f"{rel} [{channel}]: Budget/CPC missing or unparseable")
553
- continue
554
- # verify each stated cell against computation from STATED upstream
555
- # cells (so a single wrong cell does not cascade into four errors)
556
- chain_inputs = dict(stated)
557
- for name, compute, label in FUNNEL_CHAIN:
558
- computed = compute(budget, cpc, cvr, close, chain_inputs)
559
- if stated[name] is None:
560
- rep.error(f"{rel} [{channel}]: '{name}' cell missing")
561
- continue
562
- if computed is None:
563
- continue # upstream missing, already reported
564
- if not close_enough(stated[name], computed, FUNNEL_TOLERANCE):
565
- rep.error(f"{rel} [{channel}]: {name}={stated[name]:g} but {label} "
566
- f"= {computed:g} (tolerance 5%)")
567
- return rep
568
-
569
-
570
- # ---------------------------------------------------------------- trace
571
-
572
- def run_trace(root):
573
- rep = Report()
574
- docs = sdlc_core.ai_path(root)
575
- obj_path = docs / "strategy" / "OBJECTIVES.md"
576
- if not obj_path.exists():
577
- rep.warn("strategy/OBJECTIVES.md missing -- trace check skipped")
578
- return rep
579
- objectives = set(OBJECTIVE_HEADING.findall(read_text(obj_path)))
580
- if not objectives:
581
- rep.error("OBJECTIVES.md: no '### O<n> --...' headings found")
582
- return rep
583
-
584
- tactical = docs / "tactics" / "TACTICAL_PLAN.md"
585
- served = set()
586
- if tactical.exists():
587
- headers, rows = find_table(read_text(tactical), ["channel", "objective", "kpi"])
588
- if rows is None:
589
- rep.error("TACTICAL_PLAN.md: Channel Plan table not found "
590
- "(Channel | Objective | KPI | Budget | Owner)")
591
- else:
592
- for r in rows:
593
- if is_example_row(r):
594
- continue
595
- tokens = set(OBJECTIVE_TOKEN.findall(col(r, "objective") or ""))
596
- if not tokens:
597
- rep.error(f"TACTICAL_PLAN.md [{col(r, 'channel')}]: no objective (O<n>) named")
598
- for t in tokens:
599
- if t not in objectives:
600
- rep.error(f"TACTICAL_PLAN.md [{col(r, 'channel')}]: objective {t} "
601
- "not defined in OBJECTIVES.md")
602
- served |= tokens & objectives
603
- if not (col(r, "kpi") or "").strip():
604
- rep.error(f"TACTICAL_PLAN.md [{col(r, 'channel')}]: KPI cell empty")
605
- for o in sorted(objectives - served):
606
- rep.warn(f"objective {o}: no tactic serves it in TACTICAL_PLAN.md")
607
- else:
608
- rep.warn("tactics/TACTICAL_PLAN.md missing -- tactic trace skipped")
609
-
610
- measure = docs / "tactics" / "MEASUREMENT_PLAN.md"
611
- if measure.exists():
612
- headers, rows = find_table(read_text(measure), ["objective", "kpi", "target"])
613
- if rows is None:
614
- rep.error("MEASUREMENT_PLAN.md: KPI table not found (Objective | KPI | Target | ...)")
615
- else:
616
- measured = set()
617
- for r in rows:
618
- if is_example_row(r):
619
- continue
620
- measured |= set(OBJECTIVE_TOKEN.findall(col(r, "objective") or ""))
621
- for o in sorted(objectives - measured):
622
- rep.error(f"objective {o}: no KPI row in MEASUREMENT_PLAN.md")
623
- else:
624
- rep.warn("tactics/MEASUREMENT_PLAN.md missing -- KPI trace skipped")
625
- return rep
626
-
627
-
628
- # ---------------------------------------------------------------- check
629
-
630
- def run_check(root):
631
- rep = Report()
632
- for title, fn in (("validate", run_validate), ("ledger", run_ledger),
633
- ("budget", run_budget), ("funnel", run_funnel),
634
- ("trace", run_trace)):
635
- sub = fn(root)
636
- sub.print(title)
637
- rep.merge(sub)
638
- return rep
639
-
640
-
641
- # ---------------------------------------------------------------- main
642
-
643
- # --- portable checks this distribution exposes -------------------------------
644
- # The marketing arithmetic, offered to documents owned by ANOTHER domain: a
645
- # distilled spec that carries a budget table can import `marketing.budget` and have
646
- # it actually checked, instead of the table sitting in a document whose own
647
- # validator has no opinion about it (UC8). Imported checks may only ADD findings.
648
-
649
- def _report_findings(rep):
650
- return ([("error", m) for m in rep.errors]
651
- + [("warning", m) for m in rep.warnings])
652
-
653
-
654
- @sdlc_core.portable_check("marketing.ledger")
655
- def _check_ledger(rel, meta, text):
656
- root = sdlc_core.find_project_root()
657
- return _report_findings(run_ledger(root)) if root else []
658
-
659
-
660
- @sdlc_core.portable_check("marketing.budget")
661
- def _check_budget(rel, meta, text):
662
- root = sdlc_core.find_project_root()
663
- return _report_findings(run_budget(root)) if root else []
664
-
665
-
666
- @sdlc_core.portable_check("marketing.funnel")
667
- def _check_funnel(rel, meta, text):
668
- root = sdlc_core.find_project_root()
669
- return _report_findings(run_funnel(root)) if root else []
670
-
671
-
672
- def main(argv=None):
673
- argv = list(sys.argv[1:] if argv is None else argv)
674
- # Spine commands are the core's, identical in every distribution: hand over
675
- # every subcommand this overlay does not explicitly intercept, untouched,
676
- # rather than keeping a hand-copied list of spine names here -- which is how
677
- # `migrate` got dropped while SKILL.md kept promising it.
678
- if argv and not argv[0].startswith("-") and argv[0] not in OVERLAY_COMMANDS:
679
- return sdlc_core.main(argv)
680
-
681
- parser = argparse.ArgumentParser(prog="mkt_check.py", description=__doc__)
682
- parser.add_argument("command", choices=list(OVERLAY_COMMANDS))
683
- parser.add_argument("file", nargs="?", default=None,
684
- help="optional target file for budget/funnel")
685
- parser.add_argument("--root", default=None, help="project root (contains the docs root)")
686
- parser.add_argument("--docs-dir", dest="docs_dir", default=None,
687
- help="name of the documentation root (default: mkt_docs; "
688
- "use --docs-dir ai_docs on a project that has migrated "
689
- "to the family's single tree)")
690
- parser.add_argument("--strict", action="store_true",
691
- help="warnings also fail the exit code")
692
- args = parser.parse_args(argv)
693
-
694
- # Resolve the docs root fresh on EVERY invocation, from this distribution's own
695
- # default: the core keeps it in module state, and an entry point that inherited
696
- # whatever a previous call left behind would be answering the wrong question.
697
- root = find_root(args.root, args.docs_dir or MKT_DOCS_DIR)
698
- if root is None:
699
- print(f"[ERROR] no {sdlc_core.docs_dir()}/ directory found "
700
- "(use --root or run mkt-sdlc-init)")
701
- return 2
702
-
703
- if args.command == "index":
704
- return cmd_index(root)
705
-
706
- runners = {
707
- "validate": lambda: run_validate(root),
708
- "ledger": lambda: run_ledger(root),
709
- "budget": lambda: run_budget(root, args.file),
710
- "funnel": lambda: run_funnel(root, args.file),
711
- "trace": lambda: run_trace(root),
712
- }
713
- if args.command == "check":
714
- rep = run_check(root)
715
- else:
716
- rep = runners[args.command]()
717
- rep.print(args.command)
718
-
719
- code = rep.exit_code(strict=args.strict)
720
- if code == 0:
721
- print(f"[OK] {args.command} clean "
722
- f"({len(rep.warnings)} warning(s))" if rep.warnings else f"[OK] {args.command} clean")
723
- else:
724
- print(f"[FAIL] {args.command}: {len(rep.errors)} error(s), {len(rep.warnings)} warning(s)")
725
- if args.command == "check":
726
- # This overlay REPLACES the spine's cmd_check, so the F-041 wiring note
727
- # must be re-attached here: it is check-layer behaviour, whoever owns
728
- # the check (fail-open and informational -- never the exit code).
729
- sdlc_core.print_orient_hook_note(root)
730
- return code
731
-
732
-
733
- if __name__ == "__main__":
734
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ """Domain entry point with shared project memory.
3
+
4
+ Ship this file, sdlc_core.py and knowledge.py together. The core alone omits
5
+ knowledge integrity and this entry point\'s domain-specific behavior.
6
+ """
7
+
8
+ import argparse
9
+ import re
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
14
+
15
+ try:
16
+ import sdlc_core
17
+ except ImportError as exc: # pragma: no cover - exercised by the copied-file test
18
+ sys.stderr.write(
19
+ "[ERROR] mkt_check.py cannot find sdlc_core.py next to it: " + str(exc) + "\n"
20
+ " The validator needs entry point, sdlc_core.py and knowledge.py.\n"
21
+ " Copy all three together; core alone omits memory and overlay checks.\n")
22
+ sys.exit(1)
23
+
24
+ # This distribution IS the marketing lens. The default docs root is its own; the
25
+ # core still resolves `--docs-dir` and the env seam on top of it, so a project that
26
+ # has migrated to the family's single `ai_docs/` tree is reachable from here too.
27
+ DOMAIN = "marketing"
28
+ import knowledge
29
+ MKT_DOCS_DIR = "mkt_docs"
30
+ sdlc_core.set_entry_point(DOMAIN, provides=("marketing",), script="mkt_check.py")
31
+ sdlc_core.set_docs_dir(MKT_DOCS_DIR)
32
+
33
+ sdlc_core.set_profile(
34
+ skill_name="mkt-agentic-sdlc",
35
+ unit_noun="engagement",
36
+ support_files=("templates.md", "frameworks.md", "research.md", "elicitation.md",
37
+ "review.md", "routing.md", "guides.md", "vision.md", "hybrid.md", "dispatch.md",
38
+ "ENFORCEMENT.md", "memory.md"),
39
+ capabilities=(
40
+ # spine
41
+ "triage", "write_triggers", "workstream_registry", "vision_gate",
42
+ "design_review_gate", "guide_router", "worktree_hygiene",
43
+ # marketing overlay
44
+ "subagent_dispatch", "question_discipline",
45
+ ),
46
+ remind_line=(
47
+ "first decide if this skill governs the work; if yes, LOAD it once -- an "
48
+ "unread protocol cannot be applied. Declare the engagement level and the "
49
+ "guide-router verdict before acting. Every claim about the market, the "
50
+ "audience or a competitor carries its evidence -- an unsourced assertion "
51
+ "is a finding, not a premise. Replay governing text -- a brief, a "
52
+ "positioning line, a template -- against the case that motivated it."
53
+ ),
54
+ # The strategy is reviewed before any tactic is executed: nine phases, but the
55
+ # ordering the gate cares about is the same one every domain owes.
56
+ design_gate_between=("### 6. Strategy", "### 8. Action"),
57
+ )
58
+
59
+ # The subcommands this overlay implements itself. Anything else on the command
60
+ # line is handed straight to the shared core, so a spine command this file never
61
+ # heard of (as `migrate` once was) cannot be silently dropped by a stale list here.
62
+ OVERLAY_COMMANDS = ("check", "validate", "ledger", "budget", "funnel", "trace", "index")
63
+
64
+ CANONICAL_DIRS = ("vision", "strategy", "tactics", "deliverables")
65
+ VALID_STATUS = ("CURRENT", "SUPERSEDED", "DRAFT", "DEPRECATED")
66
+ LEDGER_CLASSES = ("FACT", "BENCHMARK", "ASSUMPTION")
67
+ CONFIDENCE_VALUES = ("HIGH", "MED", "MEDIUM", "LOW")
68
+ EV_REF = re.compile(r"\[(EV-\d+)\]")
69
+ # A source that points INSIDE this engagement instead of at the world: a document of
70
+ # OUR OWN under a canonical dir, or the "see X" form. Only FACT can carry it today,
71
+ # because FACT is the one class the URL rule does not reach.
72
+ INTERNAL_SOURCE_RE = re.compile(
73
+ r"(?:\b(?:mkt_docs|ai_docs|research|strategy|tactics|deliverables|vision)/\S*\.md\b"
74
+ r"|\b(?:see|vedi|cfr\.?)\s)", re.IGNORECASE)
75
+ # The two origins research.md sanctions for a FACT. A cell that opens by naming the
76
+ # client is exempt: their own primary data may well be a Markdown file they handed
77
+ # over, and telling its author to "reclassify as BENCHMARK and add a URL" would be
78
+ # advice to misclassify.
79
+ CLIENT_ORIGIN_RE = re.compile(
80
+ r"^\s*(?:user|client|owner|utente|cliente|titolare|proprietario)\b", re.IGNORECASE)
81
+ OBJECTIVE_HEADING = re.compile(r"^###\s+(O\d+)\b", re.MULTILINE)
82
+ OBJECTIVE_TOKEN = re.compile(r"\bO\d+\b")
83
+ EXAMPLE_MARKER = "(example)"
84
+
85
+ BUDGET_TOLERANCE = 0.01 # ±1% on allocation sum
86
+ FUNNEL_TOLERANCE = 0.05 # ±5% on recomputed funnel cells
87
+
88
+
89
+ class Report:
90
+ def __init__(self):
91
+ self.errors = []
92
+ self.warnings = []
93
+
94
+ def error(self, msg):
95
+ self.errors.append(msg)
96
+
97
+ def warn(self, msg):
98
+ self.warnings.append(msg)
99
+
100
+ def merge(self, other):
101
+ self.errors.extend(other.errors)
102
+ self.warnings.extend(other.warnings)
103
+
104
+ def print(self, title):
105
+ for w in self.warnings:
106
+ print(f"[WARN] {title}: {w}")
107
+ for e in self.errors:
108
+ print(f"[ERROR] {title}: {e}")
109
+
110
+ def exit_code(self, strict=False):
111
+ if self.errors:
112
+ return 1
113
+ if strict and self.warnings:
114
+ return 1
115
+ return 0
116
+
117
+
118
+ # ---------------------------------------------------------------- helpers
119
+
120
+ def find_root(root_arg, docs_dir=None):
121
+ """Resolve the project root: --root wins, else walk up from cwd.
122
+
123
+ The docs-root NAME comes from the core (`--docs-dir`, then the env seam, then
124
+ discovery, then this distribution's `mkt_docs`), so one project mid-migration
125
+ cannot be validated half under one root and half under the other.
126
+ """
127
+ if docs_dir:
128
+ sdlc_core.set_docs_dir(docs_dir)
129
+ name = sdlc_core.docs_dir()
130
+ if root_arg:
131
+ root = Path(root_arg).resolve()
132
+ return root if (root / name).is_dir() else None
133
+ cur = Path.cwd().resolve()
134
+ for candidate in (cur, *cur.parents):
135
+ if (candidate / name).is_dir():
136
+ return candidate
137
+ return None
138
+
139
+
140
+ def read_text(path):
141
+ return path.read_text(encoding="utf-8", errors="replace")
142
+
143
+
144
+ def load_frontmatter(text):
145
+ """Return (dict, body). Tolerant: missing frontmatter -> ({}, text)."""
146
+ if not text.startswith("---"):
147
+ return {}, text
148
+ lines = text.split("\n")
149
+ fm = {}
150
+ for i, line in enumerate(lines[1:], start=1):
151
+ if line.strip() == "---":
152
+ return fm, "\n".join(lines[i + 1:])
153
+ m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
154
+ if m:
155
+ fm[m.group(1).strip()] = m.group(2).strip()
156
+ return {}, text # unterminated frontmatter: treat as body
157
+
158
+
159
+ def parse_num(cell):
160
+ """Parse a table-cell number: strips currency symbols, spaces, commas,
161
+ percent signs and [EV-nn] refs; supports k/M suffix. Returns float or None."""
162
+ if cell is None:
163
+ return None
164
+ s = EV_REF.sub("", cell)
165
+ s = re.sub(r"[^\dkKmM.,%-]", "", s.strip())
166
+ s = s.replace(",", "").replace("%", "")
167
+ if not s:
168
+ return None
169
+ mult = 1.0
170
+ if s and s[-1] in "kK":
171
+ mult, s = 1_000.0, s[:-1]
172
+ elif s and s[-1] in "mM":
173
+ mult, s = 1_000_000.0, s[:-1]
174
+ try:
175
+ return float(s) * mult
176
+ except ValueError:
177
+ return None
178
+
179
+
180
+ def parse_tables(text):
181
+ """Return every markdown table as (header_cells_lower, rows) where each
182
+ row is a dict {header_lower: cell}."""
183
+ tables = []
184
+ lines = text.split("\n")
185
+ i = 0
186
+ while i < len(lines):
187
+ line = lines[i].strip()
188
+ if line.startswith("|") and i + 1 < len(lines) \
189
+ and re.match(r"^\|[\s\-:|]+\|$", lines[i + 1].strip()):
190
+ headers = [h.strip().lower() for h in line.strip("|").split("|")]
191
+ rows = []
192
+ j = i + 2
193
+ while j < len(lines) and lines[j].strip().startswith("|"):
194
+ cells = [c.strip() for c in lines[j].strip().strip("|").split("|")]
195
+ if len(cells) >= len(headers) - 1: # tolerate ragged tail
196
+ row = {}
197
+ for k, h in enumerate(headers):
198
+ row[h] = cells[k] if k < len(cells) else ""
199
+ rows.append(row)
200
+ j += 1
201
+ tables.append((headers, rows))
202
+ i = j
203
+ else:
204
+ i += 1
205
+ return tables
206
+
207
+
208
+ def find_table(text, required_cols):
209
+ """First table whose header contains every required column (contains-match)."""
210
+ req = [c.lower() for c in required_cols]
211
+ for headers, rows in parse_tables(text):
212
+ if all(any(r in h for h in headers) for r in req):
213
+ return headers, rows
214
+ # exact-name pass for short names like 'kpi'
215
+ if all(r in headers for r in req):
216
+ return headers, rows
217
+ return None, None
218
+
219
+
220
+ def col(row, name):
221
+ """Fetch a cell by contains-match on the header name."""
222
+ name = name.lower()
223
+ if name in row:
224
+ return row[name]
225
+ for h, v in row.items():
226
+ if name in h:
227
+ return v
228
+ return None
229
+
230
+
231
+ def is_example_row(row):
232
+ return any(EXAMPLE_MARKER in (v or "") for v in row.values())
233
+
234
+
235
+ def close_enough(stated, computed, tol):
236
+ if stated is None or computed is None:
237
+ return True # missing cells reported separately
238
+ if computed == 0:
239
+ return abs(stated) < 1e-9
240
+ return abs(stated - computed) / abs(computed) <= tol
241
+
242
+
243
+ def canonical_files(root):
244
+ docs = sdlc_core.ai_path(root)
245
+ for d in CANONICAL_DIRS:
246
+ base = docs / d
247
+ if base.is_dir():
248
+ for p in sorted(base.rglob("*.md")):
249
+ yield p
250
+
251
+
252
+ def all_md_files(root):
253
+ docs = sdlc_core.ai_path(root)
254
+ for p in sorted(docs.rglob("*.md")):
255
+ yield p
256
+
257
+
258
+ # ---------------------------------------------------------------- index
259
+
260
+ def build_index(root):
261
+ docs = sdlc_core.ai_path(root)
262
+ lines = [
263
+ "# mkt_docs INDEX (generated by mkt_check.py index -- do not edit by hand)",
264
+ "",
265
+ "| Path | Description | Status |",
266
+ "|---|---|---|",
267
+ ]
268
+ for p in canonical_files(root):
269
+ if p.name == "INDEX.md":
270
+ continue
271
+ fm, body = load_frontmatter(read_text(p))
272
+ desc = fm.get("description", "")
273
+ status = fm.get("status", "")
274
+ if not desc:
275
+ h1 = re.search(r"^#\s+(.+)$", body, re.MULTILINE)
276
+ desc = h1.group(1).strip() if h1 else ""
277
+ rel = p.relative_to(docs).as_posix()
278
+ lines.append(f"| {rel} | {desc} | {status} |")
279
+ return "\n".join(lines) + "\n"
280
+
281
+
282
+ def cmd_index(root):
283
+ content = build_index(root)
284
+ out = sdlc_core.ai_path(root) / "INDEX.md"
285
+ out.write_text(content, encoding="utf-8")
286
+ print(f"[OK] wrote {out.relative_to(root).as_posix()}")
287
+ # F-028: the workstream registry is generated here too. This entry point does
288
+ # not reuse the spine's cmd_index, so wiring it explicitly is what keeps the
289
+ # doctrine ("generated by mkt_check.py index") from being false in this lens.
290
+ return max(sdlc_core.rc_registry(root), knowledge.kb_refresh_indexes(root / sdlc_core.docs_dir()))
291
+
292
+
293
+ # ---------------------------------------------------------------- validate
294
+
295
+ def run_validate(root):
296
+ rep = Report()
297
+ docs = sdlc_core.ai_path(root)
298
+ # F-028: the generated registry against its HANDOFF_*.md sources. Gated on
299
+ # source presence, so a project that has not converted sees nothing new.
300
+ workstreams = sdlc_core.list_workstreams(root)
301
+ if workstreams:
302
+ hand = docs / "audit" / "handoff.md"
303
+ if not hand.is_file():
304
+ rep.error("audit/handoff.md missing while HANDOFF_*.md sources exist: "
305
+ "run 'mkt_check.py index'")
306
+ elif sdlc_core.norm_text(read_text(hand)) != sdlc_core.norm_text(sdlc_core.build_registry(root)):
307
+ rep.error("audit/handoff.md not aligned with its HANDOFF_*.md sources: "
308
+ "run 'mkt_check.py index'. A merge resolved by hand is exactly "
309
+ "what this catches")
310
+ if len(workstreams) > sdlc_core.REGISTRY_CAP:
311
+ rep.warn(f"{len(workstreams)} open workstreams: the registry is meant to "
312
+ f"stay under {sdlc_core.REGISTRY_CAP}")
313
+ known = {p.name for p in docs.rglob("*.md")}
314
+ for p in canonical_files(root):
315
+ if p.name == "INDEX.md":
316
+ continue
317
+ rel = p.relative_to(docs).as_posix()
318
+ fm, _ = load_frontmatter(read_text(p))
319
+ status = fm.get("status", "")
320
+ if not status:
321
+ rep.warn(f"{rel}: missing frontmatter 'status'")
322
+ elif status.upper() not in VALID_STATUS:
323
+ rep.warn(f"{rel}: invalid status '{status}' (expected {'|'.join(VALID_STATUS)})")
324
+ sup = fm.get("supersedes", "")
325
+ if sup and Path(sup).name not in known:
326
+ rep.warn(f"{rel}: supersedes '{sup}' which does not exist under mkt_docs/")
327
+ index_path = docs / "INDEX.md"
328
+ if not index_path.exists():
329
+ rep.warn("mkt_docs/INDEX.md missing -- generate with 'mkt_check.py index'")
330
+ elif read_text(index_path) != build_index(root):
331
+ rep.warn("mkt_docs/INDEX.md stale -- regenerate with 'mkt_check.py index'")
332
+ return rep
333
+
334
+
335
+ # ---------------------------------------------------------------- ledger
336
+
337
+ def load_ledger(root):
338
+ """Return (rows, report). Rows: list of dicts with id/claim/class/... keys."""
339
+ rep = Report()
340
+ path = sdlc_core.ai_path(root) / "research" / "evidence_ledger.md"
341
+ if not path.exists():
342
+ rep.error("research/evidence_ledger.md missing")
343
+ return [], rep
344
+ headers, rows = find_table(read_text(path), ["id", "claim", "class", "source"])
345
+ if rows is None:
346
+ rep.error("evidence_ledger.md: ledger table not found "
347
+ "(need columns ID | Claim | Class | ... | Source | ...)")
348
+ return [], rep
349
+ entries = []
350
+ seen = set()
351
+ for row in rows:
352
+ rid = (col(row, "id") or "").strip()
353
+ if not rid:
354
+ continue
355
+ if is_example_row(row):
356
+ continue
357
+ if not re.fullmatch(r"EV-\d+", rid):
358
+ rep.error(f"ledger: bad ID '{rid}' (expected EV-<n>)")
359
+ continue
360
+ if rid in seen:
361
+ rep.error(f"ledger: duplicate ID {rid}")
362
+ seen.add(rid)
363
+ entries.append(row)
364
+ return entries, rep
365
+
366
+
367
+ def run_ledger(root):
368
+ entries, rep = load_ledger(root)
369
+ ids = set()
370
+ for row in entries:
371
+ rid = col(row, "id").strip()
372
+ ids.add(rid)
373
+ cls = (col(row, "class") or "").strip().upper()
374
+ source = (col(row, "source") or "").strip()
375
+ date = (col(row, "date") or "").strip()
376
+ confidence = (col(row, "confidence") or "").strip().upper()
377
+ if cls not in LEDGER_CLASSES:
378
+ rep.error(f"ledger {rid}: class '{cls}' invalid (FACT|BENCHMARK|ASSUMPTION)")
379
+ continue
380
+ if cls == "BENCHMARK":
381
+ if "http" not in source.lower():
382
+ rep.error(f"ledger {rid}: BENCHMARK without a source URL")
383
+ if not date:
384
+ rep.error(f"ledger {rid}: BENCHMARK without a date")
385
+ if cls == "FACT" and not source:
386
+ rep.error(f"ledger {rid}: FACT without a source -- name the origin "
387
+ "('user, Wave 1' or the client's data file)")
388
+ if (cls == "FACT" and source and not CLIENT_ORIGIN_RE.match(source)
389
+ and INTERNAL_SOURCE_RE.search(source)):
390
+ # FACT means "the client told me" or "the client's own primary data" — the
391
+ # one class that legitimately has no URL. Pointing it at another document
392
+ # of this engagement is how a researched observation gets in without the
393
+ # URL its class would owe: the row cites a file that cites the source, the
394
+ # validator resolves nothing, and "see VOC.md" is exactly the form the
395
+ # BENCHMARK rule rejects. Observed in a cold-agent field test, 2026-08-02.
396
+ rep.error(f"ledger {rid}: FACT sourced to an internal document ('{source}') -- "
397
+ "a FACT is what the client stated or their own primary data; an "
398
+ "observation gathered by research is a BENCHMARK and owes its URL")
399
+ if cls == "ASSUMPTION":
400
+ if confidence not in CONFIDENCE_VALUES:
401
+ rep.error(f"ledger {rid}: ASSUMPTION without confidence (HIGH|MED|LOW)")
402
+ value = (col(row, "value") or "").strip()
403
+ if value and not re.search(r"\d\s*[-–—]\s*\d|\bto\b", value):
404
+ rep.warn(f"ledger {rid}: ASSUMPTION value looks like a point estimate, expected a range")
405
+ # reference resolution across every md file (ledger file itself excluded)
406
+ referenced = set()
407
+ ledger_path = sdlc_core.ai_path(root) / "research" / "evidence_ledger.md"
408
+ for p in all_md_files(root):
409
+ if p == ledger_path:
410
+ continue
411
+ text = read_text(p)
412
+ for line in text.split("\n"):
413
+ if EXAMPLE_MARKER in line:
414
+ continue
415
+ for m in EV_REF.finditer(line):
416
+ referenced.add(m.group(1))
417
+ if m.group(1) not in ids:
418
+ rel = p.relative_to(sdlc_core.ai_path(root)).as_posix()
419
+ rep.error(f"{rel}: reference [{m.group(1)}] not found in the ledger")
420
+ for rid in sorted(ids - referenced, key=lambda x: int(x.split("-")[1])):
421
+ rep.warn(f"ledger {rid}: never referenced by any document")
422
+ return rep
423
+
424
+
425
+ # ---------------------------------------------------------------- budget
426
+
427
+ def default_tactical_files(root, file_arg):
428
+ if file_arg:
429
+ p = Path(file_arg)
430
+ if not p.is_absolute():
431
+ p = root / file_arg
432
+ return [p] if p.exists() else []
433
+ tactics = sdlc_core.ai_path(root) / "tactics"
434
+ files = []
435
+ if (tactics / "TACTICAL_PLAN.md").exists():
436
+ files.append(tactics / "TACTICAL_PLAN.md")
437
+ if tactics.is_dir():
438
+ files.extend(sorted(tactics.glob("CAMPAIGN_*.md")))
439
+ return files
440
+
441
+
442
+ def run_budget(root, file_arg=None):
443
+ rep = Report()
444
+ files = default_tactical_files(root, file_arg)
445
+ if not files:
446
+ rep.warn("no tactical plan found (tactics/TACTICAL_PLAN.md) -- budget check skipped")
447
+ return rep
448
+ for path in files:
449
+ rel = path.name
450
+ text = read_text(path)
451
+ m = re.search(r"^Total budget:\s*(.+)$", text, re.MULTILINE | re.IGNORECASE)
452
+ if not m:
453
+ rep.error(f"{rel}: 'Total budget:' line missing")
454
+ continue
455
+ total = parse_num(m.group(1))
456
+ if total is None or total <= 0:
457
+ rep.error(f"{rel}: cannot parse Total budget '{m.group(1).strip()}'")
458
+ continue
459
+ headers, rows = find_table(text, ["channel", "budget", "share"])
460
+ if rows is None:
461
+ rep.error(f"{rel}: Budget Allocation table not found (Channel | Budget | Share)")
462
+ continue
463
+ rows = [r for r in rows if not is_example_row(r)]
464
+ if not rows:
465
+ rep.error(f"{rel}: Budget Allocation table has no rows")
466
+ continue
467
+ alloc = 0.0
468
+ for r in rows:
469
+ v = parse_num(col(r, "budget"))
470
+ if v is None:
471
+ rep.error(f"{rel}: unparseable budget cell '{col(r, 'budget')}' "
472
+ f"for channel '{col(r, 'channel')}'")
473
+ else:
474
+ alloc += v
475
+ if not close_enough(alloc, total, BUDGET_TOLERANCE):
476
+ rep.error(f"{rel}: allocations sum to {alloc:g}, Total budget is {total:g} "
477
+ f"(tolerance 1%)")
478
+ return rep
479
+
480
+
481
+ # ---------------------------------------------------------------- funnel
482
+
483
+ FUNNEL_CHAIN = (
484
+ # (stated_col, compute, label)
485
+ ("clicks", lambda b, cpc, cvr, close, r: b / cpc if cpc else None, "Budget/CPC"),
486
+ ("leads", lambda b, cpc, cvr, close, r: (r["clicks"] * cvr / 100.0)
487
+ if (r["clicks"] is not None and cvr is not None) else None, "Clicks*CVR%"),
488
+ ("customers", lambda b, cpc, cvr, close, r: (r["leads"] * close / 100.0)
489
+ if (r["leads"] is not None and close is not None) else None, "Leads*Close%"),
490
+ ("cac", lambda b, cpc, cvr, close, r: (b / r["customers"])
491
+ if (r["customers"] not in (None, 0) and b is not None) else None, "Budget/Customers"),
492
+ )
493
+
494
+
495
+ def run_funnel(root, file_arg=None):
496
+ rep = Report()
497
+ files = default_tactical_files(root, file_arg)
498
+ if not files:
499
+ rep.warn("no tactical plan found -- funnel check skipped")
500
+ return rep
501
+ for path in files:
502
+ rel = path.name
503
+ text = read_text(path)
504
+ headers, rows = find_table(text, ["channel", "budget", "cpc", "cvr", "customers", "cac"])
505
+ if rows is None:
506
+ rep.error(f"{rel}: Funnel Model table not found "
507
+ "(Channel | Budget | CPC | Clicks | CVR % | Leads | Close % | Customers | CAC)")
508
+ continue
509
+ rows = [r for r in rows if not is_example_row(r)]
510
+ if not rows:
511
+ rep.error(f"{rel}: Funnel Model table has no rows")
512
+ continue
513
+ for r in rows:
514
+ channel = col(r, "channel") or "?"
515
+ budget = parse_num(col(r, "budget"))
516
+ cpc = parse_num(col(r, "cpc"))
517
+ cvr = parse_num(col(r, "cvr"))
518
+ close = parse_num(col(r, "close"))
519
+ stated = {
520
+ "clicks": parse_num(col(r, "clicks")),
521
+ "leads": parse_num(col(r, "leads")),
522
+ "customers": parse_num(col(r, "customers")),
523
+ "cac": parse_num(col(r, "cac")),
524
+ }
525
+ if budget is None or cpc is None:
526
+ rep.error(f"{rel} [{channel}]: Budget/CPC missing or unparseable")
527
+ continue
528
+ # verify each stated cell against computation from STATED upstream
529
+ # cells (so a single wrong cell does not cascade into four errors)
530
+ chain_inputs = dict(stated)
531
+ for name, compute, label in FUNNEL_CHAIN:
532
+ computed = compute(budget, cpc, cvr, close, chain_inputs)
533
+ if stated[name] is None:
534
+ rep.error(f"{rel} [{channel}]: '{name}' cell missing")
535
+ continue
536
+ if computed is None:
537
+ continue # upstream missing, already reported
538
+ if not close_enough(stated[name], computed, FUNNEL_TOLERANCE):
539
+ rep.error(f"{rel} [{channel}]: {name}={stated[name]:g} but {label} "
540
+ f"= {computed:g} (tolerance 5%)")
541
+ return rep
542
+
543
+
544
+ # ---------------------------------------------------------------- trace
545
+
546
+ def run_trace(root):
547
+ rep = Report()
548
+ docs = sdlc_core.ai_path(root)
549
+ obj_path = docs / "strategy" / "OBJECTIVES.md"
550
+ if not obj_path.exists():
551
+ rep.warn("strategy/OBJECTIVES.md missing -- trace check skipped")
552
+ return rep
553
+ objectives = set(OBJECTIVE_HEADING.findall(read_text(obj_path)))
554
+ if not objectives:
555
+ rep.error("OBJECTIVES.md: no '### O<n> --...' headings found")
556
+ return rep
557
+
558
+ tactical = docs / "tactics" / "TACTICAL_PLAN.md"
559
+ served = set()
560
+ if tactical.exists():
561
+ headers, rows = find_table(read_text(tactical), ["channel", "objective", "kpi"])
562
+ if rows is None:
563
+ rep.error("TACTICAL_PLAN.md: Channel Plan table not found "
564
+ "(Channel | Objective | KPI | Budget | Owner)")
565
+ else:
566
+ for r in rows:
567
+ if is_example_row(r):
568
+ continue
569
+ tokens = set(OBJECTIVE_TOKEN.findall(col(r, "objective") or ""))
570
+ if not tokens:
571
+ rep.error(f"TACTICAL_PLAN.md [{col(r, 'channel')}]: no objective (O<n>) named")
572
+ for t in tokens:
573
+ if t not in objectives:
574
+ rep.error(f"TACTICAL_PLAN.md [{col(r, 'channel')}]: objective {t} "
575
+ "not defined in OBJECTIVES.md")
576
+ served |= tokens & objectives
577
+ if not (col(r, "kpi") or "").strip():
578
+ rep.error(f"TACTICAL_PLAN.md [{col(r, 'channel')}]: KPI cell empty")
579
+ for o in sorted(objectives - served):
580
+ rep.warn(f"objective {o}: no tactic serves it in TACTICAL_PLAN.md")
581
+ else:
582
+ rep.warn("tactics/TACTICAL_PLAN.md missing -- tactic trace skipped")
583
+
584
+ measure = docs / "tactics" / "MEASUREMENT_PLAN.md"
585
+ if measure.exists():
586
+ headers, rows = find_table(read_text(measure), ["objective", "kpi", "target"])
587
+ if rows is None:
588
+ rep.error("MEASUREMENT_PLAN.md: KPI table not found (Objective | KPI | Target | ...)")
589
+ else:
590
+ measured = set()
591
+ for r in rows:
592
+ if is_example_row(r):
593
+ continue
594
+ measured |= set(OBJECTIVE_TOKEN.findall(col(r, "objective") or ""))
595
+ for o in sorted(objectives - measured):
596
+ rep.error(f"objective {o}: no KPI row in MEASUREMENT_PLAN.md")
597
+ else:
598
+ rep.warn("tactics/MEASUREMENT_PLAN.md missing -- KPI trace skipped")
599
+ return rep
600
+
601
+
602
+ # ---------------------------------------------------------------- check
603
+
604
+ def run_check(root):
605
+ rep = Report()
606
+ for title, fn in (("validate", run_validate), ("ledger", run_ledger),
607
+ ("budget", run_budget), ("funnel", run_funnel),
608
+ ("trace", run_trace)):
609
+ sub = fn(root)
610
+ sub.print(title)
611
+ rep.merge(sub)
612
+ return rep
613
+
614
+
615
+ # ---------------------------------------------------------------- main
616
+
617
+ # --- portable checks this distribution exposes -------------------------------
618
+ # The marketing arithmetic, offered to documents owned by ANOTHER domain: a
619
+ # distilled spec that carries a budget table can import `marketing.budget` and have
620
+ # it actually checked, instead of the table sitting in a document whose own
621
+ # validator has no opinion about it (UC8). Imported checks may only ADD findings.
622
+
623
+ def _report_findings(rep):
624
+ return ([("error", m) for m in rep.errors]
625
+ + [("warning", m) for m in rep.warnings])
626
+
627
+
628
+ @sdlc_core.portable_check("marketing.ledger")
629
+ def _check_ledger(rel, meta, text):
630
+ root = sdlc_core.find_project_root()
631
+ return _report_findings(run_ledger(root)) if root else []
632
+
633
+
634
+ @sdlc_core.portable_check("marketing.budget")
635
+ def _check_budget(rel, meta, text):
636
+ root = sdlc_core.find_project_root()
637
+ return _report_findings(run_budget(root)) if root else []
638
+
639
+
640
+ @sdlc_core.portable_check("marketing.funnel")
641
+ def _check_funnel(rel, meta, text):
642
+ root = sdlc_core.find_project_root()
643
+ return _report_findings(run_funnel(root)) if root else []
644
+
645
+
646
+ def main(argv=None):
647
+ argv = list(sys.argv[1:] if argv is None else argv)
648
+ # Spine commands are the core's, identical in every distribution: hand over
649
+ # every subcommand this overlay does not explicitly intercept, untouched,
650
+ # rather than keeping a hand-copied list of spine names here -- which is how
651
+ # `migrate` got dropped while SKILL.md kept promising it.
652
+ if argv and not argv[0].startswith("-") and argv[0] not in OVERLAY_COMMANDS:
653
+ return knowledge.main(argv)
654
+
655
+ parser = argparse.ArgumentParser(prog="mkt_check.py", description=__doc__,
656
+ epilog="Shared memory commands: recall QUERY, graph, corpus, claim-id, anchor, "
657
+ "export, import, stale, orient. Use COMMAND --help for options.")
658
+ parser.add_argument("command", choices=list(OVERLAY_COMMANDS))
659
+ parser.add_argument("file", nargs="?", default=None,
660
+ help="optional target file for budget/funnel")
661
+ parser.add_argument("--root", default=None, help="project root (contains the docs root)")
662
+ parser.add_argument("--docs-dir", dest="docs_dir", default=None,
663
+ help="name of the documentation root (default: mkt_docs; "
664
+ "use --docs-dir ai_docs on a project that has migrated "
665
+ "to the family's single tree)")
666
+ parser.add_argument("--strict", action="store_true",
667
+ help="warnings also fail the exit code")
668
+ args = parser.parse_args(argv)
669
+
670
+ # Resolve the docs root fresh on EVERY invocation, from this distribution's own
671
+ # default: the core keeps it in module state, and an entry point that inherited
672
+ # whatever a previous call left behind would be answering the wrong question.
673
+ try:
674
+ discovered, name = sdlc_core.resolve_docs_dir(args, args.root)
675
+ except sdlc_core.AmbiguousDocsRoot as exc:
676
+ print("[ERROR] %s" % exc)
677
+ return 1
678
+ root = find_root(args.root or discovered, name)
679
+ if root is None:
680
+ print(f"[ERROR] no {sdlc_core.docs_dir()}/ directory found "
681
+ "(use --root or run mkt-sdlc-init)")
682
+ return 2
683
+
684
+ if args.command == "index":
685
+ return cmd_index(root)
686
+
687
+ runners = {
688
+ "validate": lambda: run_validate(root),
689
+ "ledger": lambda: run_ledger(root),
690
+ "budget": lambda: run_budget(root, args.file),
691
+ "funnel": lambda: run_funnel(root, args.file),
692
+ "trace": lambda: run_trace(root),
693
+ }
694
+ if args.command == "check":
695
+ rep = run_check(root)
696
+ else:
697
+ rep = runners[args.command]()
698
+ rep.print(args.command)
699
+
700
+ code = rep.exit_code(strict=args.strict)
701
+ if args.command in ("validate", "check"):
702
+ code = max(code, knowledge.kb_validate_surface(root / sdlc_core.docs_dir(),
703
+ full=args.command == "check"))
704
+ if code == 0:
705
+ print(f"[OK] {args.command} clean "
706
+ f"({len(rep.warnings)} warning(s))" if rep.warnings else f"[OK] {args.command} clean")
707
+ else:
708
+ print(f"[FAIL] {args.command}: {len(rep.errors)} error(s), {len(rep.warnings)} warning(s)")
709
+ if args.command == "check":
710
+ # This overlay REPLACES the spine's cmd_check, so the F-041 wiring note
711
+ # must be re-attached here: it is check-layer behaviour, whoever owns
712
+ # the check (fail-open and informational -- never the exit code).
713
+ sdlc_core.print_orient_hook_note(root)
714
+ return code
715
+
716
+
717
+ if __name__ == "__main__":
718
+ sys.exit(main())