@antoneeo/mkt-agentic-sdlc-skill 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +150 -134
- package/gemini-extension.json +1 -1
- package/package.json +4 -2
- package/scripts/init.js +6 -16
- package/skills/mkt-agentic-sdlc/ENFORCEMENT.md +5 -5
- package/skills/mkt-agentic-sdlc/SKILL.md +11 -3
- package/skills/mkt-agentic-sdlc/memory.md +88 -0
- package/skills/mkt-agentic-sdlc/routing.md +7 -1
- package/skills/mkt-agentic-sdlc/scripts/knowledge.py +2089 -0
- package/skills/mkt-agentic-sdlc/scripts/mkt_check.py +718 -734
- package/skills/mkt-agentic-sdlc/scripts/sdlc_core.py +76 -5
|
@@ -1,734 +1,718 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
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
|
-
headers
|
|
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
|
-
cls
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
rep
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
)
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
"
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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())
|