1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444 | #!/usr/bin/env python3
"""
verify_crm_automation.py โ the crm-automator master's real linter.
Scores ONE deliverable file (a markdown artifact produced by the master)
against the discipline rubric defined in MANUAL.md ยง8.2. Exits 0 if the
deliverable meets the standard, 1 if it fails โ printing every named
check that failed so the agent knows exactly what to fix.
python3 stdlib only. No network, no deps.
Usage:
python3 verify_crm_automation.py <path/to/deliverable.md>
python3 verify_crm_automation.py <path/to/deliverable.md> --type automation-spec
The file must declare its own type via a header line:
Deliverable-Type: automation-spec
(one of: automation-spec, lead-scoring-model, segmentation-plan,
journey-map, pipeline-audit, integration-runbook)
If --type is given it must match the declared type, or the check fails
hard (a deliverable that doesn't know what it is, is not done).
"""
import re
import sys
import argparse
VALID_TYPES = {
"automation-spec",
"lead-scoring-model",
"segmentation-plan",
"journey-map",
"pipeline-audit",
"integration-runbook",
}
# Patterns that look like an actual leaked secret value rather than an
# env-var / vault-name reference. Deliberately generous โ a hard fail
# on a false positive is far cheaper than shipping a leaked key.
SECRET_PATTERNS = [
re.compile(r"sk_live_[A-Za-z0-9]+"),
re.compile(r"sk_test_[A-Za-z0-9]+"),
re.compile(r"sk-[A-Za-z0-9]{16,}"),
re.compile(r"pk_live_[A-Za-z0-9]+"),
re.compile(r"AKIA[0-9A-Z]{16}"),
re.compile(r"ghp_[A-Za-z0-9]{20,}"),
re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"),
re.compile(r"AIza[0-9A-Za-z\-_]{20,}"),
re.compile(r"eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}"), # JWT-shaped
# generic: "key"/"token"/"secret"/"password" : <long opaque literal>
# (not `${ENV_VAR}`, not `env:NAME`, not `vault:name/path`)
re.compile(
r"(?i)\b(api[_-]?key|secret|token|password|auth)\b\s*[:=]\s*"
r"['\"]?(?!\$\{|env:|vault:|<|\{\{)[A-Za-z0-9/+_\-]{20,}['\"]?"
),
]
def fail_list():
return []
def get_field(block, label):
"""Return the text after 'Label:' on its own line within block, or None."""
m = re.search(
rf"^[ \t\-\*]*{re.escape(label)}\s*:\s*(.+)$", block, re.IGNORECASE | re.MULTILINE
)
return m.group(1).strip() if m else None
def has_section(text, *keywords):
"""True if a heading-ish line contains all keywords (case-insensitive)."""
for line in text.splitlines():
if line.strip().startswith("#") or line.strip().startswith("**"):
low = line.lower()
if all(k.lower() in low for k in keywords):
return True
return False
def get_deliverable_type(text):
m = re.search(r"^Deliverable-Type:\s*(\S+)", text, re.IGNORECASE | re.MULTILINE)
return m.group(1).strip().lower() if m else None
# ---------------------------------------------------------------------------
# automation-spec
# ---------------------------------------------------------------------------
def check_automation_spec(text):
failures = fail_list()
blocks = re.split(r"^##\s*Automation\s*:", text, flags=re.IGNORECASE | re.MULTILINE)[1:]
if not blocks:
failures.append("no '## Automation: <name>' blocks found โ need at least one automation defined")
return failures
required_fields = [
"Trigger",
"Entry conditions",
"Exit condition",
"Failure path",
"Idempotency",
"Owner",
"Kill switch",
]
for i, block in enumerate(blocks, start=1):
tag = f"automation #{i}"
for field in required_fields:
val = get_field(block, field)
if not val or val.strip().lower() in ("", "n/a", "none", "tbd"):
failures.append(f"{tag}: missing or empty '{field}' โ required by rubric")
exit_val = get_field(block, "Exit condition")
if exit_val and exit_val.strip().lower() in ("none", "n/a", "never", ""):
failures.append(f"{tag}: EXIT/suppression condition is mandatory and cannot be 'none' โ a spec with no exit fails")
idem_val = get_field(block, "Idempotency")
if idem_val and idem_val.strip().lower() in ("none", "n/a", ""):
failures.append(f"{tag}: idempotency note cannot be empty โ must state what prevents double-fire")
# Action steps section: everything between "Action steps" and the next
# known field label (or end of block).
steps_match = re.search(
r"Action steps\s*:?\s*(.*?)(?=^\s*[\-\*]?\s*(Exit condition|Failure path|Idempotency|Owner|Kill switch)\s*:|\Z)",
block,
re.IGNORECASE | re.DOTALL | re.MULTILINE,
)
steps_text = steps_match.group(1) if steps_match else ""
if not steps_text.strip():
failures.append(f"{tag}: missing 'Action steps' list")
else:
for line in steps_text.splitlines():
if re.search(r"\bemail(s|ed|ing)?\b", line, re.IGNORECASE):
if not re.search(r"(template[_ -]?id|TPL-[A-Za-z0-9]+)", line, re.IGNORECASE):
# allow the reference to live anywhere in the same step block line group
if not re.search(r"(template[_ -]?id|TPL-[A-Za-z0-9]+)", steps_text, re.IGNORECASE):
failures.append(
f"{tag}: an action step emails a contact without referencing a compliance-checked template ID "
f"(offending line: {line.strip()!r})"
)
break
return failures
# ---------------------------------------------------------------------------
# lead-scoring-model
# ---------------------------------------------------------------------------
def check_lead_scoring_model(text):
failures = fail_list()
if not has_section(text, "explicit") or not (has_section(text, "fit") or "explicit-fit" in text.lower()):
failures.append("missing an explicit-fit criteria section/table")
if not (has_section(text, "implicit") or has_section(text, "behavior")):
failures.append("missing an implicit-behavior criteria section/table")
if not re.search(r"(?i)\bdecay\b", text):
failures.append("no DECAY rule found โ a behavior score with no decay fails")
mql_lines = [l for l in text.splitlines() if re.search(r"(?i)mql threshold", l)]
if not mql_lines:
failures.append("no MQL threshold line found")
else:
paired = any(
re.search(r"(?i)(->|routes? to|routing|fires?|assign)", l) for l in mql_lines
) or any(
re.search(r"(?i)(->|routes? to|routing|fires?|assign)", text[m.start():m.start() + 300])
for m in re.finditer(r"(?i)mql threshold", text)
)
if not paired:
failures.append("MQL threshold is not paired with the routing action it fires")
# count negative point values in table rows: | ... | -N | or "-N pts" style
neg_matches = re.findall(r"(?:\|\s*-\d+(?:\.\d+)?\s*\|)|(?:-\d+(?:\.\d+)?\s*(?:pts?|points))", text)
if len(neg_matches) < 2:
failures.append(
f"fewer than 2 negative-scoring entries found ({len(neg_matches)}) โ a model with no negatives fails"
)
if not re.search(r"(?i)review cadence", text):
failures.append("missing a review-cadence line")
if not re.search(r"(?i)goodhart", text):
failures.append("missing a Goodhart note")
elif not re.search(r"(?i)counter[- ]?metric", text):
failures.append("Goodhart note found but no named counter-metric")
return failures
# ---------------------------------------------------------------------------
# segmentation-plan
# ---------------------------------------------------------------------------
def check_segmentation_plan(text):
failures = fail_list()
segments = re.split(r"^###\s*Segment\s*:", text, flags=re.IGNORECASE | re.MULTILINE)[1:]
if not segments:
failures.append("no '### Segment: <name>' blocks found")
return failures
rule_op_re = re.compile(r"(>=|<=|==|!=|>|<|\bAND\b|\bOR\b|\bIN\s*\()", re.IGNORECASE)
sunset_found = False
# RFM base layer may live as its own top-level section (before/around the
# segment blocks), not necessarily nested inside one โ check the whole doc.
rfm_section = re.search(r"(?i)RFM base layer(.*?)(?=^###\s*Segment|\Z)", text, re.DOTALL | re.MULTILINE)
rfm_scope = rfm_section.group(1) if rfm_section else ""
r_val = get_field(rfm_scope, "R")
f_val = get_field(rfm_scope, "F")
m_val = get_field(rfm_scope, "M")
rfm_found = bool(
r_val and f_val and m_val
and re.search(r"\d", r_val) and re.search(r"\d", f_val) and re.search(r"\d", m_val)
)
for i, seg in enumerate(segments, start=1):
name_line = seg.strip().splitlines()[0] if seg.strip() else ""
tag = f"segment #{i} ({name_line[:40]})"
rule = get_field(seg, "Rule")
if not rule:
failures.append(f"{tag}: missing 'Rule' line")
elif not rule_op_re.search(rule):
failures.append(f"{tag}: rule is prose, not machine-evaluable (no comparison operator/boolean found): {rule!r}")
size = get_field(seg, "Size estimate")
if not size or not re.search(r"(?i)source\s*:", seg):
failures.append(f"{tag}: missing size estimate with a data source")
if not get_field(seg, "Treatment"):
failures.append(f"{tag}: missing intended 'Treatment'")
if not re.search(r"(?i)(mutually exclusive|overlap)", seg):
failures.append(f"{tag}: missing mutual-exclusivity/overlap declaration")
if re.search(r"(?i)\b(sunset|dead)\b", name_line):
sunset_found = True
if not rfm_found:
failures.append("no RFM base layer found with numeric R, F, M boundaries")
if not sunset_found:
failures.append("no sunset/dead segment found")
return failures
# ---------------------------------------------------------------------------
# journey-map
# ---------------------------------------------------------------------------
def check_journey_map(text):
failures = fail_list()
stages = re.split(r"^###\s*Stage\s*:", text, flags=re.IGNORECASE | re.MULTILINE)[1:]
if not stages:
failures.append("no '### Stage: <name>' blocks found")
return failures
inbound_sla_ok = False
for i, stage in enumerate(stages, start=1):
name_line = stage.strip().splitlines()[0] if stage.strip() else ""
tag = f"stage #{i} ({name_line[:40]})"
entry = get_field(stage, "Entry criteria")
if not entry:
failures.append(f"{tag}: missing 'Entry criteria'")
sla = get_field(stage, "SLA")
is_inbound = bool(re.search(r"(?i)(inbound|lead[- ]?created|speed-to-lead)", name_line + stage[:200]))
if is_inbound:
if not sla or not re.search(r"\d+\s*min", sla, re.IGNORECASE):
failures.append(f"{tag}: inbound/speed-to-lead stage must state an SLA in minutes")
else:
inbound_sla_ok = True
if not re.search(r"(?i)trigger inventory", text):
failures.append("missing a 'Trigger inventory' section")
else:
inv_match = re.search(r"(?i)trigger inventory(.*?)(?=^##|\Z)", text, re.DOTALL | re.MULTILINE)
inv_text = inv_match.group(1) if inv_match else ""
if not re.search(r"\.md", inv_text):
failures.append("trigger inventory does not reference any automation-spec file (expected a .md reference)")
if not re.search(r"(?i)conflict rule", text):
failures.append("missing 'Conflict rules' section")
if not re.search(r"(?i)metric\s*:", text):
failures.append("missing per-transition instrumentation ('Metric:' lines)")
if not inbound_sla_ok and not any(re.search(r"(?i)speed-to-lead", s) for s in stages):
failures.append("no inbound stage with a stated minutes-SLA found anywhere in the journey")
return failures
# ---------------------------------------------------------------------------
# pipeline-audit
# ---------------------------------------------------------------------------
def check_pipeline_audit(text):
failures = fail_list()
required_metrics = {
"Stale-deal count": r"(?i)stale-deal count",
"Next-step-empty count": r"(?i)next-step-empty count",
"Stage-conversion rates": r"(?i)stage-conversion",
"Pipeline coverage ratio": r"(?i)coverage ratio",
}
source_tag_re = re.compile(r"\[source\s*:[^\]]+\]|\(source\s*:[^)]+\)", re.IGNORECASE)
for label, pattern in required_metrics.items():
m = re.search(pattern, text)
if not m:
failures.append(f"missing required metric: {label}")
continue
line_start = text.rfind("\n", 0, m.start()) + 1
line_end = text.find("\n", m.start())
line_end = line_end if line_end != -1 else len(text)
line = text[line_start:line_end]
if not source_tag_re.search(line):
failures.append(f"'{label}' line is missing a source tag, e.g. [source: SQL/report name]: {line.strip()!r}")
if not re.search(r"(?i)stale-deal count.*?\d+\s*days?", text):
failures.append("stale-deal count does not state its age threshold in days")
at_risk = re.search(r"(?i)top-5 at-risk deals(.*?)(?=^##|\Z)", text, re.DOTALL | re.MULTILINE)
if not at_risk:
failures.append("missing 'Top-5 at-risk deals' section")
else:
reason_count = len(re.findall(r"(?i)reason\s*:", at_risk.group(1)))
if reason_count < 5:
failures.append(f"top-5 at-risk deals section has only {reason_count} reason codes (need 5)")
action_section = re.search(r"(?i)ranked action(s)? list(.*?)(?=^##|\Z)", text, re.DOTALL | re.MULTILINE)
if not action_section:
failures.append("missing a ranked action list")
else:
owners = re.findall(r"(?i)owner\s*:\s*(funnel-closer|outreach-operator|human|[A-Za-z][\w .]+)", action_section.group(2))
if not owners:
failures.append("ranked action list has no named owner/agent per action")
return failures
# ---------------------------------------------------------------------------
# integration-runbook
# ---------------------------------------------------------------------------
def check_integration_runbook(text):
failures = fail_list()
for pat in SECRET_PATTERNS:
m = pat.search(text)
if m:
failures.append(f"HARD FAIL: inline secret-looking value found ({m.group(0)[:24]}...) โ must reference an env var or vault name instead")
break
if not re.search(r"(?i)(direction\s*:|->|โ)", text):
failures.append("missing systems + direction of sync")
if not re.search(r"\|.*(source field|src field).*\|.*(destination field|dst field)", text, re.IGNORECASE):
failures.append("missing a field-mapping table (source field -> destination field)")
auth_match = re.search(r"(?i)auth(entication)? method\s*:(.*)", text)
if not auth_match:
failures.append("missing 'Auth method' line")
else:
auth_val = auth_match.group(2)
if not re.search(r"(?i)(env var|environment variable|vault)", auth_val):
failures.append("auth method must reference an env var name or a vault name")
if not re.search(r"(?i)sync frequency|sync trigger", text):
failures.append("missing sync frequency/trigger")
if not re.search(r"(?i)dedupe rule", text):
failures.append("missing a dedupe rule")
if not re.search(r"(?i)(failure|retry) behavior", text):
failures.append("missing failure/retry behavior")
if not re.search(r"(?i)rollback procedure", text):
failures.append("missing a rollback procedure")
return failures
CHECKERS = {
"automation-spec": check_automation_spec,
"lead-scoring-model": check_lead_scoring_model,
"segmentation-plan": check_segmentation_plan,
"journey-map": check_journey_map,
"pipeline-audit": check_pipeline_audit,
"integration-runbook": check_integration_runbook,
}
def main():
parser = argparse.ArgumentParser(description="Lint a crm-automation deliverable against the master rubric.")
parser.add_argument("path", help="path to the deliverable markdown file")
parser.add_argument("--type", dest="type_override", default=None, choices=sorted(VALID_TYPES))
args = parser.parse_args()
try:
with open(args.path, "r", encoding="utf-8") as f:
raw_text = f.read()
except OSError as e:
print(f"FAIL: could not read {args.path}: {e}")
sys.exit(1)
# Strip HTML comments before linting โ annotations/notes in a doc must
# never count as the doc's actual content (a comment saying "no decay
# rule here" must not itself satisfy a regex looking for the word).
text = re.sub(r"<!--.*?-->", "", raw_text, flags=re.DOTALL)
declared_type = get_deliverable_type(text)
if not declared_type:
print("FAIL: no 'Deliverable-Type: <type>' header found โ a deliverable that doesn't declare its own type cannot be verified")
sys.exit(1)
if declared_type not in VALID_TYPES:
print(f"FAIL: unknown Deliverable-Type '{declared_type}' โ must be one of {sorted(VALID_TYPES)}")
sys.exit(1)
if args.type_override and args.type_override != declared_type:
print(f"FAIL: --type {args.type_override} does not match declared Deliverable-Type: {declared_type}")
sys.exit(1)
checker = CHECKERS[declared_type]
failures = checker(text)
print(f"== verify_crm_automation :: {args.path} :: type={declared_type} ==")
if failures:
print(f"FAIL โ {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
else:
print("PASS โ all rubric checks satisfied")
sys.exit(0)
if __name__ == "__main__":
main()
|