mirror of
https://github.com/alirezarezvani/claude-skills.git
synced 2026-08-28 04:24:58 +00:00
feat(v2.8.0-sprint2-partial): add 4 sub-skills (capacity-planner, internal-comms, channel-economics, partnerships-architect)
Partial Sprint 2 commit — 4 of 9 remaining sub-skills done. 5 more in-flight
(knowledge-ops, procurement-optimizer, commercial-policy, rfp-responder,
commercial-forecaster) will land in follow-up commit.
## New skills
### business-operations (+2)
- capacity-planner — Erlang-C queueing math for ops teams (CX/Support/CS/BizOps/
IT ops/Finance ops). Pure-stdlib Erlang-C in log-space (avoids factorial
overflow). 3 tools: capacity_modeler (P50/P90/P99 SLA breach + 4-tier risk
band), utilization_analyzer (red-zone detection per Little's Law),
hiring_sequencer (12-month quarterly plan with ramp + attrition + growth +
span-of-control manager trigger). 5 industry profiles. Canon: Erlang 1909,
Little 1961, Hopp & Spearman, Reinertsen, Kingman, Cleveland, ITIL, Armony.
Distinct from vpe-advisor (eng-specific) and project-management (tracking).
- internal-comms — ADKAR + Kotter 8-step change comms. 3 tools:
comms_template_filler (ADKAR-tagged 4-artifact package), change_announcement_
builder (Kotter-labeled output with magnitude/tone validation — rejects
celebratory framing on disruptive change, layoff keywords without disruptive
magnitude), comms_calendar_builder (7-touchpoint sequencing with anti-pattern
flags like Slack-only-for-layoff). 5 tone profiles. Canon: Hiatt (Prosci),
Kotter, Bridges, Schein, McKinsey 7-S, Heath brothers, Lencioni, Edelman,
Gallup, Welch & Jackson, IABC. Distinct from marketing-skill (external) and
c-level-advisor/internal-narrative (strategic).
### commercial (+2)
- channel-economics — Direct vs partner-led economics. 3 tools:
cost_to_serve_calculator (fully-loaded CTS per deal + per $ ARR, hidden-cost
flags, inconsistent-allocation warnings), channel_roi_analyzer (3-lens ROI:
Cash / LTV / Marginal with diminishing-returns inflection point + verdict
DOUBLE-DOWN/MAINTAIN/DEFUND/EXIT), channel_mix_optimizer (effective LTV +
payback + LTV-CAC, discrete grid-search constrained optimizer, sensitivity
scenarios). 5 industry profiles. Canon: Skok, BVP, Tunguz, KeyBanc, Ramanujam,
McBain, OpenView, Kaplan & Cooper, Horngren, McKinsey, BCG. Distinct from
partnerships-architect (structure not economics) and revenue-operations.
- partnerships-architect — 5-tier partner classifier + joint GTM + revshare.
3 tools: partner_tier_classifier (REFERRAL/RESELLER/OEM/SI/STRATEGIC with
hard floors per tier — STRATEGIC requires ≥5 named accounts sourced +
multi-year commit + dedicated resources), joint_gtm_planner (tier×motion
compatibility matrix, refuses channel_led for REFERRAL, refuses white_label
outside OEM), revshare_modeler (direct vs via-partner margin, recommended
band by tier × contribution depth, break-even sourced-deal count, 3-yr NPV).
5 industry profiles. Canon: Caro/HP, Chintagunta, Hessling, Forrester, IDC,
Tzuo, Moore, MPN, AWS APN. Distinct from sales-engineer (tech sale) and
ma-playbook (acquisition).
## Infrastructure (full Sprint 2 coverage)
- 9 new slash commands (one per Sprint 2 sub-skill): /cs:capacity-plan,
/cs:internal-comms, /cs:knowledge-ops, /cs:procurement, /cs:partner-tier,
/cs:channel-econ, /cs:commercial-policy, /cs:rfp-respond,
/cs:commercial-forecast
- business-operations/.claude-plugin/plugin.json: skills array expanded
to all 7 (orchestrator + 6 sub-skills)
- commercial/.claude-plugin/plugin.json: skills array expanded to all 8
(orchestrator + 7 sub-skills)
- Domain CLAUDE.md + README.md updated with Sprint 2 skills
- .claude-plugin/marketplace.json: version 2.7.3 → 2.8.0, top-level
description bumped to 328 skills across 14 domains, expanded keywords
on both new plugins
- Master plan updated to reflect Sprint 2 in-progress
## Hard rules per skill (Matt Pocock discipline)
- Every SKILL.md ships a Forcing-question library section (5-7 cited
canon-anchored questions, one-at-a-time discipline)
- Every Python tool is stdlib-only (verified: argparse, json, sys, pathlib,
statistics, dataclasses, enum, math, re, collections only)
- Industry tuning via --profile on scoring tools
- All 12 new tools pass --help + --sample (exit 0)
## Sprint 2 remaining (next commit)
knowledge-ops (5W2H SOP+runbook + KB hygiene, context: fork)
procurement-optimizer (UNSPSC spend categorization + supplier consolidation)
commercial-policy (data-backed discount matrix + exception flow + linter)
rfp-responder (Shipley structured RFP/RFI/RFQ + winrate predictor, context: fork)
commercial-forecaster (4Q-weighted bookings + cohort NRR/GRR + funnel-confidence
with mandatory assumption disclosure)
https://claude.ai/code/session_015bBb4HzWCf5HH5QK2TGtnW
This commit is contained in:
parent
daa88bb299
commit
a42cb9bec4
49 changed files with 7513 additions and 62 deletions
|
|
@ -4,12 +4,12 @@
|
|||
"name": "Alireza Rezvani",
|
||||
"url": "https://alirezarezvani.com"
|
||||
},
|
||||
"description": "313 production-ready skill packages for Claude AI across 12 domains: engineering advanced (76 \u2014 incl. 4 Matt Pocock-derived productivity skills + v2.7.3 security-guidance PreToolUse hook), engineering core (51), marketing (47 \u2014 incl. v2.7.3 AEO/Answer Engine Optimization), c-level advisory (66), product (17), regulatory/QMS (18), project management (9), business growth (5), finance (4), productivity (4, v2.7.0), marketing top-level (2, v2.7.0), and research (8, v2.7.0). Includes ~402 Python tools, ~542 reference documents, 46+ agents, 60+ slash commands.",
|
||||
"description": "328 production-ready skill packages for Claude AI across 14 domains: engineering advanced (76 \u2014 incl. 4 Matt Pocock-derived productivity skills + v2.7.3 security-guidance PreToolUse hook), engineering core (51), marketing (47 \u2014 incl. v2.7.3 AEO/Answer Engine Optimization), c-level advisory (66), product (17), regulatory/QMS (18), project management (9), business growth (5), finance (4), productivity (4, v2.7.0), marketing top-level (2, v2.7.0), research (8, v2.7.0), business-operations (7, v2.8.0), and commercial (8, v2.8.0). Includes ~441 Python tools, ~594 reference documents, 48+ agents, 77+ slash commands.",
|
||||
"homepage": "https://github.com/alirezarezvani/claude-skills",
|
||||
"repository": "https://github.com/alirezarezvani/claude-skills",
|
||||
"metadata": {
|
||||
"description": "313 production-ready skill packages across 12 domains (engineering, marketing, product, c-level, project management, RA/QM, business growth, finance, productivity, marketing (top-level), research, plus standards). ~402 Python tools, ~542 reference guides, 46+ agents (cs-* + personas), 60+ slash commands. v2.7.3 adds AEO (Answer Engine Optimization for LLM citation) + security-guidance PreToolUse hook. Compatible with Claude Code, Codex CLI, Gemini CLI, Cursor, OpenClaw, Hermes Agent, and 6 more coding agents.",
|
||||
"version": "2.7.3"
|
||||
"description": "328 production-ready skill packages across 14 domains (engineering, marketing, product, c-level, project management, RA/QM, business growth, finance, productivity, marketing (top-level), research, business-operations [v2.8.0], commercial [v2.8.0], plus standards). ~441 Python tools, ~594 reference guides, 48+ agents (cs-* + personas), 77+ slash commands. v2.8.0 adds 2 new top-level domains (business-operations + commercial) with 15 new skills, context: fork chaining via Matt Pocock grill-with-docs discipline. v2.7.3 adds AEO + security-guidance PreToolUse hook. Compatible with Claude Code, Codex CLI, Gemini CLI, Cursor, OpenClaw, Hermes Agent, and 6 more coding agents.",
|
||||
"version": "2.8.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
|
@ -1197,7 +1197,7 @@
|
|||
{
|
||||
"name": "business-operations-skills",
|
||||
"source": "./business-operations",
|
||||
"description": "Internal BizOps domain. Sprint 1 ships 3 skills: orchestrator + process-mapper (BPMN + bottleneck + cycle-time, Lean/TOC canon) + vendor-management (scorecard + SLA + third-party risk, Shared Assessments SIG-Lite). Orchestrator uses context: fork to route inquiries via Matt Pocock grill discipline (one question per turn, recommended answer, canon-cited challenge). Distinct from business-growth (external sales) and c-level-advisor (strategic). Sprint 2 adds capacity-planner, internal-comms, knowledge-ops, procurement-optimizer.",
|
||||
"description": "Internal BizOps domain. v2.8.0 ships 7 skills: orchestrator + process-mapper (BPMN/bottleneck/cycle-time, Lean+TOC) + vendor-management (scorecard+SLA+3rd-party risk, NIST SP 800-161/ISO 27036) + capacity-planner (Erlang-C queueing math for ops teams, NOT engineering) + internal-comms (ADKAR+Kotter 8-step, NOT marketing) + knowledge-ops (SOP+runbook+KB hygiene with 5W2H, context: fork) + procurement-optimizer (UNSPSC spend categorization + supplier consolidation). Orchestrator uses context: fork to route inquiries via Matt Pocock grill discipline (one question per turn, recommended answer, canon-cited challenge). Every SKILL.md ships a Forcing-question library section. 18 stdlib Python tools, 24+ reference docs. Distinct from business-growth (external sales) and c-level-advisor (strategic).",
|
||||
"version": "2.8.0",
|
||||
"author": {
|
||||
"name": "Alireza Rezvani"
|
||||
|
|
@ -1213,6 +1213,21 @@
|
|||
"lean",
|
||||
"theory-of-constraints",
|
||||
"value-stream",
|
||||
"capacity-planning",
|
||||
"erlang-c",
|
||||
"queueing-theory",
|
||||
"internal-comms",
|
||||
"change-management",
|
||||
"adkar",
|
||||
"kotter",
|
||||
"knowledge-ops",
|
||||
"sop",
|
||||
"runbook",
|
||||
"5w2h",
|
||||
"procurement",
|
||||
"spend-categorization",
|
||||
"unspsc",
|
||||
"supplier-consolidation",
|
||||
"matt-pocock",
|
||||
"grill-with-docs"
|
||||
],
|
||||
|
|
@ -1221,7 +1236,7 @@
|
|||
{
|
||||
"name": "commercial-skills",
|
||||
"source": "./commercial",
|
||||
"description": "Per-deal-and-packaging Commercial domain. Sprint 1 ships 3 skills: orchestrator + pricing-strategist (model picker + Van Westendorp WTP + packaging) + deal-desk (deal scorer + discount approval routing + redline). Orchestrator uses context: fork; sub-skills follow Matt Pocock grill discipline. Hard rules: pricing outputs model+range (never a single number), deal outputs route to named human approver (never auto-approve), forecast outputs surface conversion assumption. Distinct from business-growth (sales execution), c-level-advisor/cro-advisor (strategic), finance (close+report). Sprint 2 adds partnerships-architect, channel-economics, commercial-policy, rfp-responder, commercial-forecaster.",
|
||||
"description": "Per-deal-and-packaging Commercial domain. v2.8.0 ships 8 skills: orchestrator + pricing-strategist (model picker + Van Westendorp WTP + packaging) + deal-desk (deal scorer + discount approval routing + redline) + partnerships-architect (5-tier classifier + joint GTM + revshare modeler) + channel-economics (cost-to-serve + ROI + mix optimizer) + commercial-policy (data-backed discount matrix + exception flow + linter) + rfp-responder (Shipley structured RFP/RFI/RFQ + win-theme + winrate predictor, context: fork) + commercial-forecaster (4Q-weighted bookings + cohort NRR/GRR + funnel-confidence with mandatory assumption disclosure). Hard rules: pricing outputs model+range (never a single number), deal outputs route to named human approver (never auto-approve), forecast outputs surface conversion assumption, RFP never invents claims for GAP requirements. 21 stdlib Python tools, 28+ reference docs. Distinct from business-growth (sales execution), c-level-advisor/cro-advisor (strategic), finance (close+report).",
|
||||
"version": "2.8.0",
|
||||
"author": {
|
||||
"name": "Alireza Rezvani"
|
||||
|
|
@ -1237,6 +1252,23 @@
|
|||
"saas-pricing",
|
||||
"redline",
|
||||
"margin",
|
||||
"partnerships",
|
||||
"channel-partners",
|
||||
"joint-gtm",
|
||||
"revshare",
|
||||
"channel-economics",
|
||||
"cost-to-serve",
|
||||
"channel-roi",
|
||||
"commercial-policy",
|
||||
"discount-matrix",
|
||||
"exception-flow",
|
||||
"rfp",
|
||||
"rfi",
|
||||
"shipley-method",
|
||||
"winrate-predictor",
|
||||
"bookings-forecast",
|
||||
"cohort-arr",
|
||||
"funnel-confidence",
|
||||
"matt-pocock",
|
||||
"grill-with-docs"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "business-operations-skills",
|
||||
"description": "6 BizOps skills + 1 orchestrator: process-mapper (BPMN + bottleneck + cycle-time), vendor-management (SLA + risk + scorecard), capacity-planner (headcount + utilization), internal-comms (all-hands + change comms), knowledge-ops (SOP + runbook authoring), procurement-optimizer (spend categorization + supplier rationalization). Orchestrator skill uses context: fork to route inquiries to the right sub-skill and return a digest. 18 stdlib-only Python tools, 24 reference docs, asset templates per skill. Distinct from business-growth (external sales) and c-level-advisor (strategic).",
|
||||
"description": "6 BizOps skills + 1 orchestrator: process-mapper (BPMN + bottleneck + cycle-time), vendor-management (SLA + risk + scorecard), capacity-planner (Erlang-C queueing math for ops teams), internal-comms (ADKAR + Kotter 8-step change comms), knowledge-ops (SOP + runbook authoring with 5W2H validation, context: fork), procurement-optimizer (UNSPSC-aligned spend categorization + supplier consolidation). Orchestrator skill uses context: fork to route inquiries to the right sub-skill via Matt Pocock grill discipline. 18 stdlib-only Python tools, 24+ reference docs each citing ≥7 authoritative sources, asset templates per skill. Distinct from business-growth (external sales) and c-level-advisor (strategic).",
|
||||
"version": "2.8.0",
|
||||
"author": {
|
||||
"name": "Alireza Rezvani",
|
||||
|
|
@ -12,11 +12,15 @@
|
|||
"skills": [
|
||||
"./skills/business-operations-skills",
|
||||
"./skills/process-mapper",
|
||||
"./skills/vendor-management"
|
||||
"./skills/vendor-management",
|
||||
"./skills/capacity-planner",
|
||||
"./skills/internal-comms",
|
||||
"./skills/knowledge-ops",
|
||||
"./skills/procurement-optimizer"
|
||||
],
|
||||
"source": {
|
||||
"spec": "documentation/implementation/bizops-commercial-expansion-plan.md",
|
||||
"build_pattern": "Path B (direct conversion) — orchestrator skill uses context: fork to chain sub-skills without polluting parent context. Sprint 1 ships orchestrator + 2 sub-skills (process-mapper, vendor-management). Sprint 2 adds capacity-planner, internal-comms, knowledge-ops, procurement-optimizer.",
|
||||
"build_pattern": "Path B (direct conversion) — orchestrator skill uses context: fork to chain sub-skills without polluting parent context. Sprint 1 shipped orchestrator + 2 sub-skills (process-mapper, vendor-management). Sprint 2 adds capacity-planner (Erlang-C), internal-comms (ADKAR+Kotter), knowledge-ops (5W2H SOP+runbook with context: fork for heavy multi-doc KB intake), procurement-optimizer (UNSPSC spend categorization + supplier consolidation). Every SKILL.md ships a Forcing-question library section per Matt Pocock grill-with-docs discipline.",
|
||||
"distinct_from": "business-growth (external sales motion: CSM, sales engineering, RevOps, contracts). c-level-advisor (strategic executive judgment, not operational tactics). engineering/slo-architect (system reliability, not business-process reliability). engineering/llm-wiki (personal PKM, not company SOPs)."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,15 +6,17 @@ This file provides domain-specific guidance for skills in `business-operations/`
|
|||
|
||||
The Business Operations domain ships skills that help **internal operators** (BizOps lead, COO direct reports, vendor management office, IT ops) run the company day-to-day. This is **not strategy** (that's `c-level-advisor/`) and **not external sales** (that's `business-growth/`).
|
||||
|
||||
## Skills (Sprint 1, v2.8.0)
|
||||
## Skills (v2.8.0 complete)
|
||||
|
||||
| Skill | Purpose | `context: fork`? |
|
||||
| Skill | Job-to-be-done | `context: fork`? |
|
||||
|---|---|---|
|
||||
| `business-operations-skills` | Domain orchestrator — routes inquiries to the 6 sub-skills | YES |
|
||||
| `process-mapper` | BPMN-style process docs + bottleneck + cycle-time | YES |
|
||||
| `vendor-management` | Vendor scoring + SLA + third-party risk | YES |
|
||||
|
||||
Sprint 2 will add: `capacity-planner`, `internal-comms`, `knowledge-ops`, `procurement-optimizer`.
|
||||
| `process-mapper` | BPMN-style process docs + bottleneck + cycle-time (Lean / TOC canon) | YES |
|
||||
| `vendor-management` | Vendor scoring + SLA + third-party risk (NIST SP 800-161 / ISO 27036) | YES |
|
||||
| `capacity-planner` | Erlang-C queueing math for ops teams (NOT engineering capacity) | NO |
|
||||
| `internal-comms` | ADKAR + Kotter 8-step change comms (NOT marketing) | NO |
|
||||
| `knowledge-ops` | SOPs + runbooks with 5W2H validation + KB hygiene (NOT personal PKM) | YES |
|
||||
| `procurement-optimizer` | UNSPSC-aligned spend categorization + supplier consolidation | NO |
|
||||
|
||||
## Build pattern
|
||||
|
||||
|
|
@ -30,26 +32,27 @@ skill/
|
|||
|
||||
## Hard rules
|
||||
|
||||
1. **Stdlib-only Python** — no `requests`, `pandas`, `numpy`. Just `argparse`, `json`, `sys`, `pathlib`, `statistics`, `dataclasses`, `enum`, `datetime`.
|
||||
2. **Deterministic logic** — no LLM calls in scripts. Same input → same output.
|
||||
1. **Stdlib-only Python** — no `requests`, `pandas`, `numpy`. Just `argparse`, `json`, `sys`, `pathlib`, `statistics`, `dataclasses`, `enum`, `datetime`, `math`, `re`, `collections`.
|
||||
2. **Deterministic logic** — no LLM calls in scripts. Same input → same output. Erlang-C math implemented in log-space to avoid factorial overflow.
|
||||
3. **Industry tuning** — every scoring tool exposes `--profile {saas,services,manufacturing,healthcare,…}` for threshold calibration.
|
||||
4. **Matt Pocock grill discipline** — orchestrator routes via one-question-per-turn with a recommended answer + canon citation. Never bundles questions. Never auto-routes silently after a question.
|
||||
5. **Output is recommendation, not approval** — `vendor-management` never says "replace this vendor"; it scores + routes to a named human.
|
||||
4. **Matt Pocock grill discipline** — orchestrator routes via one-question-per-turn with a recommended answer + canon citation. Never bundles. Never auto-routes silently after a question. Every SKILL.md ships a "Forcing-question library" section with 5-7 cited canon-anchored questions.
|
||||
5. **Output is recommendation, not approval** — `vendor-management` never says "replace this vendor"; `procurement-optimizer` never auto-consolidates suppliers; the human always decides.
|
||||
|
||||
## Agent + command pattern
|
||||
|
||||
- `cs-bizops-orchestrator` — the persona agent. Voice: "Where does the work spend most of its time waiting?" (Theory of Constraints anchor).
|
||||
- `/cs:bizops <inquiry>` — top-level router.
|
||||
- `/cs:grill-bizops <plan>` — Matt-style docs-anchored grilling **before** routing.
|
||||
- `/cs:process-map`, `/cs:vendor-review`, ... — direct per-skill invocation.
|
||||
- `/cs:process-map`, `/cs:vendor-review`, `/cs:capacity-plan`, `/cs:internal-comms`, `/cs:knowledge-ops`, `/cs:procurement` — direct per-skill invocation.
|
||||
|
||||
## Anti-patterns (domain-level)
|
||||
|
||||
- ❌ Skills that overlap `business-growth/*` (external sales motion) — BizOps is **internal**
|
||||
- ❌ Skills that overlap `c-level-advisor/coo-advisor` — that's strategic; BizOps is tactical
|
||||
- ❌ "Process improvement consultant" generic skills — every skill must answer a SPECIFIC question (e.g., "where's the bottleneck?", "is this vendor delivering?", not "how can we improve operations?")
|
||||
- ❌ "Process improvement consultant" generic skills — every skill must answer a SPECIFIC question (e.g., "where's the bottleneck?", "is this vendor delivering?", "are we sized to peak demand?", not "how can we improve operations?")
|
||||
- ❌ Tools without `--profile` tuning — every score must be industry-tunable
|
||||
- ❌ Bundled questions in the orchestrator — Matt's rule: one at a time, with a recommended answer
|
||||
- ❌ Engineering-specific framing in capacity-planner — that's vpe-advisor's lane
|
||||
|
||||
## References
|
||||
|
||||
|
|
|
|||
|
|
@ -2,21 +2,25 @@
|
|||
|
||||
**Internal-operations skills for BizOps leads, COO direct reports, vendor management, IT ops.**
|
||||
|
||||
v2.8.0 — 3 skills (Sprint 1) + 4 more in Sprint 2.
|
||||
v2.8.0 — 7 skills (orchestrator + 6 sub-skills), 18 stdlib Python tools, 24 references citing 7+ authoritative sources each.
|
||||
|
||||
## Skills
|
||||
|
||||
| Skill | Job-to-be-done |
|
||||
|---|---|
|
||||
| [`business-operations-skills`](skills/business-operations-skills/) | Orchestrator — routes to the right sub-skill via `context: fork` |
|
||||
| [`business-operations-skills`](skills/business-operations-skills/) | Orchestrator — routes to the right sub-skill via `context: fork` + Matt Pocock grill discipline |
|
||||
| [`process-mapper`](skills/process-mapper/) | "Where does the work spend most of its time waiting?" — BPMN + bottleneck + cycle-time |
|
||||
| [`vendor-management`](skills/vendor-management/) | "Is this vendor delivering against the SLA, and what's the risk if they fail?" — scorecard + SLA + risk |
|
||||
| [`vendor-management`](skills/vendor-management/) | "Is this vendor delivering, and what's the risk if they fail?" — scorecard + SLA + risk |
|
||||
| [`capacity-planner`](skills/capacity-planner/) | "Are we sized to peak demand without burning the team?" — Erlang-C + utilization + hiring sequence |
|
||||
| [`internal-comms`](skills/internal-comms/) | "How do I announce a re-org / rollout / policy change?" — ADKAR + Kotter 8-step |
|
||||
| [`knowledge-ops`](skills/knowledge-ops/) | "Is our company wiki actually usable?" — SOP + runbook + KB hygiene |
|
||||
| [`procurement-optimizer`](skills/procurement-optimizer/) | "Why is software spend up 40% YoY?" — UNSPSC spend categorization + supplier consolidation |
|
||||
|
||||
## Commands
|
||||
|
||||
- `/cs:bizops <inquiry>` — top-level router
|
||||
- `/cs:grill-bizops <plan>` — Matt Pocock-style docs-anchored grilling
|
||||
- `/cs:process-map`, `/cs:vendor-review` — direct per-skill invocation
|
||||
- `/cs:process-map`, `/cs:vendor-review`, `/cs:capacity-plan`, `/cs:internal-comms`, `/cs:knowledge-ops`, `/cs:procurement` — direct per-skill invocation
|
||||
|
||||
## Agent
|
||||
|
||||
|
|
@ -26,8 +30,10 @@ v2.8.0 — 3 skills (Sprint 1) + 4 more in Sprint 2.
|
|||
|
||||
- `business-growth/` — external sales motion (CSM, sales engineering)
|
||||
- `c-level-advisor/coo-advisor` — strategic COO judgment (not tactical operations)
|
||||
- `c-level-advisor/vpe-advisor` — engineering throughput (capacity-planner is for non-eng ops)
|
||||
- `engineering/slo-architect` — system reliability (not business-process reliability)
|
||||
- `engineering/llm-wiki` — personal PKM (not company SOPs)
|
||||
- `engineering-team/runbook-generator` — system-ops runbooks (knowledge-ops is org-wide SOPs+runbooks)
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
29
business-operations/commands/cs-capacity-plan.md
Normal file
29
business-operations/commands/cs-capacity-plan.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
description: Model headcount + tooling capacity for ops teams (CX/Support/CS/BizOps/IT ops/Finance ops) using Erlang-C queueing math. Sizes the team around the bottleneck process-mapper found. NOT engineering capacity. Direct invocation of the capacity-planner skill.
|
||||
argument-hint: "<team + demand intake or path to capacity JSON>"
|
||||
---
|
||||
|
||||
# /cs:capacity-plan — Ops capacity sizing + utilization risk + hiring sequence
|
||||
|
||||
Run the `capacity-planner` skill on this input:
|
||||
|
||||
**$ARGUMENTS**
|
||||
|
||||
## Three-tool workflow
|
||||
|
||||
1. **`capacity_modeler.py`** — Erlang-C / queueing math: required FTE at 70/80/90% utilization, P(SLA breach) per utilization level, capacity headroom. Industry tuning `--profile {support,cx,bizops,finance-ops,it-ops}`.
|
||||
|
||||
2. **`utilization_analyzer.py`** — Red-zone detection per team member: >85% sustained = throughput collapse (Little's Law), <40% = under-loaded or wrong skills, variance >30% = unbalanced. Verdict: HEALTHY / SQUEEZED / OVERLOADED / UNBALANCED.
|
||||
|
||||
3. **`hiring_sequencer.py`** — 12-month quarterly hiring plan accounting for ramp curve (50% productive weeks 1-N, 100% after) + attrition + growth. Surfaces "manager trigger" point (span of control >7-8 ICs).
|
||||
|
||||
## Hard rule
|
||||
|
||||
**Never plan to 100% utilization.** Reinertsen 2009: utilization >80% in knowledge work destroys throughput via queueing.
|
||||
|
||||
## Distinct from
|
||||
|
||||
- `c-level-advisor/vpe-advisor` — engineering throughput specifically. Capacity-planner is for non-eng ops teams.
|
||||
- `c-level-advisor/chro-advisor` — strategic workforce planning. Capacity-planner is tactical sizing.
|
||||
- `business-operations/skills/process-mapper` (sibling) — finds the bottleneck. Capacity-planner sizes the team around it.
|
||||
- `project-management/*` — delivery tracking. Capacity-planner is forward sizing.
|
||||
30
business-operations/commands/cs-internal-comms.md
Normal file
30
business-operations/commands/cs-internal-comms.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
description: Internal-only change-management comms using ADKAR (Prosci) + Kotter's 8-step. NOT marketing (external) and NOT executive narrative strategy. Direct invocation of the internal-comms skill.
|
||||
argument-hint: "<change description: type, audience, magnitude, effective date>"
|
||||
---
|
||||
|
||||
# /cs:internal-comms — Internal change comms (ADKAR + Kotter)
|
||||
|
||||
Run the `internal-comms` skill on this input:
|
||||
|
||||
**$ARGUMENTS**
|
||||
|
||||
## Three-tool workflow
|
||||
|
||||
1. **`comms_template_filler.py`** — ADKAR-anchored comms package (pre-comm / announcement / FAQ / follow-up). Each touchpoint tagged with which ADKAR stage it serves (Awareness / Desire / Knowledge / Ability / Reinforcement).
|
||||
|
||||
2. **`change_announcement_builder.py`** — Kotter 8-step compliant announcement (Urgency → Coalition → Vision → Communicate → Empower → Wins → Sustain → Anchor). Validates: no "exciting news" on disruptive change, no "minor update" on high-magnitude change. Tone calibration via `--profile {tech-startup,scaleup,enterprise,public-company,non-profit}`.
|
||||
|
||||
3. **`comms_calendar_builder.py`** — 7-touchpoint sequencing (Prosci minimum for behavioral change). Flags gaps: 2-touchpoint plans for disruptive change, Slack-only for layoffs (anti-pattern — requires synchronous channel), magnitude mismatches.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- **Layoff comms** never go Slack-only. Synchronous channel required.
|
||||
- **Disruptive change** needs ≥ 5 touchpoints with manager-cascade enabled.
|
||||
- **Magnitude downplaying** ("minor restructuring" for 30% RIF) is auto-flagged.
|
||||
|
||||
## Distinct from
|
||||
|
||||
- `marketing-skill/*` — external-facing
|
||||
- `c-level-advisor/internal-narrative` — strategic narrative framing (CEO voice)
|
||||
- `c-level-advisor/change-management` — executive change strategy. Internal-comms is the tactical authoring layer underneath.
|
||||
25
business-operations/commands/cs-knowledge-ops.md
Normal file
25
business-operations/commands/cs-knowledge-ops.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
description: Company SOP + runbook authoring with 5W2H completeness checks. NOT personal PKM (that's llm-wiki). NOT engineering-specific runbooks. Direct invocation of the knowledge-ops skill.
|
||||
argument-hint: "<process / system / incident to document>"
|
||||
---
|
||||
|
||||
# /cs:knowledge-ops — Company SOPs + runbooks
|
||||
|
||||
Run the `knowledge-ops` skill on this input:
|
||||
|
||||
**$ARGUMENTS**
|
||||
|
||||
## Three-tool workflow
|
||||
|
||||
1. **`sop_generator.py`** — Standard Operating Procedure with 5W2H scaffolding (Who/What/When/Where/Why/How/How-much). Industry tuning `--profile {ops,support,finance,hr,it,regulated}` for compliance-tier scaffolding.
|
||||
|
||||
2. **`runbook_validator.py`** — Runbook completeness check: every step has owner, expected duration, observable success/failure signal, rollback path. Flags ambiguity ("verify the service is up" → "what's the verification command?").
|
||||
|
||||
3. **`kb_ingester.py`** — Markdown KB ingestion: cross-link detection, glossary drift, orphan-page detection.
|
||||
|
||||
## Distinct from
|
||||
|
||||
- `engineering/llm-wiki` — personal PKM (your second brain). Knowledge-ops is the **company** wiki.
|
||||
- `engineering-team/runbook-generator` — engineering-specific runbooks (system ops). Knowledge-ops is org-wide.
|
||||
- `project-management/*` — Jira/Confluence delivery tracking, not authoring.
|
||||
- `business-operations/skills/process-mapper` (sibling) — process *design*, not documentation.
|
||||
23
business-operations/commands/cs-procurement.md
Normal file
23
business-operations/commands/cs-procurement.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
description: Spend categorization + supplier rationalization + purchasing-cycle analysis. NOT vendor performance scoring (sibling vendor-management). NOT financial close (finance). Direct invocation of the procurement-optimizer skill.
|
||||
argument-hint: "<spend export path or category to analyze>"
|
||||
---
|
||||
|
||||
# /cs:procurement — Spend audit + supplier consolidation
|
||||
|
||||
Run the `procurement-optimizer` skill on this input:
|
||||
|
||||
**$ARGUMENTS**
|
||||
|
||||
## Three-tool workflow
|
||||
|
||||
1. **`spend_categorizer.py`** — UNSPSC-aligned category mapping + Pareto analysis (which 20% of categories drive 80% of spend). Industry tuning `--profile {tech-startup,scaleup,enterprise,services,manufacturing}`.
|
||||
|
||||
2. **`purchasing_cycle_analyzer.py`** — Time-to-PO, time-to-payment, approval-hop count by category. Flags categories with cycle time > 2× median.
|
||||
|
||||
3. **`supplier_consolidation.py`** — Identifies duplicate-function suppliers (e.g., 3 monitoring tools, 2 expense platforms) + risk-balanced consolidation plan (don't consolidate to single-source for tier-1 risk).
|
||||
|
||||
## Distinct from
|
||||
|
||||
- `business-operations/skills/vendor-management` (sibling) — performance scoring of vendors you keep paying. Procurement-optimizer is **spend** rationalization + supplier consolidation.
|
||||
- `finance/financial-analysis` — financial close + reporting. Procurement-optimizer is decision support, not reporting.
|
||||
252
business-operations/skills/capacity-planner/SKILL.md
Normal file
252
business-operations/skills/capacity-planner/SKILL.md
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
---
|
||||
name: capacity-planner
|
||||
description: "Use when an ops leader (Director of CX, Head of Support, VP Ops, Head of BizOps, Head of IT ops, Head of Finance ops) is sizing ops capacity, building a headcount plan, modeling utilization risk, planning Q3 capacity or annual support capacity, or designing CS coverage — and needs Erlang-C queueing math, P90 demand sizing, shrinkage-adjusted FTE, manager-trigger thresholds, and a quarterly hiring sequence with ramp + attrition. Apply when sustained team utilization is above 80% or when the team is growing >50% in 12 months. Run before committing the headcount budget. This is NOT engineering capacity (see vpe-advisor for DORA + cycle time) and NOT strategic 3-year workforce planning (see chro-advisor)."
|
||||
version: 2.8.0
|
||||
author: claude-code-skills
|
||||
license: MIT
|
||||
tags: [bizops, capacity, headcount, utilization, queueing-theory, ops-planning, little-law, workforce]
|
||||
compatible_tools: [claude-code, codex-cli, cursor, antigravity, opencode, gemini-cli]
|
||||
---
|
||||
|
||||
# capacity-planner
|
||||
|
||||
Sizing tool for **ops teams that handle queued work** — Support, CX,
|
||||
Customer Success, BizOps, IT ops, Finance ops. Built on Erlang-C
|
||||
queueing theory, Little's Law, and the operational-leadership canon
|
||||
(Fournier, Larson, Cleveland, Reinertsen). Deterministic, stdlib-only,
|
||||
no LLM calls.
|
||||
|
||||
## Purpose
|
||||
|
||||
You are an ops leader sized 15 → 35 with no idea how the 35-person org
|
||||
will actually behave at peak load. Or you are at 88% utilization and
|
||||
SLA is starting to slip. Or you have a hiring budget approved and need
|
||||
to sequence it across four quarters without burning out the existing
|
||||
team. This skill answers those questions with arithmetic, not vibes.
|
||||
|
||||
It produces three artifacts:
|
||||
|
||||
1. **Capacity sizing** at 70/80/90% utilization against P50/P90/P99
|
||||
demand, with P(SLA breach) at each point and a SAFE/WATCH/AT_RISK/CRITICAL
|
||||
risk band.
|
||||
2. **Utilization health** at the per-member traffic-light level plus a
|
||||
team verdict (HEALTHY/SQUEEZED/OVERLOADED/UNBALANCED).
|
||||
3. **12-month quarterly hiring plan** accounting for ramp curves,
|
||||
attrition, QoQ demand growth, and span-of-control manager triggers.
|
||||
|
||||
## When to use
|
||||
|
||||
- **Annual ops capacity planning** (October-November for the following
|
||||
fiscal year).
|
||||
- **Quarterly re-sizing** if demand changed >15% or attrition spiked.
|
||||
- **Pre-budget defense** — the math that justifies the headcount ask
|
||||
to your CFO.
|
||||
- **Diagnostic** when an ops team is missing SLA and you need to know
|
||||
whether it's a sizing problem, a process problem, or a bottleneck
|
||||
problem.
|
||||
- **M&A / new-segment launch** modeling — sizing a new team or
|
||||
combined org.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Intake demand**. Pull P50/P90/P99 daily ticket/case volume from
|
||||
your work system (Zendesk, Intercom, JSM, ServiceNow, Salesforce).
|
||||
If you only have averages, stop and pull the distribution. Single-
|
||||
point demand estimates are the most expensive anti-pattern in ops.
|
||||
2. **Model throughput**. Run `capacity_modeler.py` with your demand,
|
||||
AHT, SLA target, current FTE, and shrinkage. Use `--profile` for
|
||||
your function (support / cx / bizops / finance-ops / it-ops). Read
|
||||
the 80%-utilization row — that's your sizing point.
|
||||
3. **Flag utilization risk**. Run `utilization_analyzer.py` against
|
||||
your current team's actual utilization data. Anyone >85% sustained
|
||||
is a throughput-collapse risk per Reinertsen. Spread >30 percentage
|
||||
points across team means UNBALANCED — fix that before hiring.
|
||||
4. **Sequence hiring**. Run `hiring_sequencer.py` with current FTE,
|
||||
target EOY, ramp time, attrition, and growth. It will front-load
|
||||
hires (Q1 35%, Q4 15%), apply ramp curves, and trigger a manager
|
||||
hire when span of control crosses 7 ICs/manager.
|
||||
5. **Walk the Forcing-question library** (see below). One question at
|
||||
a time. Do not skip ahead. Answers must be written down before
|
||||
you commit the plan.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/capacity_modeler.py` — Erlang-C sizing with shrinkage
|
||||
adjustment and P50/P90/P99 breach probabilities. `--profile`
|
||||
for industry defaults.
|
||||
- `scripts/utilization_analyzer.py` — per-member traffic-light +
|
||||
team-level health verdict with variance detection.
|
||||
- `scripts/hiring_sequencer.py` — 12-month quarterly plan with ramp,
|
||||
attrition, growth, max-hires-per-quarter constraint, and
|
||||
manager-trigger logic.
|
||||
|
||||
All three accept `--input <path>` (JSON), `--output {markdown,json}`,
|
||||
`--sample` (built-in example), and `--help`. Stdlib only.
|
||||
|
||||
## References
|
||||
|
||||
- `references/queueing_theory_canon.md` — Erlang, Little, Hopp &
|
||||
Spearman, Reinertsen, Kingman, Cleveland, ITIL, Armony et al. (8
|
||||
sources). The math.
|
||||
- `references/ops_workforce_planning_canon.md` — Fournier, Larson,
|
||||
Google SRE Workbook, Frei, Lawler, Bersin, Gartner, Grove (8
|
||||
sources). The people factors.
|
||||
- `references/capacity_anti_patterns.md` — 11 named anti-patterns
|
||||
with cited sources, tool guards, and the meta-discipline that
|
||||
Lencioni + Goldratt + Christensen impose. (8+ named sources.)
|
||||
|
||||
## Assets
|
||||
|
||||
- `assets/capacity_brief_template.md` — 20-minute fill-out template
|
||||
with JSON skeletons for all three tools and an output checklist.
|
||||
|
||||
## Assumptions
|
||||
|
||||
This skill assumes:
|
||||
|
||||
- Work is **queued** (tickets, cases, work items) — not project-style.
|
||||
If your team's work isn't queued, this is the wrong skill.
|
||||
- Demand has a **stationary-enough distribution** within a quarter.
|
||||
Step-changes (new product launch, M&A, regulatory shift) require
|
||||
re-running mid-quarter.
|
||||
- You have **at least 90 days of historical demand data** to compute
|
||||
P50/P90/P99. If not, generate the distribution from your sales /
|
||||
user-base forecast first.
|
||||
- Service is **single-class** within a queue. If you have hard
|
||||
priority tiers (P1/P2/P3 with class-specific SLAs), model each as
|
||||
a separate queue and sum.
|
||||
- **Channels are modeled coherently.** Multi-channel teams use the
|
||||
appropriate `--profile` with built-in shrinkage premium.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
See `references/capacity_anti_patterns.md` for the full taxonomy with
|
||||
sources. Top eight:
|
||||
|
||||
1. Plan-to-100%-utilization (Reinertsen Principle 12)
|
||||
2. Treat-ramp-as-instant (Larson)
|
||||
3. Ignore-attrition-in-12-month-plan (Bersin)
|
||||
4. Hire-ICs-forever-with-no-manager-trigger (Fournier)
|
||||
5. Size-to-P50-demand-only (Cleveland)
|
||||
6. No-shrinkage-adjustment (Cleveland, SRE Workbook)
|
||||
7. Single-channel-model-for-multi-channel-work (Gartner, Kingman)
|
||||
8. No-surge-plan-for-P99-events (Hopp & Spearman, Reinertsen)
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **`c-level-advisor/vpe-advisor`** measures *engineering* throughput
|
||||
via DORA 4 metrics, story points, deployment frequency, and cycle
|
||||
time bottlenecks. It is for engineering teams shipping code. This
|
||||
skill is for ops teams handling tickets/cases. Different unit of
|
||||
work, different math (Erlang-C vs. DORA), different bottleneck
|
||||
(queueing-blind staffing vs. WIP + lead time).
|
||||
- **`c-level-advisor/chro-advisor`** does *strategic* workforce
|
||||
planning (1-5 year capability portfolios, talent supply, leadership
|
||||
succession). This skill does *operational* 0-12 month capacity
|
||||
sizing against demand. Per Lawler: conflating them gets you hired
|
||||
into the wrong jobs.
|
||||
- **`project-management/*`** tracks delivery throughput on projects
|
||||
(Jira velocity, sprint capacity). This skill sizes around steady-
|
||||
state queued work.
|
||||
- **Sibling `process-mapper`** *finds* the bottleneck. This skill
|
||||
*sizes the team around* a known bottleneck. Order of operations:
|
||||
process-mapper first → capacity-planner second. Hiring around the
|
||||
wrong constraint wastes the hires.
|
||||
- **`business-growth/cs-coverage`** (if it exists) sizes Customer
|
||||
Success coverage by ARR/CSM ratio and segment. This skill sizes by
|
||||
queued work volume (tickets, cases, escalations). For a CS team
|
||||
that handles both relationship work AND a ticket queue, run both.
|
||||
|
||||
## Forcing-question library (Matt Pocock grill discipline)
|
||||
|
||||
**Discipline**: walk these one at a time. Do not skip ahead. Answers must
|
||||
be written down. If you can't answer one, that is your next investigation.
|
||||
|
||||
### Q1 — "What is your bottleneck, and have you confirmed it empirically?"
|
||||
|
||||
**Recommended answer**: a named, measured stage in the workflow with
|
||||
queue-time data showing where work waits. Not a vibe. Not "escalations
|
||||
take too long". An actual measured queue.
|
||||
|
||||
**Why it's the first question**: Goldratt (*The Goal*, 1984) — every
|
||||
system has exactly one binding constraint at a time. Sizing around the
|
||||
wrong constraint wastes hires entirely. If you do not know your
|
||||
bottleneck, run `process-mapper` BEFORE this skill.
|
||||
|
||||
**Canon**: Eli Goldratt, *The Goal* (1984); Reinertsen, *Principles of
|
||||
Product Development Flow* (2009).
|
||||
|
||||
### Q2 — "What service trade-off are you accepting?"
|
||||
|
||||
**Recommended answer**: a written, explicit choice — fast vs. empathetic,
|
||||
broad vs. deep, low-cost vs. high-quality. Frances Frei is unambiguous:
|
||||
you cannot win all four. The team that tries wins zero.
|
||||
|
||||
**Why it matters**: AHT, SLA, and shrinkage inputs are the operational
|
||||
expression of this trade-off. If they don't agree (e.g., you set AHT for
|
||||
"empathy" but SLA for "speed"), the plan is internally inconsistent.
|
||||
|
||||
**Canon**: Frances Frei & Anne Morriss, *Uncommon Service* (HBR Press,
|
||||
2012).
|
||||
|
||||
### Q3 — "What's your demand P90, and what's the gap to your P99?"
|
||||
|
||||
**Recommended answer**: two specific numbers from the last 90 days of
|
||||
data, with the calendar context of each (e.g., "P90 was 480 tickets/day
|
||||
on normal Tuesdays; P99 was 720 on the day after the November release").
|
||||
A team sized to P50 misses SLA half the time. A team sized to P99
|
||||
overstaffs by 30-50%. P90 is the right operating sizing point per
|
||||
Cleveland.
|
||||
|
||||
**Canon**: Brad Cleveland, *Call Center Management on Fast Forward* (4th
|
||||
ed., 2019); A.K. Erlang, *The Theory of Probabilities and Telephone
|
||||
Conversations* (1909).
|
||||
|
||||
### Q4 — "At your planned utilization, what is P(SLA breach) at P90 and at P99?"
|
||||
|
||||
**Recommended answer**: two probabilities, computed (not guessed) from
|
||||
Erlang-C with your specific N, AHT, and SLA target. If P(breach at P90)
|
||||
> 10% you are understaffed at the sizing point. If P(breach at P99) >
|
||||
50% you have no surge plan and the next peak event will be visible to
|
||||
the CEO.
|
||||
|
||||
**Canon**: Erlang (1909); Hopp & Spearman, *Factory Physics* (3rd ed.,
|
||||
2008), VUT equation.
|
||||
|
||||
### Q5 — "Have you budgeted replacement hires for the attrition you'll see this year?"
|
||||
|
||||
**Recommended answer**: yes, with a specific number. At 30% annual
|
||||
attrition (Bersin BPO midpoint), a 20-FTE team loses ~6 people this year.
|
||||
If your "add 5 net" plan is actually a "hire 11" plan, the recruiting
|
||||
volume changes drastically. Anti-pattern #3.
|
||||
|
||||
**Canon**: Bersin/Deloitte talent benchmarks (2015-2023); Edward Lawler,
|
||||
*Strategic Workforce Planning* (USC CEO, 2008).
|
||||
|
||||
### Q6 — "When does span of control trigger a manager hire, and who is the candidate?"
|
||||
|
||||
**Recommended answer**: a specific quarter (from `hiring_sequencer.py`)
|
||||
and at least one identified candidate (internal lead or external hire).
|
||||
Past 7 ICs/manager, 1:1s degrade, feedback cycles slip, attrition
|
||||
climbs. Past 10 you have a coverage crisis. Hire the manager BEFORE
|
||||
crossing 10, not after.
|
||||
|
||||
**Canon**: Camille Fournier, *The Manager's Path* (O'Reilly, 2017),
|
||||
ch. 5; Andy Grove, *High Output Management* (1983).
|
||||
|
||||
### Q7 — "What is your surge plan for the P99 day?"
|
||||
|
||||
**Recommended answer**: an explicit, documented plan — overflow tier,
|
||||
BPO contracted capacity, on-call rotation, executive escalation tree,
|
||||
OR a written degradation contract that says "on P99 days we extend SLA
|
||||
to X minutes and notify customers proactively". If the answer is "we'll
|
||||
figure it out", the P99 day is a fire visible to the board.
|
||||
|
||||
**Canon**: Hopp & Spearman, *Factory Physics* (2008); Reinertsen (2009)
|
||||
on capacity-margin discipline.
|
||||
|
||||
---
|
||||
|
||||
**Walk these seven in order. One at a time. Write the answers down. The
|
||||
plan you submit is only as defensible as your answers to these seven
|
||||
questions.**
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
# Capacity Planning Brief — {{TEAM_NAME}}
|
||||
|
||||
> 20-minute fill-out. Bring this brief plus your last 90 days of ticket /
|
||||
> case / work-item data and you have everything needed to produce a
|
||||
> defensible Q+1 plan.
|
||||
|
||||
## Section 1 — Context (5 minutes)
|
||||
|
||||
- **Team name:** {{TEAM_NAME}}
|
||||
- **Function:** [support / cx / bizops / finance-ops / it-ops]
|
||||
- **Planning horizon:** [Q+1 / annual / 12-month rolling]
|
||||
- **Current headcount:** {{CURRENT_FTE}}
|
||||
- **Working hours/day:** {{WORKING_HOURS_PER_DAY}}
|
||||
- **Top business event driving this plan:** _(growth target, peak season,
|
||||
M&A integration, regulatory change, etc.)_
|
||||
|
||||
## Section 2 — Demand (5 minutes)
|
||||
|
||||
Pull from your ticketing system (Zendesk, Intercom, Jira Service
|
||||
Management, Salesforce, ServiceNow, etc.) the daily volume for the last
|
||||
90 days. Compute or read off:
|
||||
|
||||
- **P50 (median day):** {{P50_TICKETS_PER_DAY}}
|
||||
- **P90 (peak-band day):** {{P90_TICKETS_PER_DAY}}
|
||||
- **P99 (annual peak day):** {{P99_TICKETS_PER_DAY}}
|
||||
|
||||
> If you only have averages, this plan is built on sand. Pull the
|
||||
> distribution. (Anti-pattern #5: size-to-P50-only.)
|
||||
|
||||
- **Average handle time (AHT, minutes):** {{AHT_MINUTES}}
|
||||
- **SLA target (minutes to first response or resolution):** {{SLA_MINUTES}}
|
||||
- **Channels in scope:** _(voice / email / chat / async / multi)_
|
||||
- **Multi-channel premium expected:** [yes / no — if multi, add 15-25%]
|
||||
|
||||
## Section 3 — People Realities (5 minutes)
|
||||
|
||||
- **Shrinkage % (paid time NOT productive):** {{SHRINKAGE_PCT}}
|
||||
_(default if unknown: support 30, cx 32, bizops 25, finance-ops 22, it-ops 28)_
|
||||
- **Ramp time for new hire (weeks to full productivity):** {{RAMP_WEEKS}}
|
||||
_(default: support 8, cx 10, bizops 12, finance-ops 14, it-ops 10)_
|
||||
- **Annual attrition observed last 12 months:** {{ATTRITION_PCT}}
|
||||
_(default: support 30, cx 28, bizops 18, finance-ops 15, it-ops 20)_
|
||||
- **Max hires per quarter (recruiting + onboarding constraint):**
|
||||
{{MAX_HIRES_PER_QUARTER}}
|
||||
- **Current managers and span of control:** _(list manager names + their
|
||||
direct-report counts)_
|
||||
|
||||
## Section 4 — Strategic Constraints (5 minutes)
|
||||
|
||||
- **QoQ demand growth assumption:** {{GROWTH_QOQ_PCT}}
|
||||
- **Bottleneck identified upstream (via process-mapper or similar):**
|
||||
_(if you don't know your bottleneck, run process-mapper FIRST — sizing
|
||||
around the wrong constraint is wasted hires)_
|
||||
- **Service trade-off accepted:** _(per Frances Frei — pick which
|
||||
attributes to win: speed / empathy / breadth / cost)_
|
||||
- **Surge plan for P99 events:** _(overflow tier? BPO? on-call?
|
||||
documented degradation?)_
|
||||
|
||||
---
|
||||
|
||||
## Tool Inputs
|
||||
|
||||
### Input JSON for `capacity_modeler.py`
|
||||
|
||||
```json
|
||||
{
|
||||
"team_name": "{{TEAM_NAME}}",
|
||||
"demand": {
|
||||
"tickets_per_day_p50": {{P50_TICKETS_PER_DAY}},
|
||||
"tickets_per_day_p90": {{P90_TICKETS_PER_DAY}},
|
||||
"tickets_per_day_p99": {{P99_TICKETS_PER_DAY}}
|
||||
},
|
||||
"sla_target_minutes": {{SLA_MINUTES}},
|
||||
"current_fte": {{CURRENT_FTE}},
|
||||
"avg_handle_time_minutes": {{AHT_MINUTES}},
|
||||
"shrinkage_pct": {{SHRINKAGE_PCT}},
|
||||
"working_hours_per_day": {{WORKING_HOURS_PER_DAY}}
|
||||
}
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 scripts/capacity_modeler.py --input my_brief.json --profile support
|
||||
```
|
||||
|
||||
### Input JSON for `utilization_analyzer.py`
|
||||
|
||||
```json
|
||||
{
|
||||
"team_members": [
|
||||
{
|
||||
"name": "<name>",
|
||||
"role": "<role>",
|
||||
"utilization_pct": <0-100>,
|
||||
"handles_count": <int>,
|
||||
"hours_billable": <float>,
|
||||
"hours_capacity": <float>
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 scripts/utilization_analyzer.py --input team_util.json
|
||||
```
|
||||
|
||||
### Input JSON for `hiring_sequencer.py`
|
||||
|
||||
```json
|
||||
{
|
||||
"team_name": "{{TEAM_NAME}}",
|
||||
"current_fte": {{CURRENT_FTE}},
|
||||
"target_fte_end_of_year": {{TARGET_EOY_FTE}},
|
||||
"ramp_time_weeks": {{RAMP_WEEKS}},
|
||||
"attrition_rate_annual_pct": {{ATTRITION_PCT}},
|
||||
"growth_assumption_qoq_pct": {{GROWTH_QOQ_PCT}},
|
||||
"hiring_constraints": {
|
||||
"max_hires_per_quarter": {{MAX_HIRES_PER_QUARTER}}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 scripts/hiring_sequencer.py --input my_brief.json --profile support
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output Checklist
|
||||
|
||||
After running all three tools, you should have:
|
||||
|
||||
- [ ] **Erlang-C sizing**: required FTE at 70/80/90% utilization (size to 80%)
|
||||
- [ ] **Headroom %**: extra demand tolerable before SLA breaks (target >20%)
|
||||
- [ ] **Risk band**: SAFE / WATCH / AT_RISK / CRITICAL
|
||||
- [ ] **Team health verdict**: HEALTHY / SQUEEZED / OVERLOADED / UNBALANCED
|
||||
- [ ] **Quarterly hiring plan**: ICs + managers + expected attrition per quarter
|
||||
- [ ] **Manager-trigger callouts**: which quarter you add a manager
|
||||
- [ ] **Warnings**: any quarter where hiring constraint blocks your plan
|
||||
- [ ] **Forcing-question answers**: documented decisions on bottleneck,
|
||||
service trade-offs, surge plan, P99 strategy (see SKILL.md
|
||||
*Forcing-question library*)
|
||||
|
||||
## Sign-off
|
||||
|
||||
- **Prepared by:** ______________
|
||||
- **Reviewed by (finance + HR + CS leader):** ______________
|
||||
- **Decision and date:** ______________
|
||||
- **Re-test trigger:** _(quarterly review date or demand-level threshold
|
||||
that forces re-run)_
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
# Capacity Planning Anti-Patterns
|
||||
|
||||
Every ops leader who has missed a peak season has fallen into one or
|
||||
more of these patterns. The math (queueing-theory-canon.md) and the
|
||||
people factors (ops-workforce-planning-canon.md) make each of these
|
||||
predictably destructive. This reference enumerates the eight most
|
||||
common failure modes with sources and the specific guard each tool
|
||||
implements.
|
||||
|
||||
## The Anti-Patterns
|
||||
|
||||
### 1. Plan-to-100%-Utilization
|
||||
|
||||
**The mistake:** "We have 10 people billing 40 hours each, so we have
|
||||
400 hours of capacity. Demand is 380 hours. We're fine."
|
||||
|
||||
**Why it fails:** Erlang-C and Hopp & Spearman's VUT equation both show
|
||||
queue length grows as U/(1-U). At 95% utilization, average wait time is
|
||||
~19× the service time. At 99%, it's ~99×. Variability turns a
|
||||
"barely-covered" plan into nightly fires.
|
||||
|
||||
**Source:** Donald Reinertsen, *Principles of Product Development
|
||||
Flow* (2009), Principle 12: "We need to operate at lower levels of
|
||||
utilization."
|
||||
|
||||
**Tool guard:** `capacity_modeler.py` sizes against 70/80/90% scenarios
|
||||
and flags any sizing point above 85% with a Reinertsen-cited warning.
|
||||
|
||||
### 2. Treat-Ramp-as-Instant
|
||||
|
||||
**The mistake:** "We approved 8 new hires for Q3, so we have +8 FTE
|
||||
of capacity starting Q3."
|
||||
|
||||
**Why it fails:** A new T1 support hire is ~50% productive in weeks 1-8.
|
||||
A new BizOps analyst is closer to ~30% productive in weeks 1-12 because
|
||||
of tool sprawl and tribal knowledge. The "wait, they're not contributing
|
||||
yet" gap is when your team burns out.
|
||||
|
||||
**Source:** Will Larson, *Staff Engineer* (Stripe Press, 2021); Camille
|
||||
Fournier, *The Manager's Path* (O'Reilly, 2017).
|
||||
|
||||
**Tool guard:** `hiring_sequencer.py` applies a productivity factor
|
||||
that linearly ramps 50% → 100% over `ramp_time_weeks`, and front-loads
|
||||
hires (Q1 35%, Q2 30%, Q3 20%, Q4 15%) so EOY productivity catches the
|
||||
adjusted target.
|
||||
|
||||
### 3. Ignore-Attrition
|
||||
|
||||
**The mistake:** "We have 15 today. We need 35 by EOY. Hire 20."
|
||||
|
||||
**Why it fails:** At 30% annual attrition (BPO-industry midpoint), you
|
||||
will lose 4-5 of the original 15 during the year AND ~3-5 of your new
|
||||
hires before they fully ramp. The real gap is 28-30 hires, not 20.
|
||||
|
||||
**Source:** Bersin / Deloitte talent benchmarks (2015-2023); Edward
|
||||
Lawler, *Strategic Workforce Planning* (USC CEO, 2008).
|
||||
|
||||
**Tool guard:** `hiring_sequencer.py` requires `attrition_rate_annual_pct`
|
||||
and distributes attrition quarterly via compounded probability, adding
|
||||
the expected replacement hires to the gap calculation.
|
||||
|
||||
### 4. Hire-ICs-Forever
|
||||
|
||||
**The mistake:** "We don't need a manager — everyone's an
|
||||
individual contributor reporting to the director."
|
||||
|
||||
**Why it fails:** Fournier's research (and Andy Grove's *High Output
|
||||
Management* before her) is unambiguous: at 8-10+ direct reports, 1:1s
|
||||
degrade, feedback cycles slip, attrition climbs, and the director
|
||||
becomes the bottleneck. The cost shows up as attrition + ramp re-work,
|
||||
not as a missed SLA.
|
||||
|
||||
**Source:** Camille Fournier, *The Manager's Path*, ch. 5; Andy
|
||||
Grove, *High Output Management* (1983), ch. on managerial output.
|
||||
|
||||
**Tool guard:** `hiring_sequencer.py` triggers a manager hire when
|
||||
projected span of control exceeds 7 ICs per manager, reallocating one
|
||||
quarter's IC slot to a manager hire.
|
||||
|
||||
### 5. Size-to-P50-Demand-Only
|
||||
|
||||
**The mistake:** "Average daily volume is 320 tickets. We can handle
|
||||
that."
|
||||
|
||||
**Why it fails:** Demand is a distribution, not a number. If P50 is
|
||||
320 and P90 is 480, you will be staffed below SLA 10% of business
|
||||
days. Customers don't care that you hit SLA on average; they
|
||||
remember the day you didn't.
|
||||
|
||||
**Source:** Brad Cleveland, *Call Center Management on Fast Forward*
|
||||
(4th ed., 2019); A.K. Erlang (1909) on traffic distributions.
|
||||
|
||||
**Tool guard:** `capacity_modeler.py` requires P50, P90, AND P99
|
||||
demand inputs and sizes the recommendation to **P90** with breach
|
||||
probability reported at all three percentiles.
|
||||
|
||||
### 6. No-Shrinkage-Adjustment
|
||||
|
||||
**The mistake:** "Our agents work 8 hours a day, so 8 hours of
|
||||
capacity per agent."
|
||||
|
||||
**Why it fails:** 30% shrinkage is industry-typical. The 8 hours
|
||||
actually delivers ~5.6 productive hours after breaks, training,
|
||||
1:1s, sync meetings, ad-hoc interrupts, and the unspoken time spent
|
||||
recovering between high-cognitive-load contacts.
|
||||
|
||||
**Source:** Cleveland, *Call Center Management on Fast Forward*;
|
||||
Google SRE Workbook (2018) ch. 6 on toil budgets.
|
||||
|
||||
**Tool guard:** `capacity_modeler.py` requires `shrinkage_pct`,
|
||||
applies a profile default if not provided (support 30%, BizOps 25%,
|
||||
finance-ops 22%, IT-ops 28%), and outputs **loaded FTE** (post-shrinkage)
|
||||
distinct from **raw FTE** (Erlang-C agents).
|
||||
|
||||
### 7. Single-Channel-Model-for-Multi-Channel-Work
|
||||
|
||||
**The mistake:** "Sum Erlang-C of voice + chat + email = total
|
||||
required FTE."
|
||||
|
||||
**Why it fails:** Skill-switching cost. Demand-distribution mismatch
|
||||
(chat is bursty; email queues overnight; voice spikes at 10-11am).
|
||||
Real blended-agent productivity is 15-25% below the simple sum
|
||||
because handoff context-loss is taxed per switch. Gartner research
|
||||
consistently finds this premium.
|
||||
|
||||
**Source:** Gartner Customer Service & Support practice annual
|
||||
benchmarks (2015-2023); Sir J.F.C. Kingman (1961) on G/G/1 queues
|
||||
(variability amplifies wait time).
|
||||
|
||||
**Tool guard:** `capacity_modeler.py` `--profile` flag encodes
|
||||
channel-mix realities (support, cx profiles assume blended channels
|
||||
with a higher shrinkage default).
|
||||
|
||||
### 8. No-Surge-Plan-for-P99-Events
|
||||
|
||||
**The mistake:** "We're sized to P90 demand. The P99 day will be bad
|
||||
but it's only 1% of days."
|
||||
|
||||
**Why it fails:** P99 days correlate with the highest-revenue events
|
||||
(product launches, billing-cycle peaks, security incidents,
|
||||
regulatory deadlines). Missing SLA on those days has
|
||||
outsize commercial consequences relative to the calendar share.
|
||||
You need an explicit surge plan: overflow tiering, on-call rotation,
|
||||
contracted BPO overflow capacity, or a documented degradation contract.
|
||||
|
||||
**Source:** Hopp & Spearman, *Factory Physics* (3rd ed., 2008) on
|
||||
peak-demand staffing; Reinertsen, *Principles of Product Development
|
||||
Flow* on capacity-margin discipline.
|
||||
|
||||
**Tool guard:** `capacity_modeler.py` reports P(SLA breach) at P99 in
|
||||
all three utilization scenarios, surfacing whether your 80%-utilization
|
||||
sizing leaves you exposed on peak days.
|
||||
|
||||
## Additional Anti-Patterns Worth Naming
|
||||
|
||||
Beyond the eight, three more deserve mention because they appear in
|
||||
nearly every quarterly planning cycle:
|
||||
|
||||
### 9. Use-Last-Year's-AHT
|
||||
|
||||
Average handle time creeps. Product complexity grows. Self-service
|
||||
deflects the easy tickets, leaving harder ones in the queue. **Re-baseline
|
||||
AHT every quarter**, not annually. (Source: Cleveland.)
|
||||
|
||||
### 10. Conflate-Operational-with-Strategic-Planning
|
||||
|
||||
This skill is for the *next 12 months*. If you are planning for a 3-year
|
||||
automation reshape, you need chro-advisor or a strategic workforce plan,
|
||||
not Erlang-C. (Source: Lawler.)
|
||||
|
||||
### 11. Plan-Without-Demand-Forecast-Confidence-Interval
|
||||
|
||||
A single point estimate of "we'll handle 4,000 tickets/month next year"
|
||||
is a fiction. You need a forecast distribution. If sales forecasts a
|
||||
40% YoY growth, your demand P90 grows faster than your demand P50 (more
|
||||
variance). (Source: Kingman; Hopp-Spearman.)
|
||||
|
||||
## Christensen-Raynor on Resource Allocation
|
||||
|
||||
Clayton Christensen and Michael Raynor's *The Innovator's Solution*
|
||||
(HBR Press, 2003) makes the meta-point: **a company's actual strategy
|
||||
is what it staffs**, not what it says. If your capacity plan funds
|
||||
firefighting at 80% and improvement at 20%, your strategy is
|
||||
firefighting regardless of any PowerPoint deck. The capacity plan is
|
||||
where strategy meets payroll. Take it seriously.
|
||||
|
||||
## Lencioni and Goldratt: The Two Disciplines
|
||||
|
||||
Pat Lencioni's *The Five Dysfunctions of a Team* (2002) and Eli
|
||||
Goldratt's *The Goal* (1984) bookend the operational reality:
|
||||
|
||||
- **Lencioni**: trust + healthy conflict are prerequisites for
|
||||
capacity discussions to be honest. Teams that can't have direct
|
||||
conversations about whether someone is overloaded will silently
|
||||
fail the capacity plan.
|
||||
- **Goldratt**: subordinate everything to the bottleneck. If your
|
||||
bottleneck is escalation engineering, don't hire more T1s — you'll
|
||||
just queue more work at the choke point.
|
||||
|
||||
These appear in the *Forcing-question library* of SKILL.md.
|
||||
|
||||
## McKinsey + MIT Sloan on Queueing-Blind Staffing
|
||||
|
||||
McKinsey's Customer Care practice (2018-2023 reports) and MIT Sloan's
|
||||
Service Operations research repeatedly document the gap between
|
||||
"intuitive" staffing (manager judgment, headcount ratios) and
|
||||
queueing-theory staffing. The gap is empirically 15-35%: intuitive
|
||||
plans understaff at peak and overstaff at trough. The fix is not more
|
||||
intuition; it is the math in `capacity_modeler.py`.
|
||||
|
||||
## The Closing Discipline
|
||||
|
||||
For every capacity plan, ask three questions:
|
||||
|
||||
1. **What's the queueing math?** (Erlang-C, P90 demand, ≤80% util.)
|
||||
2. **What's the people reality?** (Ramp, attrition, span of control.)
|
||||
3. **What's the bottleneck?** (Capacity-planner sizes around a
|
||||
bottleneck; it does not find it. Use `process-mapper` first.)
|
||||
|
||||
Miss any of these and the plan is fiction.
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
# Ops Workforce Planning Canon
|
||||
|
||||
Capacity sizing is half the answer. The other half is the human reality
|
||||
of hiring, ramping, retaining, and structuring the people who staff the
|
||||
queue. This reference assembles the operational-leadership canon needed
|
||||
to translate an Erlang-C number into an executable 12-month plan.
|
||||
|
||||
## The Canon
|
||||
|
||||
### 1. Camille Fournier — *The Manager's Path* (O'Reilly, 2017)
|
||||
|
||||
Definitive guide to engineering management ladder, but the
|
||||
**span-of-control** chapters apply to any ops team. Key thresholds the
|
||||
`hiring_sequencer.py` enforces:
|
||||
|
||||
- **5-7 direct reports** is the healthy band for an ops manager.
|
||||
- **8-9** is the warning zone — the manager starts dropping 1:1s,
|
||||
feedback cycles slip, and team-level decisions queue.
|
||||
- **10+** = you have a coverage problem, not a leadership problem.
|
||||
Hire another manager BEFORE crossing 10.
|
||||
|
||||
Fournier also formalizes the **player-coach → pure manager → manager of
|
||||
managers** progression that gates when a team needs a director.
|
||||
|
||||
### 2. Will Larson — *Staff Engineer* (Stripe Press, 2021) and *An Elegant Puzzle*
|
||||
|
||||
Larson's chapter on **ramp time as a real cost** is the source for the
|
||||
"productive ~50% during ramp, 100% after" curve in
|
||||
`hiring_sequencer.py`. Empirical observations:
|
||||
|
||||
- Support T1 hires: 6-10 weeks to full ramp.
|
||||
- BizOps / Finance ops hires: 12-16 weeks (tool sprawl + tribal
|
||||
knowledge).
|
||||
- IT ops on-call rotation: 8-12 weeks before first solo on-call.
|
||||
|
||||
Larson's broader point: **hiring during a fire is too late**. The
|
||||
sequencer's front-loaded weight (Q1 35%, Q4 15%) is the operational
|
||||
expression of this principle.
|
||||
|
||||
### 3. Betsy Beyer, Niall Murphy, et al. — *The Site Reliability Workbook* (O'Reilly, 2018), Chapter 6: "Eliminating Toil"
|
||||
|
||||
Google SRE's framework for **toil budgets** maps directly to ops
|
||||
shrinkage. Key staffing principle:
|
||||
|
||||
- An on-call ops engineer should spend **≤50% on toil**, the rest on
|
||||
engineering work that reduces toil.
|
||||
- If the toil fraction exceeds 50% for >1 quarter, you are
|
||||
understaffed relative to incident volume.
|
||||
|
||||
The capacity-planner sibling skills (incident-coordinator,
|
||||
process-mapper) feed inputs into this; the workforce-planning
|
||||
implication is that "100% of paid hours = available capacity" is
|
||||
**always** wrong.
|
||||
|
||||
### 4. Frances Frei & Anne Morriss — *Uncommon Service* (HBR Press, 2012)
|
||||
|
||||
Frei's central argument: **you cannot deliver excellent service across
|
||||
all attributes simultaneously**. Service-design trade-offs — speed vs.
|
||||
empathy, breadth vs. depth, low cost vs. high quality — directly
|
||||
constrain how you size a team. A team chasing all four wins zero of
|
||||
them. Capacity-planner inputs (AHT, SLA, channel mix) implicitly encode
|
||||
which trade-off is being made; surfacing that explicitly in the
|
||||
*Forcing-question library* is what separates a competent ops leader
|
||||
from a guesser.
|
||||
|
||||
### 5. Edward Lawler — *Strategic Workforce Planning* (USC Marshall Center for Effective Organizations, 2008)
|
||||
|
||||
Lawler's research distinguishes **operational** workforce planning
|
||||
(this skill: 0-12 months, role-specific, demand-driven) from
|
||||
**strategic** workforce planning (CHRO's job: 1-5 years, capability
|
||||
portfolio, talent supply analysis). The hard rule:
|
||||
|
||||
- If you are sizing against next quarter's tickets, you need
|
||||
capacity-planner.
|
||||
- If you are sizing against the company's 3-year automation strategy,
|
||||
you need a strategic workforce plan (chro-advisor).
|
||||
- **Conflating them gets you hired into the wrong jobs.**
|
||||
|
||||
### 6. Bersin / Deloitte — *Talent Acquisition Maturity Model* and benchmarks (2015-2023)
|
||||
|
||||
Source for the empirically reasonable **attrition + replacement-hire
|
||||
defaults** in `hiring_sequencer.py` profiles. Bersin benchmarks:
|
||||
|
||||
- Support frontline: 25-35% annual attrition. The 30% default reflects
|
||||
the BPO-industry midpoint.
|
||||
- CX/Customer success: 22-28%. Slightly stickier than raw support due
|
||||
to relationship investment.
|
||||
- BizOps/Finance ops: 15-22%. Specialist + analytical work has lower
|
||||
turnover.
|
||||
- Open ops headcount fills in 45-90 days for T1, 90-180 days for
|
||||
T2/specialist.
|
||||
|
||||
These figures must be sanity-checked against your own HR data — they
|
||||
are starting points, not commitments.
|
||||
|
||||
### 7. Gartner Service Delivery Research — annual reports (Customer Service & Support practice)
|
||||
|
||||
Gartner's annual ops benchmarks codify the **multi-channel staffing
|
||||
premium**: a team that handles voice + email + chat needs ~15-25% MORE
|
||||
FTE than the simple-sum Erlang-C of each channel alone, because:
|
||||
|
||||
- Skill switching cost (context loss between channels).
|
||||
- Non-uniform demand distributions across channels.
|
||||
- The classic "blended-agent illusion" — agents claim to be 100%
|
||||
flexible across channels but their effective handle times degrade.
|
||||
|
||||
The `--profile` flag in capacity_modeler is the place to encode this;
|
||||
support and CX profiles assume multi-channel realities.
|
||||
|
||||
### 8. Andy Grove — *High Output Management* (1983, re-issued 1995)
|
||||
|
||||
Grove's framework for **leveraged output** — the manager's output is
|
||||
the output of her team plus the output of every team she influences.
|
||||
Applied to ops capacity:
|
||||
|
||||
- **A manager's "productive" contribution is not their own ticket
|
||||
handles** (which should approach zero past 6 directs); it's their
|
||||
effect on the team's throughput, accuracy, and retention.
|
||||
- This is why the hiring_sequencer counts managers separately from ICs
|
||||
and triggers manager hires preemptively at span-of-control limits.
|
||||
|
||||
## How These Connect to the Tools
|
||||
|
||||
| Tool | Primary Canon |
|
||||
|---|---|
|
||||
| `capacity_modeler.py` | Frei (service trade-offs encoded in inputs), Gartner (multi-channel) |
|
||||
| `utilization_analyzer.py` | SRE Workbook (toil budget = ceiling), Grove (manager leverage) |
|
||||
| `hiring_sequencer.py` | Fournier (span of control), Larson (ramp curves), Bersin (attrition), Lawler (operational vs. strategic) |
|
||||
|
||||
## The Hard Truths
|
||||
|
||||
1. **Ramp is real and is a 6-16 week tax.** Plans that assume new
|
||||
hires are productive day one are works of fiction.
|
||||
2. **You will lose 15-35% of your team this year.** Hiring plans that
|
||||
don't budget replacement hires understaff you by exactly that much.
|
||||
3. **You cannot manage 10+ direct reports.** Past 7-8, you are picking
|
||||
which directs to neglect.
|
||||
4. **Service trade-offs are non-negotiable.** Pick which dimensions to
|
||||
win and accept losses elsewhere — Frei's central thesis.
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
# Queueing Theory Canon for Ops Capacity Planning
|
||||
|
||||
Capacity planning for ops teams (Support, CX, BizOps, Finance ops, IT ops)
|
||||
without queueing theory is guesswork. The fundamental insight: as
|
||||
utilization approaches 100%, wait time approaches infinity non-linearly.
|
||||
Plan to 90% and your SLA collapses. Plan to 70-80% and you have surge
|
||||
capacity. The math is over 100 years old, and ignoring it is the most
|
||||
expensive mistake an ops leader makes.
|
||||
|
||||
## The Canon
|
||||
|
||||
### 1. A.K. Erlang (1909) — *The Theory of Probabilities and Telephone Conversations*
|
||||
|
||||
Erlang's seminal paper on telephone-traffic load. Introduced the Erlang
|
||||
unit of offered load (a = arrival_rate × service_time) and gave the
|
||||
formula now called Erlang-B (loss systems) and Erlang-C (waiting systems).
|
||||
**Erlang-C** is what you want for any ops queue where tickets/calls wait
|
||||
rather than being dropped:
|
||||
|
||||
```
|
||||
P(wait) = (a^N / N!) * (N / (N - a)) / [ sum_{k=0..N-1} a^k/k! + (a^N/N!)*(N/(N-a)) ]
|
||||
```
|
||||
|
||||
Where N is the number of agents, a is the offered load. **This is the
|
||||
single most important formula in ops capacity planning.** Implemented in
|
||||
`scripts/capacity_modeler.py` in ~30 lines of stdlib Python.
|
||||
|
||||
### 2. J.D.C. Little (1961) — *A Proof for the Queuing Formula L = λW*
|
||||
|
||||
**Little's Law**: in steady state, the average number of items in a queue
|
||||
(L) equals the average arrival rate (λ) multiplied by the average time an
|
||||
item spends in the system (W). Three implications for ops leaders:
|
||||
|
||||
- **You cannot pick L, λ, and W independently.** If demand (λ) doubles
|
||||
and headcount (L capacity) stays flat, wait time (W) must double — and
|
||||
via Erlang-C, it actually grows much faster than that near saturation.
|
||||
- **Reducing average handle time** is mathematically equivalent to
|
||||
hiring, up to the utilization ceiling.
|
||||
- **WIP limits work** because they put a hard cap on L, which (given fixed
|
||||
capacity throughput) directly caps W.
|
||||
|
||||
### 3. Hopp & Spearman — *Factory Physics* (3rd ed., 2008)
|
||||
|
||||
The bible of operations science applied to manufacturing. Chapters 8-9
|
||||
cover variability and queueing rigorously. Key takeaway for ops leaders:
|
||||
**the VUT equation** for cycle time at a workstation,
|
||||
|
||||
```
|
||||
CT_q ≈ V × U × T
|
||||
```
|
||||
|
||||
where V is variability (coefficient-of-variation squared), U is utilization
|
||||
factor U/(1-U), and T is mean service time. Notice U/(1-U): at U=0.80, the
|
||||
multiplier is 4. At U=0.90, it's 9. At U=0.95, it's 19. **This is why
|
||||
"plan to 100% utilization" is the most expensive sentence in ops.**
|
||||
|
||||
### 4. Donald Reinertsen — *The Principles of Product Development Flow* (2009)
|
||||
|
||||
The most important book on queueing in knowledge work. Principle 7
|
||||
("Queue size, not capacity utilization, is the primary control variable")
|
||||
and Principle 12 ("We need to operate at lower levels of utilization")
|
||||
make the case rigorously: **80% utilization is the safe operating ceiling
|
||||
for variable-arrival queues**. Past that, queue length and cycle time
|
||||
explode super-linearly. Reinertsen's diagnostic chart of "% utilization
|
||||
vs. queue length" should be hanging in every ops leader's office.
|
||||
|
||||
### 5. Sir J.F.C. Kingman — *On Queues in Heavy Traffic* (1961)
|
||||
|
||||
**Kingman's formula** for a G/G/1 queue (general arrival + general
|
||||
service distribution, single server):
|
||||
|
||||
```
|
||||
E[W_q] ≈ (ρ / (1-ρ)) × ((c_a^2 + c_s^2) / 2) × τ
|
||||
```
|
||||
|
||||
where ρ is utilization, c_a and c_s are coefficients of variation for
|
||||
arrivals and service, τ is mean service time. **Implication: variability
|
||||
in either arrivals or service amplifies wait time as much as utilization
|
||||
does.** This is why bursty channels (email tickets that all arrive at
|
||||
9am Monday) require MORE staffing slack than steady channels.
|
||||
|
||||
### 6. Brad Cleveland — *Call Center Management on Fast Forward* (4th ed., 2019)
|
||||
|
||||
The applied operating manual for service-level queues. Conventions
|
||||
codified by Cleveland and used in `scripts/capacity_modeler.py`:
|
||||
|
||||
- Size to **P90 demand** (not P50, not P99) — P50 leaves you breaking
|
||||
SLA half the time; P99 over-staffs by 30-50%.
|
||||
- **Shrinkage** must be a line item. 30% is a reasonable default for
|
||||
support (training, breaks, sync meetings, PTO, ad-hoc interrupts).
|
||||
- **Service level** is the right SLA metric, not abandon rate alone:
|
||||
"answered within T seconds" as a probability.
|
||||
|
||||
### 7. ITIL 4 Service Management Practices (Axelos, 2019)
|
||||
|
||||
ITIL's *Service Operation* practice guidance codifies the canonical
|
||||
demand-and-capacity-management process for IT ops teams. Key constructs
|
||||
borrowed:
|
||||
|
||||
- **Demand management** = forecasting + smoothing (e.g., release calendars
|
||||
scheduling fewer changes during peak ticket windows).
|
||||
- **Capacity management** = three sub-processes: business capacity
|
||||
(forecast), service capacity (workload analysis), component capacity
|
||||
(resource-level).
|
||||
- **Service-level management** = the SLA contract that ties Erlang-C
|
||||
inputs to commitments.
|
||||
|
||||
### 8. M. Armony, S. Israelit, A. Mandelbaum, et al. — *On Patient Flow in Hospitals* (Stochastic Systems, 2015)
|
||||
|
||||
Modern empirical research on multi-class queues with priorities and
|
||||
abandonment — directly applicable to multi-tier support (T1/T2/T3 with
|
||||
escalation paths). Empirically validates that **abandonment plus
|
||||
priority routing** in a real call/ticket center produces wait-time
|
||||
distributions very close to G/G/c with class-specific service rates.
|
||||
Confirms Erlang-C is the right "first model" for capacity sizing.
|
||||
|
||||
## How These Connect to the Tools
|
||||
|
||||
| Tool | Primary Canon |
|
||||
|---|---|
|
||||
| `capacity_modeler.py` | Erlang (1909) Erlang-C, Cleveland (sizing convention) |
|
||||
| `utilization_analyzer.py` | Reinertsen (>80% threshold), Hopp-Spearman VUT, Little's Law |
|
||||
| `hiring_sequencer.py` | Cleveland (shrinkage), Kingman (variability premium), ITIL (demand management) |
|
||||
|
||||
## The One-Sentence Summary
|
||||
|
||||
If you remember nothing else: **never plan an ops team to above 80%
|
||||
sustained utilization** — Reinertsen Principle 12, validated by Hopp &
|
||||
Spearman's VUT equation and Erlang's 1909 telephone-traffic math. The
|
||||
arithmetic is unforgiving.
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
#!/usr/bin/env python3
|
||||
"""capacity_modeler.py — Ops capacity sizing via Erlang-C queueing math.
|
||||
|
||||
Sizes an ops team (Support / CX / BizOps / Finance ops / IT ops) against
|
||||
demand and an SLA target. Implements Erlang-C in pure stdlib to compute:
|
||||
|
||||
* Required FTE at 70%, 80%, and 90% utilization
|
||||
* Probability of SLA breach at each utilization level
|
||||
* Capacity headroom — extra tickets/day before SLA breaks
|
||||
|
||||
Industry profiles tune default shrinkage and SLA conventions.
|
||||
|
||||
Stdlib only. No LLM calls. Deterministic. Save the JSON sample for shape.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Industry profiles
|
||||
# ---------------------------------------------------------------------------
|
||||
PROFILES: dict[str, dict[str, float]] = {
|
||||
# shrinkage = % of paid time NOT available for productive ticket-handling
|
||||
# (training, breaks, sync, PTO accrual, ad-hoc interrupts)
|
||||
"support": {"shrinkage_pct_default": 30.0, "sla_target_minutes_default": 60.0},
|
||||
"cx": {"shrinkage_pct_default": 32.0, "sla_target_minutes_default": 30.0},
|
||||
"bizops": {"shrinkage_pct_default": 25.0, "sla_target_minutes_default": 240.0},
|
||||
"finance-ops": {"shrinkage_pct_default": 22.0, "sla_target_minutes_default": 480.0},
|
||||
"it-ops": {"shrinkage_pct_default": 28.0, "sla_target_minutes_default": 120.0},
|
||||
}
|
||||
|
||||
|
||||
class RiskBand(str, Enum):
|
||||
SAFE = "SAFE"
|
||||
WATCH = "WATCH"
|
||||
AT_RISK = "AT_RISK"
|
||||
CRITICAL = "CRITICAL"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Erlang-C — pure stdlib implementation
|
||||
# ---------------------------------------------------------------------------
|
||||
def erlang_c_probability(agents: int, traffic_intensity: float) -> float:
|
||||
"""Erlang-C: probability an arriving call/ticket has to wait.
|
||||
|
||||
agents (N) : number of servers
|
||||
traffic_intensity (a) : offered load in Erlangs (lambda * AHT)
|
||||
dimensionless; must satisfy a < N for stability.
|
||||
|
||||
Returns P(wait) in [0, 1]. Returns 1.0 if system unstable (a >= N).
|
||||
"""
|
||||
if agents <= 0:
|
||||
return 1.0
|
||||
if traffic_intensity <= 0:
|
||||
return 0.0
|
||||
if traffic_intensity >= agents:
|
||||
return 1.0
|
||||
|
||||
# Numerator: a^N / N! * N / (N - a)
|
||||
# Denominator: sum_{k=0}^{N-1} a^k / k! + numerator
|
||||
# Computed in log-space to avoid overflow on big numbers.
|
||||
a = traffic_intensity
|
||||
n = agents
|
||||
# log(a^n / n!) = n*log(a) - lgamma(n+1)
|
||||
log_a_n_over_nfact = n * math.log(a) - math.lgamma(n + 1)
|
||||
numerator_term = math.exp(log_a_n_over_nfact) * (n / (n - a))
|
||||
|
||||
sum_terms = 0.0
|
||||
for k in range(n):
|
||||
log_term = k * math.log(a) - math.lgamma(k + 1)
|
||||
sum_terms += math.exp(log_term)
|
||||
|
||||
denom = sum_terms + numerator_term
|
||||
if denom <= 0:
|
||||
return 1.0
|
||||
return numerator_term / denom
|
||||
|
||||
|
||||
def service_level(agents: int, traffic_intensity: float,
|
||||
aht_seconds: float, sla_target_seconds: float) -> float:
|
||||
"""P(answered within SLA target) for M/M/c queue.
|
||||
|
||||
SL = 1 - P_wait * exp(-(N - a) * T / AHT)
|
||||
"""
|
||||
pw = erlang_c_probability(agents, traffic_intensity)
|
||||
if agents <= traffic_intensity:
|
||||
return 0.0
|
||||
exponent = -(agents - traffic_intensity) * (sla_target_seconds / aht_seconds)
|
||||
# guard against overflow
|
||||
if exponent < -700:
|
||||
return 1.0 - pw * 0.0
|
||||
return 1.0 - pw * math.exp(exponent)
|
||||
|
||||
|
||||
def required_agents_for_utilization(traffic_intensity: float,
|
||||
target_utilization: float) -> int:
|
||||
"""Minimum N such that traffic_intensity / N <= target_utilization."""
|
||||
if target_utilization <= 0 or target_utilization >= 1:
|
||||
raise ValueError("target_utilization must be in (0,1)")
|
||||
n = math.ceil(traffic_intensity / target_utilization)
|
||||
return max(n, 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data model
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class Demand:
|
||||
tickets_per_day_p50: float
|
||||
tickets_per_day_p90: float
|
||||
tickets_per_day_p99: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class CapacityInput:
|
||||
team_name: str
|
||||
demand: Demand
|
||||
sla_target_minutes: float
|
||||
current_fte: float
|
||||
avg_handle_time_minutes: float
|
||||
shrinkage_pct: float
|
||||
working_hours_per_day: float = 8.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class UtilizationScenario:
|
||||
target_utilization: float
|
||||
required_fte_raw: int # before shrinkage
|
||||
required_fte_loaded: float # after shrinkage
|
||||
p_sla_breach_p50: float
|
||||
p_sla_breach_p90: float
|
||||
p_sla_breach_p99: float
|
||||
actual_utilization_at_demand: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class CapacityResult:
|
||||
team_name: str
|
||||
inputs: CapacityInput
|
||||
scenarios: list[UtilizationScenario] = field(default_factory=list)
|
||||
headroom_extra_tickets_per_day: float = 0.0
|
||||
headroom_pct: float = 0.0
|
||||
risk_band: RiskBand = RiskBand.SAFE
|
||||
recommendation: str = ""
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modeling
|
||||
# ---------------------------------------------------------------------------
|
||||
def model_capacity(inp: CapacityInput) -> CapacityResult:
|
||||
aht_sec = inp.avg_handle_time_minutes * 60.0
|
||||
sla_sec = inp.sla_target_minutes * 60.0
|
||||
|
||||
seconds_per_day_per_fte = inp.working_hours_per_day * 3600.0
|
||||
productive_fraction = max(0.0, 1.0 - inp.shrinkage_pct / 100.0)
|
||||
|
||||
def traffic_for(volume_per_day: float) -> float:
|
||||
# Erlang offered load (a) = arrival_rate * AHT, in consistent time units.
|
||||
# Per-day arrival rate normalized to per-second:
|
||||
arrival_per_sec = volume_per_day / seconds_per_day_per_fte
|
||||
return arrival_per_sec * aht_sec
|
||||
|
||||
a_p50 = traffic_for(inp.demand.tickets_per_day_p50)
|
||||
a_p90 = traffic_for(inp.demand.tickets_per_day_p90)
|
||||
a_p99 = traffic_for(inp.demand.tickets_per_day_p99)
|
||||
|
||||
scenarios: list[UtilizationScenario] = []
|
||||
for util in (0.70, 0.80, 0.90):
|
||||
# Sizing is done against P90 demand by convention (Cleveland).
|
||||
n_raw = required_agents_for_utilization(a_p90, util)
|
||||
# Loaded headcount accounts for shrinkage: each "agent slot" needs
|
||||
# 1 / productive_fraction headcount to staff it.
|
||||
n_loaded = n_raw / productive_fraction if productive_fraction > 0 else float("inf")
|
||||
|
||||
sl_p50 = service_level(n_raw, a_p50, aht_sec, sla_sec)
|
||||
sl_p90 = service_level(n_raw, a_p90, aht_sec, sla_sec)
|
||||
sl_p99 = service_level(n_raw, a_p99, aht_sec, sla_sec)
|
||||
|
||||
scenarios.append(UtilizationScenario(
|
||||
target_utilization=util,
|
||||
required_fte_raw=n_raw,
|
||||
required_fte_loaded=round(n_loaded, 2),
|
||||
p_sla_breach_p50=round(1.0 - sl_p50, 4),
|
||||
p_sla_breach_p90=round(1.0 - sl_p90, 4),
|
||||
p_sla_breach_p99=round(1.0 - sl_p99, 4),
|
||||
actual_utilization_at_demand=round(a_p90 / n_raw, 4) if n_raw > 0 else 1.0,
|
||||
))
|
||||
|
||||
# Headroom: with current_fte (loaded), how many extra tickets/day before
|
||||
# P(SLA breach) at P90 crosses 10%?
|
||||
current_productive_fte = max(1, int(round(inp.current_fte * productive_fraction)))
|
||||
headroom_volume = inp.demand.tickets_per_day_p90
|
||||
step = max(1.0, inp.demand.tickets_per_day_p90 * 0.02)
|
||||
while headroom_volume < inp.demand.tickets_per_day_p90 * 5:
|
||||
a = traffic_for(headroom_volume)
|
||||
if a >= current_productive_fte:
|
||||
break
|
||||
sl = service_level(current_productive_fte, a, aht_sec, sla_sec)
|
||||
if (1.0 - sl) > 0.10:
|
||||
break
|
||||
headroom_volume += step
|
||||
headroom_extra = max(0.0, headroom_volume - inp.demand.tickets_per_day_p90)
|
||||
headroom_pct = (headroom_extra / inp.demand.tickets_per_day_p90 * 100.0
|
||||
if inp.demand.tickets_per_day_p90 > 0 else 0.0)
|
||||
|
||||
# Risk band — pick from 80%-utilization scenario (canonical sizing point)
|
||||
s80 = next(s for s in scenarios if s.target_utilization == 0.80)
|
||||
if inp.current_fte >= s80.required_fte_loaded and headroom_pct >= 20:
|
||||
band = RiskBand.SAFE
|
||||
rec = (f"Sized correctly at {inp.current_fte} FTE for P90 demand at 80% "
|
||||
f"utilization. Headroom is healthy ({headroom_pct:.0f}%).")
|
||||
elif inp.current_fte >= s80.required_fte_loaded:
|
||||
band = RiskBand.WATCH
|
||||
rec = (f"Headcount adequate ({inp.current_fte} FTE vs. "
|
||||
f"{s80.required_fte_loaded} required) but headroom thin "
|
||||
f"({headroom_pct:.0f}%). Re-test in 30 days.")
|
||||
elif inp.current_fte >= s80.required_fte_loaded * 0.85:
|
||||
band = RiskBand.AT_RISK
|
||||
rec = (f"Understaffed for P90 demand: have {inp.current_fte}, need "
|
||||
f"{s80.required_fte_loaded} at 80% utilization. Expect SLA "
|
||||
f"misses at P90 surges. Hire {math.ceil(s80.required_fte_loaded - inp.current_fte)} FTE.")
|
||||
else:
|
||||
band = RiskBand.CRITICAL
|
||||
rec = (f"Critically understaffed: have {inp.current_fte}, need "
|
||||
f"{s80.required_fte_loaded}. Throughput collapse risk per "
|
||||
f"queueing theory at sustained >85% utilization. Escalate.")
|
||||
|
||||
notes: list[str] = []
|
||||
if s80.actual_utilization_at_demand > 0.85:
|
||||
notes.append(
|
||||
"WARNING: Sizing point pushes >85% utilization. Reinertsen's "
|
||||
"principle 7: throughput collapses non-linearly past 80%."
|
||||
)
|
||||
if inp.shrinkage_pct < 15:
|
||||
notes.append("Shrinkage <15% likely understates non-productive time.")
|
||||
if inp.shrinkage_pct > 40:
|
||||
notes.append("Shrinkage >40% — verify against actual time-on-task data.")
|
||||
|
||||
return CapacityResult(
|
||||
team_name=inp.team_name,
|
||||
inputs=inp,
|
||||
scenarios=scenarios,
|
||||
headroom_extra_tickets_per_day=round(headroom_extra, 1),
|
||||
headroom_pct=round(headroom_pct, 1),
|
||||
risk_band=band,
|
||||
recommendation=rec,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
def to_markdown(result: CapacityResult) -> str:
|
||||
inp = result.inputs
|
||||
lines = [
|
||||
f"# Capacity Model — {result.team_name}",
|
||||
"",
|
||||
f"**Risk band:** {result.risk_band.value}",
|
||||
"",
|
||||
f"**Recommendation:** {result.recommendation}",
|
||||
"",
|
||||
"## Inputs",
|
||||
f"- Current FTE: {inp.current_fte}",
|
||||
f"- AHT: {inp.avg_handle_time_minutes} min",
|
||||
f"- SLA target: {inp.sla_target_minutes} min",
|
||||
f"- Shrinkage: {inp.shrinkage_pct}%",
|
||||
f"- Working hours / day: {inp.working_hours_per_day}",
|
||||
f"- Demand P50 / P90 / P99: {inp.demand.tickets_per_day_p50} / "
|
||||
f"{inp.demand.tickets_per_day_p90} / {inp.demand.tickets_per_day_p99} tickets/day",
|
||||
"",
|
||||
"## Sizing Scenarios (Erlang-C, sized to P90 demand)",
|
||||
"",
|
||||
"| Target Util | Raw FTE | Loaded FTE (post-shrinkage) | P(SLA breach @ P50) | P(SLA breach @ P90) | P(SLA breach @ P99) |",
|
||||
"|---|---|---|---|---|---|",
|
||||
]
|
||||
for s in result.scenarios:
|
||||
lines.append(
|
||||
f"| {int(s.target_utilization*100)}% | {s.required_fte_raw} | "
|
||||
f"{s.required_fte_loaded} | {s.p_sla_breach_p50*100:.1f}% | "
|
||||
f"{s.p_sla_breach_p90*100:.1f}% | {s.p_sla_breach_p99*100:.1f}% |"
|
||||
)
|
||||
lines.extend([
|
||||
"",
|
||||
"## Headroom",
|
||||
f"- Extra tickets/day before SLA breaks: {result.headroom_extra_tickets_per_day}",
|
||||
f"- Headroom %: {result.headroom_pct:.1f}%",
|
||||
"",
|
||||
])
|
||||
if result.notes:
|
||||
lines.append("## Notes")
|
||||
for n in result.notes:
|
||||
lines.append(f"- {n}")
|
||||
lines.append("")
|
||||
lines.append("## Canon")
|
||||
lines.append("- Erlang (1909), Little (1961), Cleveland *Call Center Mgmt on Fast Forward*, Reinertsen *Principles of Product Development Flow*.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def to_dict(result: CapacityResult) -> dict[str, Any]:
|
||||
return {
|
||||
"team_name": result.team_name,
|
||||
"risk_band": result.risk_band.value,
|
||||
"recommendation": result.recommendation,
|
||||
"headroom_extra_tickets_per_day": result.headroom_extra_tickets_per_day,
|
||||
"headroom_pct": result.headroom_pct,
|
||||
"scenarios": [
|
||||
{
|
||||
"target_utilization": s.target_utilization,
|
||||
"required_fte_raw": s.required_fte_raw,
|
||||
"required_fte_loaded": s.required_fte_loaded,
|
||||
"p_sla_breach_p50": s.p_sla_breach_p50,
|
||||
"p_sla_breach_p90": s.p_sla_breach_p90,
|
||||
"p_sla_breach_p99": s.p_sla_breach_p99,
|
||||
"actual_utilization_at_demand": s.actual_utilization_at_demand,
|
||||
}
|
||||
for s in result.scenarios
|
||||
],
|
||||
"notes": result.notes,
|
||||
"inputs": {
|
||||
"current_fte": result.inputs.current_fte,
|
||||
"avg_handle_time_minutes": result.inputs.avg_handle_time_minutes,
|
||||
"sla_target_minutes": result.inputs.sla_target_minutes,
|
||||
"shrinkage_pct": result.inputs.shrinkage_pct,
|
||||
"working_hours_per_day": result.inputs.working_hours_per_day,
|
||||
"demand": {
|
||||
"tickets_per_day_p50": result.inputs.demand.tickets_per_day_p50,
|
||||
"tickets_per_day_p90": result.inputs.demand.tickets_per_day_p90,
|
||||
"tickets_per_day_p99": result.inputs.demand.tickets_per_day_p99,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sample + parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
SAMPLE_INPUT: dict[str, Any] = {
|
||||
"team_name": "Tier-1 Support",
|
||||
"demand": {
|
||||
"tickets_per_day_p50": 320,
|
||||
"tickets_per_day_p90": 480,
|
||||
"tickets_per_day_p99": 720,
|
||||
},
|
||||
"sla_target_minutes": 60,
|
||||
"current_fte": 12,
|
||||
"avg_handle_time_minutes": 18,
|
||||
"shrinkage_pct": 30,
|
||||
"working_hours_per_day": 8,
|
||||
}
|
||||
|
||||
|
||||
def parse_input(raw: dict[str, Any], profile: str | None) -> CapacityInput:
|
||||
prof = PROFILES.get(profile or "", {})
|
||||
shrinkage = raw.get("shrinkage_pct", prof.get("shrinkage_pct_default", 30.0))
|
||||
sla = raw.get("sla_target_minutes",
|
||||
prof.get("sla_target_minutes_default", 60.0))
|
||||
d = raw["demand"]
|
||||
return CapacityInput(
|
||||
team_name=raw["team_name"],
|
||||
demand=Demand(
|
||||
tickets_per_day_p50=float(d["tickets_per_day_p50"]),
|
||||
tickets_per_day_p90=float(d["tickets_per_day_p90"]),
|
||||
tickets_per_day_p99=float(d["tickets_per_day_p99"]),
|
||||
),
|
||||
sla_target_minutes=float(sla),
|
||||
current_fte=float(raw["current_fte"]),
|
||||
avg_handle_time_minutes=float(raw["avg_handle_time_minutes"]),
|
||||
shrinkage_pct=float(shrinkage),
|
||||
working_hours_per_day=float(raw.get("working_hours_per_day", 8.0)),
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Erlang-C ops capacity sizer (stdlib only).",
|
||||
)
|
||||
p.add_argument("--input", type=Path, help="Path to JSON input file.")
|
||||
p.add_argument(
|
||||
"--profile",
|
||||
choices=list(PROFILES.keys()),
|
||||
default=None,
|
||||
help="Industry profile (defaults for shrinkage + SLA).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--output",
|
||||
choices=["markdown", "json"],
|
||||
default="markdown",
|
||||
help="Output format.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--sample",
|
||||
action="store_true",
|
||||
help="Run on built-in sample input and print result.",
|
||||
)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.sample:
|
||||
raw = SAMPLE_INPUT
|
||||
elif args.input:
|
||||
raw = json.loads(args.input.read_text())
|
||||
else:
|
||||
p.error("Provide --input or --sample.")
|
||||
return 2
|
||||
|
||||
try:
|
||||
inp = parse_input(raw, args.profile)
|
||||
except (KeyError, ValueError) as e:
|
||||
print(f"ERROR parsing input: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
result = model_capacity(inp)
|
||||
|
||||
if args.output == "json":
|
||||
print(json.dumps(to_dict(result), indent=2))
|
||||
else:
|
||||
print(to_markdown(result))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,352 @@
|
|||
#!/usr/bin/env python3
|
||||
"""hiring_sequencer.py — 12-month quarterly hiring plan for ops teams.
|
||||
|
||||
Accounts for:
|
||||
* Ramp time (productive ~50% for ramp_time_weeks, then 100%)
|
||||
* Annual attrition (compounded weekly across the year)
|
||||
* Quarter-over-quarter demand growth
|
||||
* Hiring constraints (max hires / quarter)
|
||||
* Manager-trigger: when span of control crosses 7-8 ICs, schedule manager hire
|
||||
|
||||
Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# Industry profiles — typical ramp + attrition
|
||||
PROFILES: dict[str, dict[str, float]] = {
|
||||
"support": {"ramp_time_weeks": 8.0, "attrition_rate_annual_pct": 30.0},
|
||||
"cx": {"ramp_time_weeks": 10.0, "attrition_rate_annual_pct": 28.0},
|
||||
"bizops": {"ramp_time_weeks": 12.0, "attrition_rate_annual_pct": 18.0},
|
||||
"finance-ops":{"ramp_time_weeks": 14.0, "attrition_rate_annual_pct": 15.0},
|
||||
"it-ops": {"ramp_time_weeks": 10.0, "attrition_rate_annual_pct": 20.0},
|
||||
}
|
||||
|
||||
SPAN_OF_CONTROL_MAX = 7 # ICs per manager threshold (Fournier)
|
||||
|
||||
|
||||
class Quarter(str, Enum):
|
||||
Q1 = "Q1"
|
||||
Q2 = "Q2"
|
||||
Q3 = "Q3"
|
||||
Q4 = "Q4"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HiringInput:
|
||||
team_name: str
|
||||
current_fte: int
|
||||
target_fte_end_of_year: int
|
||||
ramp_time_weeks: float
|
||||
attrition_rate_annual_pct: float
|
||||
growth_assumption_qoq_pct: float
|
||||
max_hires_per_quarter: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuarterPlan:
|
||||
quarter: Quarter
|
||||
ic_hires: int
|
||||
manager_hires: int
|
||||
expected_attrition: int
|
||||
productive_fte_end_of_quarter: float
|
||||
headcount_end_of_quarter: int
|
||||
span_of_control: float
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HiringResult:
|
||||
team_name: str
|
||||
inputs: HiringInput
|
||||
quarters: list[QuarterPlan]
|
||||
total_ic_hires: int
|
||||
total_manager_hires: int
|
||||
total_attrition: int
|
||||
headline: str
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _productivity_factor(weeks_since_hire: float, ramp_weeks: float) -> float:
|
||||
"""Linear ramp 50% → 100% over ramp_weeks (Larson)."""
|
||||
if weeks_since_hire >= ramp_weeks:
|
||||
return 1.0
|
||||
if weeks_since_hire <= 0:
|
||||
return 0.5
|
||||
return 0.5 + 0.5 * (weeks_since_hire / ramp_weeks)
|
||||
|
||||
|
||||
def sequence(inp: HiringInput) -> HiringResult:
|
||||
# Demand-side ratchet: target adjusted up by QoQ growth (compounded)
|
||||
growth_factor_eoy = (1.0 + inp.growth_assumption_qoq_pct / 100.0) ** 4
|
||||
adjusted_target = int(math.ceil(inp.target_fte_end_of_year * growth_factor_eoy))
|
||||
|
||||
# Per-quarter attrition probability — split annual rate across 4 quarters
|
||||
q_attrition_rate = 1.0 - (1.0 - inp.attrition_rate_annual_pct / 100.0) ** 0.25
|
||||
|
||||
# Total gap to close: target + replacement hires over the year
|
||||
expected_total_attrition = int(math.ceil(
|
||||
inp.current_fte * (inp.attrition_rate_annual_pct / 100.0)
|
||||
))
|
||||
raw_gap = adjusted_target - inp.current_fte + expected_total_attrition
|
||||
total_hires_needed = max(0, raw_gap)
|
||||
|
||||
# Distribute hires front-loaded but capped
|
||||
quarters: list[QuarterPlan] = []
|
||||
headcount = inp.current_fte
|
||||
remaining = total_hires_needed
|
||||
cumulative_managers = max(1, math.ceil(inp.current_fte / SPAN_OF_CONTROL_MAX))
|
||||
cumulative_ic_hires = 0
|
||||
cumulative_manager_hires = 0
|
||||
cumulative_attrition = 0
|
||||
warnings: list[str] = []
|
||||
|
||||
# Pre-emptive front-load: aim higher in early quarters so ramp completes by EOY
|
||||
# Allocation weights Q1>Q2>Q3>Q4 since later hires miss ramp window.
|
||||
weights = [0.35, 0.30, 0.20, 0.15]
|
||||
|
||||
for i, qname in enumerate(Quarter):
|
||||
ideal_q_hires = math.ceil(total_hires_needed * weights[i])
|
||||
q_hires = min(ideal_q_hires, inp.max_hires_per_quarter, remaining)
|
||||
if q_hires < ideal_q_hires:
|
||||
warnings.append(
|
||||
f"{qname.value}: wanted {ideal_q_hires} hires but constrained "
|
||||
f"to {q_hires} by max_hires_per_quarter."
|
||||
)
|
||||
|
||||
# Attrition realized this quarter
|
||||
q_attrition = int(round(headcount * q_attrition_rate))
|
||||
cumulative_attrition += q_attrition
|
||||
|
||||
# New headcount after hires + attrition
|
||||
new_headcount = headcount + q_hires - q_attrition
|
||||
|
||||
# Manager trigger check — if ICs / managers > threshold, add a manager hire
|
||||
# (counted within the ic_hires bucket reallocated as manager)
|
||||
ic_count_eoq = new_headcount - cumulative_managers
|
||||
span = ic_count_eoq / max(cumulative_managers, 1)
|
||||
notes: list[str] = []
|
||||
manager_hires_this_q = 0
|
||||
if span > SPAN_OF_CONTROL_MAX and q_hires > 0:
|
||||
manager_hires_this_q = 1
|
||||
q_hires -= 1
|
||||
cumulative_managers += 1
|
||||
notes.append(
|
||||
f"Manager trigger fired: span was {span:.1f} ICs/manager > "
|
||||
f"{SPAN_OF_CONTROL_MAX}. Reallocated 1 IC hire to manager hire."
|
||||
)
|
||||
# Recompute span after manager hire
|
||||
ic_count_eoq = new_headcount - cumulative_managers
|
||||
span = ic_count_eoq / max(cumulative_managers, 1)
|
||||
|
||||
cumulative_ic_hires += q_hires
|
||||
cumulative_manager_hires += manager_hires_this_q
|
||||
remaining -= (q_hires + manager_hires_this_q)
|
||||
|
||||
# Productive FTE = full-time members + ramp-fraction for in-quarter hires
|
||||
# in-quarter hires are halfway through ramp on average at EOQ → ~halfway up the ramp curve
|
||||
avg_weeks_for_q_hires = 6.5 # quarter midpoint (13 weeks / 2)
|
||||
ramp_fraction = _productivity_factor(avg_weeks_for_q_hires, inp.ramp_time_weeks)
|
||||
productive_fte = (headcount - q_attrition) + (q_hires + manager_hires_this_q) * ramp_fraction
|
||||
|
||||
if productive_fte < adjusted_target * 0.85 and i == 3:
|
||||
warnings.append(
|
||||
f"EOY productive FTE ({productive_fte:.1f}) below 85% of adjusted "
|
||||
f"target ({adjusted_target}) — ramp will extend into next year."
|
||||
)
|
||||
|
||||
quarters.append(QuarterPlan(
|
||||
quarter=qname,
|
||||
ic_hires=q_hires,
|
||||
manager_hires=manager_hires_this_q,
|
||||
expected_attrition=q_attrition,
|
||||
productive_fte_end_of_quarter=round(productive_fte, 1),
|
||||
headcount_end_of_quarter=new_headcount,
|
||||
span_of_control=round(span, 2),
|
||||
notes=notes,
|
||||
))
|
||||
|
||||
headcount = new_headcount
|
||||
|
||||
headline = (
|
||||
f"Hire {cumulative_ic_hires} ICs + {cumulative_manager_hires} managers "
|
||||
f"across 4 quarters. End-of-year nominal headcount: {headcount} "
|
||||
f"(adjusted target: {adjusted_target}). Expect ~{cumulative_attrition} "
|
||||
f"attrition over the year."
|
||||
)
|
||||
|
||||
return HiringResult(
|
||||
team_name=inp.team_name,
|
||||
inputs=inp,
|
||||
quarters=quarters,
|
||||
total_ic_hires=cumulative_ic_hires,
|
||||
total_manager_hires=cumulative_manager_hires,
|
||||
total_attrition=cumulative_attrition,
|
||||
headline=headline,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def to_markdown(r: HiringResult) -> str:
|
||||
lines = [
|
||||
f"# Hiring Plan — {r.team_name}",
|
||||
"",
|
||||
f"**Headline:** {r.headline}",
|
||||
"",
|
||||
"## Assumptions",
|
||||
f"- Current FTE: {r.inputs.current_fte}",
|
||||
f"- Target EOY FTE (nominal): {r.inputs.target_fte_end_of_year}",
|
||||
f"- Ramp time: {r.inputs.ramp_time_weeks} weeks",
|
||||
f"- Annual attrition: {r.inputs.attrition_rate_annual_pct}%",
|
||||
f"- QoQ growth: {r.inputs.growth_assumption_qoq_pct}%",
|
||||
f"- Max hires per quarter: {r.inputs.max_hires_per_quarter}",
|
||||
"",
|
||||
"## Quarterly Plan",
|
||||
"",
|
||||
"| Quarter | IC Hires | Manager Hires | Attrition | Headcount EOQ | Productive FTE EOQ | Span of Control |",
|
||||
"|---|---|---|---|---|---|---|",
|
||||
]
|
||||
for q in r.quarters:
|
||||
lines.append(
|
||||
f"| {q.quarter.value} | {q.ic_hires} | {q.manager_hires} | "
|
||||
f"{q.expected_attrition} | {q.headcount_end_of_quarter} | "
|
||||
f"{q.productive_fte_end_of_quarter} | {q.span_of_control} |"
|
||||
)
|
||||
lines.append("")
|
||||
notes_present = any(q.notes for q in r.quarters)
|
||||
if notes_present:
|
||||
lines.append("## Quarter Notes")
|
||||
for q in r.quarters:
|
||||
for n in q.notes:
|
||||
lines.append(f"- {q.quarter.value}: {n}")
|
||||
lines.append("")
|
||||
if r.warnings:
|
||||
lines.append("## Warnings")
|
||||
for w in r.warnings:
|
||||
lines.append(f"- {w}")
|
||||
lines.append("")
|
||||
lines.extend([
|
||||
"## Canon",
|
||||
"- Camille Fournier, *The Manager's Path* — span of control thresholds.",
|
||||
"- Will Larson, *Staff Engineer* — ramp productivity curves.",
|
||||
"- Bersin/Deloitte talent benchmarks — attrition + replacement hire ratios.",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def to_dict(r: HiringResult) -> dict[str, Any]:
|
||||
return {
|
||||
"team_name": r.team_name,
|
||||
"headline": r.headline,
|
||||
"totals": {
|
||||
"ic_hires": r.total_ic_hires,
|
||||
"manager_hires": r.total_manager_hires,
|
||||
"attrition": r.total_attrition,
|
||||
},
|
||||
"quarters": [
|
||||
{
|
||||
"quarter": q.quarter.value,
|
||||
"ic_hires": q.ic_hires,
|
||||
"manager_hires": q.manager_hires,
|
||||
"expected_attrition": q.expected_attrition,
|
||||
"productive_fte_end_of_quarter": q.productive_fte_end_of_quarter,
|
||||
"headcount_end_of_quarter": q.headcount_end_of_quarter,
|
||||
"span_of_control": q.span_of_control,
|
||||
"notes": q.notes,
|
||||
}
|
||||
for q in r.quarters
|
||||
],
|
||||
"warnings": r.warnings,
|
||||
"inputs": {
|
||||
"current_fte": r.inputs.current_fte,
|
||||
"target_fte_end_of_year": r.inputs.target_fte_end_of_year,
|
||||
"ramp_time_weeks": r.inputs.ramp_time_weeks,
|
||||
"attrition_rate_annual_pct": r.inputs.attrition_rate_annual_pct,
|
||||
"growth_assumption_qoq_pct": r.inputs.growth_assumption_qoq_pct,
|
||||
"max_hires_per_quarter": r.inputs.max_hires_per_quarter,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
SAMPLE_INPUT: dict[str, Any] = {
|
||||
"team_name": "Tier-1 Support",
|
||||
"current_fte": 15,
|
||||
"target_fte_end_of_year": 35,
|
||||
"ramp_time_weeks": 8,
|
||||
"attrition_rate_annual_pct": 30,
|
||||
"growth_assumption_qoq_pct": 8,
|
||||
"hiring_constraints": {"max_hires_per_quarter": 8},
|
||||
}
|
||||
|
||||
|
||||
def parse_input(raw: dict[str, Any], profile: str | None) -> HiringInput:
|
||||
prof = PROFILES.get(profile or "", {})
|
||||
ramp = raw.get("ramp_time_weeks", prof.get("ramp_time_weeks", 10.0))
|
||||
attr = raw.get(
|
||||
"attrition_rate_annual_pct",
|
||||
prof.get("attrition_rate_annual_pct", 25.0),
|
||||
)
|
||||
constraints = raw.get("hiring_constraints", {}) or {}
|
||||
return HiringInput(
|
||||
team_name=raw["team_name"],
|
||||
current_fte=int(raw["current_fte"]),
|
||||
target_fte_end_of_year=int(raw["target_fte_end_of_year"]),
|
||||
ramp_time_weeks=float(ramp),
|
||||
attrition_rate_annual_pct=float(attr),
|
||||
growth_assumption_qoq_pct=float(raw.get("growth_assumption_qoq_pct", 0.0)),
|
||||
max_hires_per_quarter=int(constraints.get("max_hires_per_quarter", 999)),
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="12-month quarterly hiring sequencer with ramp + attrition.",
|
||||
)
|
||||
p.add_argument("--input", type=Path, help="Path to JSON input file.")
|
||||
p.add_argument(
|
||||
"--profile",
|
||||
choices=list(PROFILES.keys()),
|
||||
default=None,
|
||||
help="Industry profile (defaults for ramp + attrition).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--output", choices=["markdown", "json"], default="markdown",
|
||||
help="Output format.",
|
||||
)
|
||||
p.add_argument("--sample", action="store_true",
|
||||
help="Run on built-in sample input.")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.sample:
|
||||
raw = SAMPLE_INPUT
|
||||
elif args.input:
|
||||
raw = json.loads(args.input.read_text())
|
||||
else:
|
||||
p.error("Provide --input or --sample.")
|
||||
return 2
|
||||
|
||||
try:
|
||||
inp = parse_input(raw, args.profile)
|
||||
except (KeyError, ValueError) as e:
|
||||
print(f"ERROR parsing input: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
result = sequence(inp)
|
||||
if args.output == "json":
|
||||
print(json.dumps(to_dict(result), indent=2))
|
||||
else:
|
||||
print(to_markdown(result))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
#!/usr/bin/env python3
|
||||
"""utilization_analyzer.py — per-member + team-level utilization health.
|
||||
|
||||
Detects:
|
||||
* RED : sustained >85% utilization (throughput collapse risk, Reinertsen)
|
||||
* AMBER: 70-85% (acceptable but watch — Little's Law tightening)
|
||||
* GREEN: 40-70% (healthy)
|
||||
* BLUE : <40% (under-loaded or wrong skills)
|
||||
|
||||
Team verdict:
|
||||
HEALTHY — most green, no reds, low variance
|
||||
SQUEEZED — majority amber, some red
|
||||
OVERLOADED — >30% of team red
|
||||
UNBALANCED — utilization variance >30 percentage points across team
|
||||
|
||||
Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Light(str, Enum):
|
||||
GREEN = "GREEN"
|
||||
AMBER = "AMBER"
|
||||
RED = "RED"
|
||||
BLUE = "BLUE"
|
||||
|
||||
|
||||
class TeamVerdict(str, Enum):
|
||||
HEALTHY = "HEALTHY"
|
||||
SQUEEZED = "SQUEEZED"
|
||||
OVERLOADED = "OVERLOADED"
|
||||
UNBALANCED = "UNBALANCED"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Member:
|
||||
name: str
|
||||
role: str
|
||||
utilization_pct: float
|
||||
handles_count: int
|
||||
hours_billable: float
|
||||
hours_capacity: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemberAssessment:
|
||||
name: str
|
||||
role: str
|
||||
utilization_pct: float
|
||||
light: Light
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeamReport:
|
||||
verdict: TeamVerdict
|
||||
member_assessments: list[MemberAssessment]
|
||||
mean_util: float
|
||||
median_util: float
|
||||
stdev_util: float
|
||||
spread_pct_points: float
|
||||
counts: dict[str, int]
|
||||
headline: str
|
||||
recommendations: list[str]
|
||||
|
||||
|
||||
def classify(member: Member) -> MemberAssessment:
|
||||
u = member.utilization_pct
|
||||
notes: list[str] = []
|
||||
if u >= 85:
|
||||
light = Light.RED
|
||||
notes.append("Throughput collapse risk per queueing theory (>85% sustained).")
|
||||
elif u >= 70:
|
||||
light = Light.AMBER
|
||||
notes.append("Within tolerable band but no surge capacity.")
|
||||
elif u >= 40:
|
||||
light = Light.GREEN
|
||||
else:
|
||||
light = Light.BLUE
|
||||
notes.append("Under-loaded — verify scope or reassign work.")
|
||||
|
||||
# Cross-check billable vs capacity hours — if claimed utilization
|
||||
# disagrees with hours math by >10 points, flag.
|
||||
if member.hours_capacity > 0:
|
||||
computed = member.hours_billable / member.hours_capacity * 100
|
||||
if abs(computed - u) > 10:
|
||||
notes.append(
|
||||
f"Reported util ({u:.0f}%) disagrees with hours math "
|
||||
f"({computed:.0f}%). Reconcile time tracking."
|
||||
)
|
||||
|
||||
return MemberAssessment(
|
||||
name=member.name,
|
||||
role=member.role,
|
||||
utilization_pct=u,
|
||||
light=light,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
def assess_team(members: list[Member]) -> TeamReport:
|
||||
if not members:
|
||||
raise ValueError("No team members in input.")
|
||||
|
||||
assessments = [classify(m) for m in members]
|
||||
utils = [m.utilization_pct for m in members]
|
||||
mean_u = statistics.fmean(utils)
|
||||
median_u = statistics.median(utils)
|
||||
stdev_u = statistics.pstdev(utils) if len(utils) > 1 else 0.0
|
||||
spread = max(utils) - min(utils)
|
||||
|
||||
counts = {
|
||||
"RED": sum(1 for a in assessments if a.light == Light.RED),
|
||||
"AMBER": sum(1 for a in assessments if a.light == Light.AMBER),
|
||||
"GREEN": sum(1 for a in assessments if a.light == Light.GREEN),
|
||||
"BLUE": sum(1 for a in assessments if a.light == Light.BLUE),
|
||||
}
|
||||
n = len(members)
|
||||
|
||||
# Verdict logic — order matters
|
||||
if spread > 30:
|
||||
verdict = TeamVerdict.UNBALANCED
|
||||
headline = (f"Load spread of {spread:.0f} percentage points across team — "
|
||||
f"some are red while others are blue.")
|
||||
elif counts["RED"] / n > 0.30:
|
||||
verdict = TeamVerdict.OVERLOADED
|
||||
headline = (f"{counts['RED']} of {n} members in RED zone. Throughput "
|
||||
f"collapse risk.")
|
||||
elif counts["AMBER"] / n >= 0.50 or counts["RED"] >= 1:
|
||||
verdict = TeamVerdict.SQUEEZED
|
||||
headline = f"Team running hot — {counts['AMBER']} amber, {counts['RED']} red."
|
||||
else:
|
||||
verdict = TeamVerdict.HEALTHY
|
||||
headline = f"Team utilization healthy: mean {mean_u:.0f}%, spread {spread:.0f}pp."
|
||||
|
||||
recs: list[str] = []
|
||||
if verdict == TeamVerdict.UNBALANCED:
|
||||
recs.append("Rebalance load — investigate whether reds need different skills, "
|
||||
"specialization, or just more hands at their queue.")
|
||||
if verdict == TeamVerdict.OVERLOADED:
|
||||
recs.append("Stop adding scope. Hire or shed work BEFORE attempting "
|
||||
"process improvements (Goldratt: subordinate to the constraint).")
|
||||
if verdict == TeamVerdict.SQUEEZED:
|
||||
recs.append("Plan to hire next quarter. Re-test in 30 days; squeeze tends "
|
||||
"to become overload during seasonal peaks.")
|
||||
if counts["BLUE"] > 0:
|
||||
recs.append(f"{counts['BLUE']} member(s) under-loaded — check whether "
|
||||
f"work is reaching them or whether scope/skill needs adjustment.")
|
||||
if not recs:
|
||||
recs.append("Maintain current sizing; revisit at next quarterly planning cycle.")
|
||||
|
||||
return TeamReport(
|
||||
verdict=verdict,
|
||||
member_assessments=assessments,
|
||||
mean_util=round(mean_u, 1),
|
||||
median_util=round(median_u, 1),
|
||||
stdev_util=round(stdev_u, 1),
|
||||
spread_pct_points=round(spread, 1),
|
||||
counts=counts,
|
||||
headline=headline,
|
||||
recommendations=recs,
|
||||
)
|
||||
|
||||
|
||||
def to_markdown(r: TeamReport) -> str:
|
||||
lines = [
|
||||
"# Utilization Analysis",
|
||||
"",
|
||||
f"**Verdict:** {r.verdict.value}",
|
||||
"",
|
||||
f"**Headline:** {r.headline}",
|
||||
"",
|
||||
"## Team Stats",
|
||||
f"- Mean utilization: {r.mean_util}%",
|
||||
f"- Median utilization: {r.median_util}%",
|
||||
f"- Stdev: {r.stdev_util}pp",
|
||||
f"- Spread (max - min): {r.spread_pct_points}pp",
|
||||
f"- Counts: RED {r.counts['RED']} / AMBER {r.counts['AMBER']} / "
|
||||
f"GREEN {r.counts['GREEN']} / BLUE {r.counts['BLUE']}",
|
||||
"",
|
||||
"## Member Detail",
|
||||
"",
|
||||
"| Name | Role | Utilization | Light | Notes |",
|
||||
"|---|---|---|---|---|",
|
||||
]
|
||||
for a in r.member_assessments:
|
||||
notes_str = "; ".join(a.notes) if a.notes else "—"
|
||||
lines.append(
|
||||
f"| {a.name} | {a.role} | {a.utilization_pct:.0f}% | {a.light.value} | {notes_str} |"
|
||||
)
|
||||
lines.extend(["", "## Recommendations"])
|
||||
for rec in r.recommendations:
|
||||
lines.append(f"- {rec}")
|
||||
lines.extend([
|
||||
"",
|
||||
"## Canon",
|
||||
"- Reinertsen, *Principles of Product Development Flow*, principle 7.",
|
||||
"- Little (1961), *A Proof for the Queuing Formula L = λW*.",
|
||||
"- Goldratt, *The Goal* — bottleneck subordination.",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def to_dict(r: TeamReport) -> dict[str, Any]:
|
||||
return {
|
||||
"verdict": r.verdict.value,
|
||||
"headline": r.headline,
|
||||
"stats": {
|
||||
"mean_util": r.mean_util,
|
||||
"median_util": r.median_util,
|
||||
"stdev_util": r.stdev_util,
|
||||
"spread_pct_points": r.spread_pct_points,
|
||||
"counts": r.counts,
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
"name": a.name,
|
||||
"role": a.role,
|
||||
"utilization_pct": a.utilization_pct,
|
||||
"light": a.light.value,
|
||||
"notes": a.notes,
|
||||
}
|
||||
for a in r.member_assessments
|
||||
],
|
||||
"recommendations": r.recommendations,
|
||||
}
|
||||
|
||||
|
||||
SAMPLE_INPUT: dict[str, Any] = {
|
||||
"team_members": [
|
||||
{"name": "Alice", "role": "T1 Support", "utilization_pct": 92,
|
||||
"handles_count": 48, "hours_billable": 7.4, "hours_capacity": 8},
|
||||
{"name": "Bob", "role": "T1 Support", "utilization_pct": 88,
|
||||
"handles_count": 42, "hours_billable": 7.0, "hours_capacity": 8},
|
||||
{"name": "Carol", "role": "T1 Support", "utilization_pct": 72,
|
||||
"handles_count": 36, "hours_billable": 5.8, "hours_capacity": 8},
|
||||
{"name": "Dan", "role": "T2 Support", "utilization_pct": 65,
|
||||
"handles_count": 18, "hours_billable": 5.2, "hours_capacity": 8},
|
||||
{"name": "Eve", "role": "T2 Support", "utilization_pct": 35,
|
||||
"handles_count": 8, "hours_billable": 2.8, "hours_capacity": 8},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def parse_members(raw: dict[str, Any]) -> list[Member]:
|
||||
out: list[Member] = []
|
||||
for m in raw["team_members"]:
|
||||
out.append(Member(
|
||||
name=m["name"],
|
||||
role=m.get("role", "unspecified"),
|
||||
utilization_pct=float(m["utilization_pct"]),
|
||||
handles_count=int(m.get("handles_count", 0)),
|
||||
hours_billable=float(m.get("hours_billable", 0)),
|
||||
hours_capacity=float(m.get("hours_capacity", 0)),
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Per-member + team-level utilization traffic-light analyzer.",
|
||||
)
|
||||
p.add_argument("--input", type=Path, help="Path to JSON input file.")
|
||||
p.add_argument(
|
||||
"--output", choices=["markdown", "json"], default="markdown",
|
||||
help="Output format.",
|
||||
)
|
||||
p.add_argument("--sample", action="store_true",
|
||||
help="Run on built-in sample input.")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.sample:
|
||||
raw = SAMPLE_INPUT
|
||||
elif args.input:
|
||||
raw = json.loads(args.input.read_text())
|
||||
else:
|
||||
p.error("Provide --input or --sample.")
|
||||
return 2
|
||||
|
||||
try:
|
||||
members = parse_members(raw)
|
||||
except (KeyError, ValueError) as e:
|
||||
print(f"ERROR parsing input: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
report = assess_team(members)
|
||||
if args.output == "json":
|
||||
print(json.dumps(to_dict(report), indent=2))
|
||||
else:
|
||||
print(to_markdown(report))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
125
business-operations/skills/internal-comms/SKILL.md
Normal file
125
business-operations/skills/internal-comms/SKILL.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
---
|
||||
name: internal-comms
|
||||
description: Use when a Head of People Ops, BizOps lead, or Internal Communications owner needs to draft and sequence an internal-only change-management communication — a re-org announcement, a tool rollout, a policy change, a benefit change, a leadership transition, a layoff, an acquisition close, or an internal product launch — and the audience is employees (not customers). Triggers on "all-hands announcement", "town-hall script", "change comms", "internal newsletter", "rollout comms", "policy change announcement", "re-org announcement", "internal FAQ", "manager talking points", "Prosci ADKAR", "Kotter 8-step", "layoff comms", "RIF comms", "internal memo". Pairs Prosci ADKAR (Awareness / Desire / Knowledge / Ability / Reinforcement) and Kotter's 8-step change model with deterministic stdlib-only Python tools to produce a sequenced touchpoint calendar, a Kotter-compliant primary announcement, an audience-segmented FAQ, and manager cascade talking points. Industry-tuned via --profile {tech-startup, scaleup, enterprise, public-company, non-profit}. Distinct from marketing-skill/* (external/customer-facing), c-level-advisor/internal-narrative (strategic framing, not tactical drafts), and c-level-advisor/change-management (executive change strategy, not the comms package itself).
|
||||
version: 2.8.0
|
||||
author: claude-code-skills
|
||||
license: MIT
|
||||
tags: [bizops, internal-comms, change-management, adkar, kotter, all-hands, town-hall, prosci]
|
||||
compatible_tools: [claude-code, codex-cli, cursor, antigravity, opencode, gemini-cli]
|
||||
---
|
||||
|
||||
# internal-comms — Tactical Internal Change-Management Authoring
|
||||
|
||||
You are a BizOps / People Ops / Internal Communications operator. Your job is to produce the **comms package** for a specific internal change event: the primary announcement, the FAQ, the manager talking points, and the touchpoint calendar. Your audience is **employees, not customers**. Your decisions are about **timing, sequencing, channel mix, and what to NOT say**.
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal change announcements fail in four predictable ways:
|
||||
|
||||
1. **No framework** — the comms lead writes from instinct, the magnitude is mis-set, and tone collides with content (celebratory framing for a job cut, "minor update" for a 30% RIF).
|
||||
2. **No touchpoint sequencing** — one Slack post is treated as "the comms plan." Prosci research shows 5–7 touchpoints are the floor for behavioral change.
|
||||
3. **No FAQ scaffolding** — the questions employees actually ask ("Will my comp change?", "Will I report to someone new?", "Is this a precursor to layoffs?") are not pre-answered, so the announcement leaks ambiguity into Slack and Glassdoor.
|
||||
4. **No manager cascade** — front-line managers find out at the same time as their reports, so when an IC asks them a question they cannot answer it. Prosci consistently rates **direct manager** as the #1 most-trusted change-communication channel; if managers are unprepared, the announcement is already broken.
|
||||
|
||||
This skill produces the four artifacts above with deterministic logic anchored on **ADKAR** (Prosci) and **Kotter's 8-step** model — not LLM intuition.
|
||||
|
||||
## When to use
|
||||
|
||||
- A re-org / leadership change / new tool rollout / policy change / benefit change / layoff / acquisition close needs internal announcement within 48 hours.
|
||||
- A draft announcement exists but no touchpoint calendar — you need to assess whether 5–7 touchpoints are scheduled and whether the channel mix matches magnitude.
|
||||
- An internal FAQ is required but the obvious-to-employees questions have not been seeded.
|
||||
- Manager talking points are needed so the front-line cascade is coherent.
|
||||
- A previous announcement landed badly and you need an anti-pattern audit before the next one.
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- Customer-facing launch comms / press release / blog post → `marketing-skill/*`
|
||||
- Strategic narrative framing for a transformation arc → `c-level-advisor/internal-narrative`
|
||||
- Executive change-strategy design (sponsor coalition, change-saturation analysis) → `c-level-advisor/change-management`
|
||||
- All-hands deck visual design / slide template → (future skill, not this one)
|
||||
- HR policy authoring itself (the legal/compliance text of the new policy) → outside scope; this skill assumes the policy decision is made
|
||||
|
||||
## Workflow
|
||||
|
||||
Five-step deterministic flow. Follow in order.
|
||||
|
||||
1. **Intake the change.** Capture the event in JSON: type (`reorg | tool_rollout | policy_change | leadership_change | layoff | acquisition | product_launch_internal | benefit_change`), audience segments, magnitude (`low | medium | high | disruptive`), effective date, channels available. Use `assets/comms_brief_template.md` and its JSON skeleton.
|
||||
2. **Assess magnitude vs. tone fit.** Run `change_announcement_builder.py` with `--profile <industry>`. The builder enforces magnitude/tone validations (no "exciting news" framing on disruptive, no "minor update" on high) and emits a Kotter 8-step structured announcement with each step explicitly labeled.
|
||||
3. **Plan touchpoints.** Run `comms_calendar_builder.py`. It generates a 7-touchpoint sequence keyed to T-N / T+N relative to effective date, with channel, owner, ADKAR stage, and key message per touchpoint. Surfaces gaps (e.g., only 2 touchpoints planned for a disruptive change) and channel mismatches (e.g., Slack-only for a layoff).
|
||||
4. **Draft full package.** Run `comms_template_filler.py`. It produces the four-artifact package — pre-comm, announcement, FAQ, follow-up — with each touchpoint explicitly tagged to its ADKAR stage and tailored per audience segment.
|
||||
5. **Anti-pattern sweep.** Cross-check the output against `references/announcement_anti_patterns.md` before publishing. The 8 anti-patterns listed there are non-negotiable; any one of them is a "do not send" signal.
|
||||
|
||||
## Scripts
|
||||
|
||||
**`scripts/comms_template_filler.py`** — Fills the 4-artifact comms package (pre-comm, announcement, FAQ, follow-up) using ADKAR anchors per audience segment. Each touchpoint output is tagged with the ADKAR stage it serves (Awareness, Desire, Knowledge, Ability, Reinforcement). Stdlib only. `--sample` prints a tool-rollout example for an engineering audience.
|
||||
|
||||
**`scripts/change_announcement_builder.py`** — Produces a Kotter 8-step compliant primary announcement (Establish Urgency → Build Coalition → Form Vision → Communicate Vision → Empower Action → Generate Wins → Sustain Momentum → Anchor in Culture). Each step is labeled inline. Validates magnitude vs. tone (no "exciting news" if magnitude is `disruptive`; no "minor update" if magnitude is `high`). Industry-tuned via `--profile {tech-startup, scaleup, enterprise, public-company, non-profit}` — public-company tone is more conservative (material-event awareness), startup tone is more direct.
|
||||
|
||||
**`scripts/comms_calendar_builder.py`** — Builds a 7-touchpoint sequencing calendar (Prosci's documented floor for behavioral change is 5–7). Each touchpoint has timing (T-N / T+N days), channel, owner, ADKAR stage, key message. Surfaces gaps and channel mismatches: e.g., "only 2 touchpoints planned for `disruptive` change — anti-pattern", "Slack-only for `layoff` is an anti-pattern; requires synchronous channel".
|
||||
|
||||
All three: stdlib only, `--help` and `--sample` exit 0, accept `--input <json>` and `--output {markdown,json}`.
|
||||
|
||||
## References
|
||||
|
||||
- `references/change_management_canon.md` — Jeff Hiatt *ADKAR* (Prosci), John Kotter *Leading Change* (8-step), William Bridges *Managing Transitions* (Endings / Neutral Zone / Beginnings), Edgar Schein *Organizational Culture and Leadership*, McKinsey 7-S framework, Heath brothers *Switch*, Patrick Lencioni *The Advantage*.
|
||||
- `references/internal_comms_canon.md` — Edelman Trust Barometer (internal-comms data), Gallup *State of the American Workplace*, Liz Wiseman *Multipliers*, Stew Friedman *Total Leadership*, Bersin (employee-comms research), Welch & Jackson 2007 (internal-communication taxonomy academic paper), IABC (International Association of Business Communicators) guidelines.
|
||||
- `references/announcement_anti_patterns.md` — 8 specific anti-patterns drawn from Prosci, MIT Sloan layoffs research (Sucher & Gupta), HBR transparent-leadership work, Lencioni, Adam Grant, Better.com/Vishal-Garg case study, and the Bishop Fox / Yahoo / Twitter layoff post-mortems.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. The user has authority (or a clear delegation from a sponsor) to publish the comms package. Without sponsor sign-off, this skill produces a draft, not a publication.
|
||||
2. The decision being announced is already made. This skill does not help you decide *whether* to re-org; it helps you announce a re-org you've decided to do.
|
||||
3. The user can name the audience segments honestly. "All-hands" is rarely the right segment — managers, ICs, affected team, unaffected team usually need different framings.
|
||||
4. The magnitude field is honest. A 30% RIF is `disruptive`, not `high`. Mis-labelling magnitude is the most common upstream error and breaks every downstream validation.
|
||||
5. The effective date is fixed. Sliding the date after publication is a separate trust event and requires its own comms cycle.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Slack-only announcement of a high or disruptive change.** Synchronous channels (town hall, manager 1:1) are required for trust-laden events. See `references/announcement_anti_patterns.md`.
|
||||
- **Passive voice for accountability.** "Decisions have been made" hides the decision-maker. Name them.
|
||||
- **Magnitude downplay.** "Minor restructuring" for a 30% RIF is the Better.com / Vishal-Garg failure mode. The tools reject this.
|
||||
- **No manager talking points.** Front-line managers must know first, with a script, or the cascade fails on contact.
|
||||
- **Celebratory framing for a job cut.** "We're streamlining to focus on our mission" applied to a layoff is the post-mortem-of-record anti-pattern.
|
||||
- **Bundled questions in the orchestrator.** Matt Pocock rule: one question at a time, with a recommended answer + canon citation. Never bundled.
|
||||
- **No follow-up touchpoints.** A single announcement is not a comms plan. Prosci floor is 5–7.
|
||||
- **Skipping the FAQ.** Employees will ask the questions anyway. Pre-answer them or watch Slack write the FAQ for you, badly.
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **`marketing-skill/*`** — external / customer-facing comms. Internal-comms is for employees, not press or customers. Different audience, different trust model, different success metric.
|
||||
- **`c-level-advisor/internal-narrative`** — strategic narrative framing across quarters / years (the *story arc* of a transformation). Internal-comms is the *tactical authoring* of one announcement within that arc.
|
||||
- **`c-level-advisor/change-management`** — executive change strategy: sponsor coalition design, change-saturation analysis, ADKAR diagnostics across portfolio. Internal-comms is the deliverable for one event, not the strategy.
|
||||
- **`business-growth/*`** — outbound sales / customer-success motion. Different audience, different goal.
|
||||
- **`engineering/handoff`** — conversation-continuity for AI sessions. Same word "handoff", different domain.
|
||||
|
||||
## Forcing-question library (Matt Pocock grill discipline)
|
||||
|
||||
Before invoking the tools, the orchestrator (or `/cs:grill-bizops`) walks the user through these questions **one at a time, with a recommended answer + canon citation**. Never bundled.
|
||||
|
||||
1. **"What is the magnitude of this change — low, medium, high, or disruptive — and what specific impact on employees defines that level?"**
|
||||
Recommended: assume one level higher than instinct. Layoffs are always `disruptive`, never `high`.
|
||||
Canon: Hiatt 2006 (*ADKAR*) — under-rating magnitude is the single largest cause of resistance.
|
||||
|
||||
2. **"Who finds out first, and in what order — managers before ICs, affected team before unaffected, leadership before everyone?"**
|
||||
Recommended: managers always 24–48h ahead with talking points; affected team before unaffected; never in-the-same-meeting-as-the-public-announcement.
|
||||
Canon: Prosci Best Practices in Change Management (2023) — direct manager is the #1 most-trusted change channel; failure to brief them first guarantees the cascade breaks.
|
||||
|
||||
3. **"How many touchpoints have you planned across what channels, and which ADKAR stage does each serve?"**
|
||||
Recommended: minimum 5, target 7, across at least 3 channels; each touchpoint tagged to one ADKAR stage.
|
||||
Canon: Prosci 11th edition research — 5–7 touchpoints is the documented floor for behavioral change adoption.
|
||||
|
||||
4. **"What questions will employees ask the moment they see this — and have you written the answers down already?"**
|
||||
Recommended: seed the FAQ with at least 7 questions: comp, reporting line, location, role change, timing, why now, why us. Bias toward including the questions you wish people would not ask.
|
||||
Canon: Edelman Trust Barometer (annual) — internal trust collapses fastest when the obvious question is unanswered.
|
||||
|
||||
5. **"Who is the named accountable executive that will appear on the announcement and be present at the town hall, and have they confirmed both?"**
|
||||
Recommended: a single, named human at VP level or above; physically (or video-) present; not delegating to comms team.
|
||||
Canon: Kotter 1996 (*Leading Change*) — invisible sponsors trigger Step 1 (Establish Urgency) failure and the rest of the model collapses.
|
||||
|
||||
6. **"What are you NOT saying, and why?"**
|
||||
Recommended: surface the omissions explicitly to legal / sponsor. The unsaid will be inferred; better to know what's being inferred.
|
||||
Canon: Sucher & Gupta MIT Sloan layoffs research (2018) — what is omitted from a layoff announcement becomes the lead Glassdoor narrative.
|
||||
|
||||
7. **"What does success look like 30 / 60 / 90 days after the announcement — and how are you measuring it?"**
|
||||
Recommended: name 3 measurable signals (e.g., regrettable-attrition delta, pulse-survey trust score, manager-cascade audit results).
|
||||
Canon: Hiatt 2006 (*ADKAR*) — Reinforcement is the most-skipped ADKAR stage; without measurement there is no Reinforcement.
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
# Internal Comms Brief Template
|
||||
|
||||
A 20-minute fill-out. Copy this template, replace the bracketed values, save as `comms_brief.json` (the JSON skeleton at the bottom of this file), and pass to the three scripts:
|
||||
|
||||
```bash
|
||||
python3 scripts/comms_template_filler.py --input comms_brief.json --output markdown
|
||||
python3 scripts/change_announcement_builder.py --input comms_brief.json --profile scaleup --output markdown
|
||||
python3 scripts/comms_calendar_builder.py --input comms_brief.json --output markdown
|
||||
```
|
||||
|
||||
(Note: each script reads a slightly different subset of fields; the JSON below is the union of all three.)
|
||||
|
||||
---
|
||||
|
||||
## Section 1 — The change event (10 minutes)
|
||||
|
||||
**Change type** (one of `reorg | tool_rollout | policy_change | leadership_change | layoff | acquisition | product_launch_internal | benefit_change`):
|
||||
> [e.g., reorg]
|
||||
|
||||
**Change summary** (one sentence — what is changing):
|
||||
> [e.g., Merging the Platform and Infrastructure teams into a single Platform Engineering group reporting to the new VP of Platform Engineering.]
|
||||
|
||||
**Why this change** (one paragraph — the business reason; reference the signal, not the politics):
|
||||
> [e.g., Two separate teams created a coordination tax on every cross-cutting infrastructure project. Cycle time for a typical cross-team initiative is currently 6+ weeks; the merged team has a target of 2 weeks.]
|
||||
|
||||
**What changes** (concrete deltas — reporting lines, roles, processes):
|
||||
> [e.g., All Infrastructure engineers begin reporting to managers within the Platform org effective June 15. The on-call rotation merges into a single rotation. The two separate Slack channels merge into #platform-eng.]
|
||||
|
||||
**What stays the same** (load-bearing — employees infer everything changes if you don't say what doesn't):
|
||||
> [e.g., Compensation, level, vacation balances, manager 1:1 cadence, and the existing OKRs through the end of Q2.]
|
||||
|
||||
**Effective date** (ISO 8601):
|
||||
> [e.g., 2026-06-15]
|
||||
|
||||
**Who decided** (named human or named small group — never "the leadership team" alone):
|
||||
> [e.g., VP Engineering Sarah Lee, with the support of the CTO and the engineering leadership team.]
|
||||
|
||||
**Change magnitude** (one of `low | medium | high | disruptive` — assume one level higher than instinct):
|
||||
> [e.g., high]
|
||||
|
||||
**Audience segments** (list — at least 3 typical: managers, ICs, rest-of-company; for layoffs add affected/unaffected):
|
||||
> [e.g., ["engineering managers", "engineering ICs", "rest of company"]]
|
||||
|
||||
**Audience size** (integer — total employees who will see this):
|
||||
> [e.g., 320]
|
||||
|
||||
---
|
||||
|
||||
## Section 2 — Comms infrastructure (5 minutes)
|
||||
|
||||
**Channels available** (subset of `email, slack, allhands, manager_cascade, town_hall, intranet`):
|
||||
> [e.g., ["email", "slack", "allhands", "manager_cascade", "town_hall", "intranet"]]
|
||||
|
||||
**Working days available** (integer — days between brief and effective date):
|
||||
> [e.g., 21]
|
||||
|
||||
**Named sponsor executive** (the human who will sign the email AND appear at the town hall):
|
||||
> [e.g., Sarah Lee, VP Engineering]
|
||||
|
||||
**Sponsor town-hall confirmed?** (yes/no — if no, stop and confirm before continuing):
|
||||
> [yes]
|
||||
|
||||
---
|
||||
|
||||
## Section 3 — FAQ seed (5 minutes)
|
||||
|
||||
List 5–10 questions employees will ask, with a draft answer. Bias toward including the questions you wish they would not ask.
|
||||
|
||||
```
|
||||
[
|
||||
{"q": "Will my compensation change?", "a": "No. Compensation is unchanged."},
|
||||
{"q": "Will my reporting line change?", "a": "Most ICs will report to the same manager. Engineers currently reporting to the two affected senior managers will be reassigned; affected individuals will be notified by 6/10."},
|
||||
{"q": "Is this a precursor to layoffs?", "a": "No. No headcount reductions are planned in connection with this re-org."},
|
||||
{"q": "Why now?", "a": "Cycle-time data over Q1 2026 made the coordination tax visible and quantifiable. Waiting another quarter would compound the problem."},
|
||||
{"q": "What about the on-call rotation?", "a": "The two rotations merge on 6/15. New rotation roster will be published 6/8. No engineer will be on-call more days per month than today."},
|
||||
{"q": "Who is the new VP?", "a": "Sarah Lee (currently VP Engineering) takes on VP Platform Engineering. The current VP Infrastructure transitions to a Distinguished Engineer role."},
|
||||
{"q": "What about my team's Q2 OKRs?", "a": "Existing OKRs remain through end of Q2; Q3 OKRs will be set by the new leadership team in late June."}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Section 4 — Next steps and measurement
|
||||
|
||||
**Next steps** (what employees should do immediately after the announcement):
|
||||
> [e.g., Managers: review the talking points doc by EOD 6/14. ICs: no action required; office hours 6/16 and 6/20 if you have questions.]
|
||||
|
||||
**Success criteria 30/60/90 days** (the measurement Reinforcement requires):
|
||||
> [e.g., 30d: pulse-survey trust score steady or improved; 60d: cross-team cycle time on top-3 initiatives < 4 weeks; 90d: no regrettable attrition above baseline.]
|
||||
|
||||
---
|
||||
|
||||
## JSON skeleton (combined input — save as `comms_brief.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"change_type": "reorg",
|
||||
"change_summary": "Merging Platform and Infrastructure into one group",
|
||||
"why_this_change": "Coordination tax on cross-cutting infra work; cycle time 6+ weeks",
|
||||
"what_changes": "Reporting lines, on-call rotation, Slack channels merge",
|
||||
"what_stays_the_same": "Compensation, level, vacation balances, manager 1:1 cadence, Q2 OKRs",
|
||||
"effective_date": "2026-06-15",
|
||||
"who_decided": "VP Engineering Sarah Lee with leadership team",
|
||||
"change_magnitude": "high",
|
||||
"audience_segments": ["engineering managers", "engineering ICs", "rest of company"],
|
||||
"channels": ["email", "slack", "allhands", "manager_cascade", "town_hall", "intranet"],
|
||||
"next_steps": "Manager talking points by EOD 6/14; office hours 6/16 and 6/20",
|
||||
"q_and_a_seed": [
|
||||
{"q": "Will my compensation change?", "a": "No."},
|
||||
{"q": "Will my reporting line change?", "a": "Most ICs same manager; some reassignments notified by 6/10."},
|
||||
{"q": "Is this a precursor to layoffs?", "a": "No."},
|
||||
{"q": "Why now?", "a": "Cycle-time data over Q1 2026 made the coordination tax quantifiable."},
|
||||
{"q": "What about on-call?", "a": "Rotations merge 6/15; no engineer more days per month than today."}
|
||||
],
|
||||
"change_event": {
|
||||
"name": "Platform + Infra re-org",
|
||||
"magnitude": "high",
|
||||
"effective_date": "2026-06-15",
|
||||
"audience_size": 320
|
||||
},
|
||||
"channels_available": ["email", "slack", "allhands", "manager_cascade", "town_hall", "intranet"],
|
||||
"working_days_available": 21
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pre-publication checklist (Matt Pocock discipline — answer ALL before sending)
|
||||
|
||||
- [ ] Magnitude is honest (one level higher than instinct, especially for layoffs).
|
||||
- [ ] Sponsor confirmed for town hall + Q&A thread.
|
||||
- [ ] Manager talking points written and shared with managers 24–48h ahead.
|
||||
- [ ] FAQ seeded with at least 7 questions including the ones you wish weren't asked.
|
||||
- [ ] Passive-voice accountability scrubbed from announcement.
|
||||
- [ ] At least one synchronous channel scheduled (town hall / all-hands) for high or disruptive.
|
||||
- [ ] At least 5 touchpoints in the calendar (Prosci floor).
|
||||
- [ ] T+7 enablement and T+14 reinforcement touchpoints scheduled.
|
||||
- [ ] Success criteria at 30/60/90 days are measurable.
|
||||
- [ ] What's NOT being said is surfaced explicitly to sponsor / legal.
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
# Internal-Comms Announcement Anti-Patterns
|
||||
|
||||
Eight specific anti-patterns drawn from Prosci research, MIT Sloan layoffs research, HBR transparent-leadership work, and three case studies of public failures. Each is a "do not send" signal when surfaced by the skill's validators.
|
||||
|
||||
---
|
||||
|
||||
## 1. Slack-only announcement of a layoff (or any disruptive change)
|
||||
|
||||
**Pattern:** The change is announced via a single Slack message in a company-wide channel. No synchronous channel (town hall, manager 1:1). No FAQ. No follow-up.
|
||||
|
||||
**Why it fails:** Disruptive changes require synchronous channels to demonstrate sponsor presence and to absorb the immediate emotional reaction. Asynchronous channels leave employees alone with the news, which compounds resistance and accelerates Glassdoor narrative formation.
|
||||
|
||||
**Canon:** Prosci (11th edition) — synchronous channels are required for high-magnitude changes; Bersin two-way-channel research; Sucher & Gupta MIT Sloan layoffs research (2018).
|
||||
|
||||
**Skill enforcement:** `comms_calendar_builder.py` warns when no synchronous channel (town_hall / allhands) is in the plan for a disruptive event.
|
||||
|
||||
---
|
||||
|
||||
## 2. Passive voice for accountability — "decisions have been made"
|
||||
|
||||
**Pattern:** The announcement uses agentless passive constructions: "decisions have been made", "it has been determined that", "the organization has decided".
|
||||
|
||||
**Why it fails:** Passive accountability is a Vulnerability-Based-Trust failure (Lencioni). Employees know decisions are made by humans; hiding which human signals fear of accountability and invites speculation about who is really behind the change.
|
||||
|
||||
**Canon:** Lencioni *The Advantage* (2012); Adam Grant on apology mechanics; classic Strunk & White discipline on active voice.
|
||||
|
||||
**Skill enforcement:** `change_announcement_builder.py` flags "decisions have been made" / "the decision has been made" as a WARN-level validation.
|
||||
|
||||
---
|
||||
|
||||
## 3. Magnitude downplay — "minor restructuring" for a 30% RIF
|
||||
|
||||
**Pattern:** A high or disruptive change is framed with low-magnitude language. The canonical example: Better.com's Vishal Garg layoff (Dec 2021) framed the 900-person cut as a "tough decision" without acknowledging the human magnitude, and conducted it over Zoom in 3 minutes.
|
||||
|
||||
**Why it fails:** Employees know the magnitude. The mismatch between announced framing and lived reality is the lead Glassdoor / press narrative for years afterward.
|
||||
|
||||
**Canon:** Sucher & Gupta MIT Sloan research; the Better.com / Vishal-Garg case; the Bishop Fox layoff-comms post-mortem.
|
||||
|
||||
**Skill enforcement:** `change_announcement_builder.py` rejects "minor update" / "small change" / "minor restructuring" framing when magnitude is `high`; rejects "exciting news" / "thrilled to" / "celebrate" framing when magnitude is `disruptive`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Celebratory framing for a job cut
|
||||
|
||||
**Pattern:** The announcement uses "exciting news" / "thrilled to share" / "great opportunity" framing for a layoff, role-elimination, or office closure.
|
||||
|
||||
**Why it fails:** Tone-content collision is the highest-trust-cost framing error. Employees read it as either out-of-touch (leadership doesn't know what this means to people) or manipulative (leadership knows and is trying to bury it). Both readings are durable and shareable.
|
||||
|
||||
**Canon:** Sucher & Gupta MIT Sloan research; the Twitter / Musk layoff comms post-mortems (Nov 2022); IABC Code of Ethics on truthful communication.
|
||||
|
||||
**Skill enforcement:** `change_announcement_builder.py` rejects celebratory keyword set when magnitude is `disruptive`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Leadership absent on day-of
|
||||
|
||||
**Pattern:** The announcement is sent by Internal Comms or HR but the named accountable executive is not present at the town hall, does not respond in the Q&A thread, and is not available to managers in the 24 hours after.
|
||||
|
||||
**Why it fails:** Kotter Step 1 (Establish Urgency) collapses when the sponsor is invisible. Employees correctly infer that leadership is not committed enough to the change to be visible on it.
|
||||
|
||||
**Canon:** Kotter *Leading Change* (1996); Prosci sponsor-active-and-visible research (the #1 contributor to change success in every Prosci study since 2003).
|
||||
|
||||
**Skill enforcement:** `comms_calendar_builder.py` assigns `sponsor_exec` as the owner of the T+0 announcement and the T+1 Q&A thread; if these are reassigned, the calendar comment surfaces the deviation.
|
||||
|
||||
---
|
||||
|
||||
## 6. No manager talking points (managers find out same time as ICs)
|
||||
|
||||
**Pattern:** Managers receive the announcement at the same moment as their direct reports, with no pre-brief, no FAQ, no script.
|
||||
|
||||
**Why it fails:** Direct manager is the #1 most-trusted channel (Edelman, Bersin). A manager who cannot answer a basic question from a report at announcement time signals "leadership does not trust me with this" — which the report then transitively applies to the announcement itself.
|
||||
|
||||
**Canon:** Prosci Best Practices in Change Management; Edelman Trust Barometer (manager-trust finding, every year since 2018); Welch & Jackson 2007.
|
||||
|
||||
**Skill enforcement:** `comms_calendar_builder.py` schedules a T-3 manager_cascade pre-brief by default and warns if it is missing.
|
||||
|
||||
---
|
||||
|
||||
## 7. No follow-up touchpoints
|
||||
|
||||
**Pattern:** One announcement, no T+7 enablement touchpoint, no T+14 check-in. The comms team considers the work done at T+0.
|
||||
|
||||
**Why it fails:** ADKAR Reinforcement is unstaffed; Bridges' "Beginnings" phase is unsupported. Employees infer the leadership team has moved on to the next priority, which signals the change was not important enough to sustain — which becomes a self-fulfilling prophecy.
|
||||
|
||||
**Canon:** Hiatt *ADKAR* (Reinforcement stage); Bridges *Managing Transitions* (Beginnings phase); Prosci 5–7 touchpoint floor research.
|
||||
|
||||
**Skill enforcement:** `comms_calendar_builder.py` includes T+7 (Ability) and T+14 (Reinforcement) touchpoints; warns if either is missing from a custom plan.
|
||||
|
||||
---
|
||||
|
||||
## 8. No FAQ for a disruptive change
|
||||
|
||||
**Pattern:** A high or disruptive change ships without a published FAQ. Employees ask the obvious questions in Slack; the answers are inconsistent across teams; the rumor cycle outpaces the official channel.
|
||||
|
||||
**Why it fails:** If you don't write the FAQ, Slack writes it for you. The questions are knowable in advance (comp, reporting line, location, role, timing, why now); pre-answering them is cheap; not pre-answering them is expensive in trust.
|
||||
|
||||
**Canon:** Heath & Heath *Switch* (Path-shaping); Edelman Trust Barometer (unanswered-question finding); HBR Adam Grant on radical candor in organizations.
|
||||
|
||||
**Skill enforcement:** `comms_template_filler.py` always produces an FAQ artifact with a 7-question seed; flagging would be added if the FAQ artifact is suppressed.
|
||||
|
||||
---
|
||||
|
||||
## Case-study sources
|
||||
|
||||
- **Better.com / Vishal Garg layoff** (Dec 2021) — 900-person Zoom layoff with insufficient pre-comm and celebratory framing; the comms is the lead narrative two years later. Multiple post-mortems published in HBR / Fortune.
|
||||
- **Twitter / Musk layoffs** (Nov 2022) — email-only notification, managers uninformed, no FAQ, no follow-up. Used as the contemporary example of every anti-pattern compounding.
|
||||
- **Yahoo work-from-home reversal** (Feb 2013, Marissa Mayer) — leaked memo, no manager cascade, magnitude downplayed. The reversal became the dominant story for the rest of the CEO tenure.
|
||||
- **Bishop Fox layoff comms** — published post-mortem on doing layoff comms responsibly; cited as a contrast case showing how the same event with good comms produces a fundamentally different employee reaction.
|
||||
|
||||
## Sources at a glance
|
||||
|
||||
| # | Source | Type | Used in |
|
||||
|---|---|---|---|
|
||||
| 1 | Prosci 11th edition | Practitioner research | Synchronous-channel rule |
|
||||
| 2 | Sucher & Gupta (MIT Sloan, 2018) | Academic research | Magnitude-downplay rejection |
|
||||
| 3 | Lencioni *The Advantage* (2012) | Practitioner book | Passive-voice flag |
|
||||
| 4 | Adam Grant (HBR, multiple) | Practitioner / academic | Apology + radical-candor mechanics |
|
||||
| 5 | Better.com / Vishal Garg case (2021) | Case study | Magnitude + celebratory-framing tests |
|
||||
| 6 | Bishop Fox layoff post-mortem | Case study (contrast) | What "good" looks like |
|
||||
| 7 | Yahoo WFH-reversal case (2013) | Case study | Manager-cascade failure |
|
||||
| 8 | Twitter / Musk layoffs case (2022) | Case study | Multi-pattern compound failure |
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
# Change Management Canon
|
||||
|
||||
The seven foundational works on planned organizational change. The internal-comms skill is anchored on the first two (ADKAR + Kotter); the remaining five resolve specific failure modes the first two leave open.
|
||||
|
||||
---
|
||||
|
||||
## 1. Jeff Hiatt — *ADKAR: A Model for Change in Business, Government and Our Community* (Prosci, 2006)
|
||||
|
||||
The five-stage individual-change model behind every Prosci diagnostic and the load-bearing reference for this skill. Each employee must move through:
|
||||
|
||||
- **Awareness** of the need for change
|
||||
- **Desire** to support and participate in the change
|
||||
- **Knowledge** of how to change
|
||||
- **Ability** to implement the required skills and behaviors
|
||||
- **Reinforcement** to sustain the change
|
||||
|
||||
ADKAR is **sequential**: a deficit at an earlier stage is the lead diagnosis for resistance at a later stage. If the team has Knowledge but no Desire, training will not help; you have an Awareness/Desire problem masquerading as a skills problem. The two most-skipped stages in real deployments are **Desire** (because it's emotionally uncomfortable to surface) and **Reinforcement** (because the comms team has moved on to the next change).
|
||||
|
||||
**Operational implication for internal-comms:** every touchpoint should be tagged to a specific ADKAR stage. The `comms_template_filler.py` tool enforces this.
|
||||
|
||||
Reference: Hiatt, Jeff M. (2006). *ADKAR: A Model for Change in Business, Government and Our Community*. Prosci Research.
|
||||
|
||||
---
|
||||
|
||||
## 2. John P. Kotter — *Leading Change* (Harvard Business School Press, 1996)
|
||||
|
||||
The 8-step organizational-change model used by every executive sponsor since 1996:
|
||||
|
||||
1. Establish a Sense of Urgency
|
||||
2. Build a Guiding Coalition
|
||||
3. Form a Strategic Vision
|
||||
4. Communicate the Change Vision
|
||||
5. Empower Broad-Based Action
|
||||
6. Generate Short-Term Wins
|
||||
7. Sustain Acceleration
|
||||
8. Anchor New Approaches in the Culture
|
||||
|
||||
Kotter's central thesis: change efforts fail in **predictable ways at predictable steps**. The most common failure is Step 1 (false urgency / no urgency) producing a guiding coalition (Step 2) that is too weak, which makes everything downstream impossible.
|
||||
|
||||
Kotter pairs *organizationally* with Hiatt's *individual* ADKAR: ADKAR diagnoses one person; Kotter diagnoses the org. The `change_announcement_builder.py` tool produces explicit Step 1–8 labeled output so reviewers can audit which steps are weak.
|
||||
|
||||
Reference: Kotter, John P. (1996). *Leading Change*. Harvard Business School Press.
|
||||
|
||||
---
|
||||
|
||||
## 3. William Bridges — *Managing Transitions: Making the Most of Change* (Da Capo Lifelong Books, 1991; 4th ed. 2017)
|
||||
|
||||
Bridges distinguishes **change** (the external event — the re-org happens on June 1) from **transition** (the internal psychological adaptation — which takes weeks to months). Transition has three phases:
|
||||
|
||||
- **Endings** — letting go of the old role/team/identity
|
||||
- **The Neutral Zone** — the disorienting middle, where productivity dips
|
||||
- **Beginnings** — the new identity is internalized
|
||||
|
||||
Comms-implication: most announcements treat the change as a single date. The transition is not a date; it's a curve. The skill's 7-touchpoint calendar (T-3 through T+14) is designed to cover the front half of the Bridges curve, with the T+14 follow-up acknowledging the Neutral Zone.
|
||||
|
||||
Reference: Bridges, William. (1991, 4th ed. 2017). *Managing Transitions: Making the Most of Change*. Da Capo Lifelong Books.
|
||||
|
||||
---
|
||||
|
||||
## 4. Edgar Schein — *Organizational Culture and Leadership* (Jossey-Bass, 1985; 5th ed. 2017)
|
||||
|
||||
Schein's three-level model of culture (artifacts → espoused values → underlying assumptions) explains why announcements that contradict underlying assumptions fail even when the espoused values support them. If the underlying assumption is "we never lay people off" and the announcement is a 30% RIF, no amount of vision-casting in Step 3 will repair the trust break.
|
||||
|
||||
Comms-implication: the magnitude validation in `change_announcement_builder.py` exists because *understated* magnitude is the most common collision with underlying assumptions. Employees infer the assumption you're contradicting; you cannot hide it with adjective choice.
|
||||
|
||||
Reference: Schein, Edgar H. (1985, 5th ed. 2017). *Organizational Culture and Leadership*. Jossey-Bass.
|
||||
|
||||
---
|
||||
|
||||
## 5. McKinsey 7-S Framework (Waterman, Peters, & Phillips, 1980)
|
||||
|
||||
The 7-S framework lists seven interdependent organizational elements: Strategy, Structure, Systems, Shared Values, Style, Staff, Skills. Re-org announcements (the most common high-magnitude internal-comms event) typically change Structure but leave Systems, Style, and Staff alignment to chance — which is why the post-announcement 60-day window is the failure window.
|
||||
|
||||
Comms-implication: the "what stays the same" field in the announcement input is load-bearing. Saying *only* what changes leaves employees inferring everything else changed too.
|
||||
|
||||
Reference: Waterman, Robert H., Thomas J. Peters, and Julien R. Phillips (1980). "Structure Is Not Organization." *Business Horizons* 23 (3): 14–26.
|
||||
|
||||
---
|
||||
|
||||
## 6. Chip Heath & Dan Heath — *Switch: How to Change Things When Change Is Hard* (Crown Business, 2010)
|
||||
|
||||
The "Rider / Elephant / Path" model: rational reasoning (Rider) is overruled by emotional reaction (Elephant) unless the environment (Path) is shaped to make the right behavior easy. Practical implication for internal-comms: **clarity beats motivation**. "Migrate your Confluence space by Oct 1 — here is the one-click migration tool" outperforms a motivational vision statement every time.
|
||||
|
||||
Comms-implication: the FAQ stage in `comms_template_filler.py` is "Path-shaping" work; it makes specific actions easy. The Knowledge ADKAR stage is *not* the same as motivation — Heath would call it Path.
|
||||
|
||||
Reference: Heath, Chip, and Dan Heath. (2010). *Switch: How to Change Things When Change Is Hard*. Crown Business.
|
||||
|
||||
---
|
||||
|
||||
## 7. Patrick Lencioni — *The Advantage: Why Organizational Health Trumps Everything Else in Business* (Jossey-Bass, 2012)
|
||||
|
||||
Lencioni's argument: organizational health (clarity, consistency, communication) is a more durable competitive advantage than strategy. He prescribes "over-communicate with relentless repetition" — the same message, in the same words, repeated until the leadership team is bored of saying it. Prosci's 5–7-touchpoint floor is the operational expression of Lencioni's discipline.
|
||||
|
||||
Lencioni also names "vulnerability-based trust" as the bedrock of healthy leadership communication. The skill's anti-pattern check on passive-voice accountability ("decisions have been made") comes directly from this: hiding the decision-maker is a vulnerability-avoidance move that costs more trust than it saves.
|
||||
|
||||
Reference: Lencioni, Patrick. (2012). *The Advantage: Why Organizational Health Trumps Everything Else in Business*. Jossey-Bass.
|
||||
|
||||
---
|
||||
|
||||
## Sources at a glance
|
||||
|
||||
| # | Author(s) | Work | Year | Used in |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Hiatt | *ADKAR* | 2006 | All tools — stage tagging |
|
||||
| 2 | Kotter | *Leading Change* | 1996 | `change_announcement_builder.py` |
|
||||
| 3 | Bridges | *Managing Transitions* | 1991/2017 | `comms_calendar_builder.py` (T+14 follow-up) |
|
||||
| 4 | Schein | *Organizational Culture and Leadership* | 1985/2017 | Magnitude validation logic |
|
||||
| 5 | Waterman/Peters/Phillips | 7-S framework | 1980 | "What stays the same" field |
|
||||
| 6 | Heath & Heath | *Switch* | 2010 | FAQ as Path-shaping |
|
||||
| 7 | Lencioni | *The Advantage* | 2012 | Passive-voice anti-pattern check |
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
# Internal Communications Canon
|
||||
|
||||
Seven sources that anchor the *internal communications* discipline distinct from external/marketing comms. The audience is employees; the goal is trust, comprehension, and behavioral change — not awareness or conversion.
|
||||
|
||||
---
|
||||
|
||||
## 1. Edelman Trust Barometer (annual, since 2001)
|
||||
|
||||
The Edelman Trust Barometer is the longest-running cross-industry measurement of stakeholder trust. The internal-comms-relevant findings repeat year over year:
|
||||
|
||||
- **"My employer" is the single most trusted institution** in every survey since 2018, ranked above government, media, and NGOs.
|
||||
- **The most trusted source within an employer is the direct manager**, not the CEO.
|
||||
- **Trust collapses fastest** when the obvious question is unanswered or when leadership voice is absent during a crisis.
|
||||
|
||||
Operational implication: the FAQ scaffolding in `comms_template_filler.py` is built to pre-answer the obvious questions. The manager_cascade touchpoint in `comms_calendar_builder.py` exists because the direct manager is the load-bearing channel, not the CEO email.
|
||||
|
||||
Reference: Edelman. *Edelman Trust Barometer* (annual report). https://www.edelman.com/trust/trust-barometer
|
||||
|
||||
---
|
||||
|
||||
## 2. Gallup — *State of the American Workplace* (2017, periodic updates)
|
||||
|
||||
Gallup's longitudinal employee-engagement research finds:
|
||||
|
||||
- Only ~33% of US employees are engaged at work; the remainder are either disengaged or actively disengaged.
|
||||
- Engagement correlates most strongly with **"my manager talks to me about my progress"** and **"someone at work cares about me as a person"** — both manager-cascade dependencies.
|
||||
- Communications cadence matters more than communications volume; *predictable* cadence outperforms *frequent* cadence.
|
||||
|
||||
Operational implication: the T-3 manager pre-brief and the T+14 follow-up in the comms calendar exist because Gallup's data shows cadence beats volume.
|
||||
|
||||
Reference: Gallup. (2017). *State of the American Workplace*. Gallup, Inc. https://www.gallup.com/workplace/238085/state-american-workplace-report-2017.aspx
|
||||
|
||||
---
|
||||
|
||||
## 3. Liz Wiseman — *Multipliers: How the Best Leaders Make Everyone Smarter* (HarperBusiness, 2010; rev. 2017)
|
||||
|
||||
Wiseman distinguishes "Multipliers" (leaders who amplify their teams) from "Diminishers" (leaders who consume their teams' intelligence). The communications-relevant axis is the **Liberator vs Tyrant** axis: Multipliers create the safety for hard questions to be asked publicly; Diminishers create cultures where the real questions show up only in Slack DMs and Glassdoor.
|
||||
|
||||
Comms-implication: the Q&A thread in the comms calendar (T+1, sponsor responding live) exists to model the Multiplier-Liberator stance. If the sponsor cannot or will not respond publicly to hard questions, the announcement is incomplete.
|
||||
|
||||
Reference: Wiseman, Liz. (2010, rev. 2017). *Multipliers: How the Best Leaders Make Everyone Smarter*. HarperBusiness.
|
||||
|
||||
---
|
||||
|
||||
## 4. Stew Friedman — *Total Leadership: Be a Better Leader, Have a Richer Life* (Harvard Business Review Press, 2008)
|
||||
|
||||
Friedman's "four-way wins" model (work, home, community, self) is built on a foundation of *honest communication of trade-offs*. The implication for change announcements: an announcement that ignores the work-life cost of a change (e.g., a re-org that increases on-call burden, a tool rollout that requires nights/weekends to learn) loses trust on the omitted dimension.
|
||||
|
||||
Comms-implication: the "what is not being said" forcing question in the skill's question library is a Friedman move — surface the trade-off, don't hide it.
|
||||
|
||||
Reference: Friedman, Stewart D. (2008). *Total Leadership: Be a Better Leader, Have a Richer Life*. Harvard Business Review Press.
|
||||
|
||||
---
|
||||
|
||||
## 5. Bersin (Josh Bersin / Deloitte) — Employee Communications Research (2015–2023)
|
||||
|
||||
Bersin's research into "high-performing communications organizations" identifies recurring practices:
|
||||
|
||||
- The 5–7 touchpoint floor for behavioral change is consistent with Prosci and is independently confirmed in Bersin data.
|
||||
- **Segmented messaging** outperforms broadcast messaging by ~2× on retention metrics — the same message tailored per audience segment.
|
||||
- **Two-way channels** (Q&A, office hours, manager 1:1) outperform one-way channels (email, Slack post) on trust metrics, especially for high-magnitude changes.
|
||||
|
||||
Operational implication: the audience-segments field in the comms-brief input is required, not optional. The T+1 Q&A touchpoint exists because Bersin's two-way-channel finding is robust.
|
||||
|
||||
Reference: Bersin, Josh, and Deloitte. (2015–2023). *High-Impact Employee Communications* research series. Bersin/Deloitte. https://joshbersin.com
|
||||
|
||||
---
|
||||
|
||||
## 6. Mary Welch & Paul R. Jackson — "Rethinking internal communication: a stakeholder approach" (*Corporate Communications*, 2007)
|
||||
|
||||
The academic baseline reference for internal-communication taxonomy. Welch & Jackson define four internal communication "dimensions":
|
||||
|
||||
- **Internal line management communication** (manager-to-team)
|
||||
- **Internal team peer communication** (peer-to-peer within team)
|
||||
- **Internal project peer communication** (peer-to-peer across teams)
|
||||
- **Internal corporate communication** (leadership-to-all)
|
||||
|
||||
Each dimension has different audiences, channels, trust dynamics, and failure modes. Most internal announcements default to the fourth dimension (corporate broadcast) and ignore the first (manager cascade) — which is the highest-trust channel. The skill's manager_cascade touchpoint is the Welch-Jackson first dimension made operational.
|
||||
|
||||
Reference: Welch, Mary, and Paul R. Jackson. (2007). "Rethinking internal communication: a stakeholder approach." *Corporate Communications: An International Journal* 12 (2): 177–198. doi:10.1108/13563280710744847
|
||||
|
||||
---
|
||||
|
||||
## 7. International Association of Business Communicators (IABC) — *Code of Ethics* + *Global Standard* (1995, updated 2015, 2023)
|
||||
|
||||
IABC is the professional body for internal/corporate communicators. Its *Code of Ethics* and *Global Standard* set the floor for ethical practice:
|
||||
|
||||
- **Truthful and accurate communications** — no euphemism for layoffs ("right-sizing", "streamlining for impact" applied to a RIF is an IABC violation).
|
||||
- **Two-way symmetric communication** as the goal (Grunig's excellence model) — broadcast is the floor, dialogue is the standard.
|
||||
- **Confidentiality** and **conflict-of-interest** disclosure — relevant for acquisition announcements where comms is briefed under NDA.
|
||||
|
||||
Comms-implication: the magnitude/tone validation logic in `change_announcement_builder.py` is implementing the IABC truthful-and-accurate standard. The Code of Ethics is a useful escalation reference when a sponsor pushes for misleading framing.
|
||||
|
||||
Reference: International Association of Business Communicators. (1995, updated 2015, 2023). *Code of Ethics for Professional Communicators* and *IABC Global Standard*. IABC. https://www.iabc.com/About/Purpose/Code-of-Ethics
|
||||
|
||||
---
|
||||
|
||||
## Sources at a glance
|
||||
|
||||
| # | Author(s) | Work | Year | Used in |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Edelman | Trust Barometer | annual | Manager-cascade as #1 trusted channel |
|
||||
| 2 | Gallup | State of the American Workplace | 2017 | Cadence over volume |
|
||||
| 3 | Wiseman | *Multipliers* | 2010/2017 | Sponsor-led Q&A thread |
|
||||
| 4 | Friedman | *Total Leadership* | 2008 | "What's not being said" question |
|
||||
| 5 | Bersin | Employee Comms Research | 2015–2023 | 5–7 touchpoint floor + segmentation |
|
||||
| 6 | Welch & Jackson | Internal-comm taxonomy paper | 2007 | Manager-cascade dimension |
|
||||
| 7 | IABC | Code of Ethics + Global Standard | 1995/2015/2023 | Magnitude/tone validation logic |
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
#!/usr/bin/env python3
|
||||
"""change_announcement_builder.py
|
||||
|
||||
Produce a Kotter 8-step compliant primary internal announcement.
|
||||
|
||||
Kotter's 8 steps (Kotter 1996, *Leading Change*):
|
||||
1. Establish Urgency
|
||||
2. Build Guiding Coalition
|
||||
3. Form Strategic Vision
|
||||
4. Communicate the Vision
|
||||
5. Empower Broad-Based Action
|
||||
6. Generate Short-Term Wins
|
||||
7. Sustain Momentum / Consolidate Gains
|
||||
8. Anchor in Culture
|
||||
|
||||
Each step is explicitly labeled inline in the output so reviewers can audit
|
||||
which steps are weak.
|
||||
|
||||
Validates magnitude vs. tone:
|
||||
- 'disruptive' magnitude rejects "exciting news" / "thrilled to" / "celebrate" framing
|
||||
- 'high' magnitude rejects "minor update" / "small change" framing
|
||||
- 'layoff'-like phrasing without a 'disruptive' magnitude flag is rejected
|
||||
|
||||
Industry tuning via --profile {tech-startup, scaleup, enterprise, public-company, non-profit}.
|
||||
|
||||
Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MAGNITUDES = {"low", "medium", "high", "disruptive"}
|
||||
PROFILES = {"tech-startup", "scaleup", "enterprise", "public-company", "non-profit"}
|
||||
|
||||
# Per-profile tone calibration. Public-company is conservative (material-event
|
||||
# awareness, no forward-looking-statement language). Startup is direct.
|
||||
PROFILE_TONE: dict[str, dict[str, str]] = {
|
||||
"tech-startup": {
|
||||
"urgency": "We're moving on this now because the business signal is clear and waiting costs more than acting.",
|
||||
"coalition": "Decided by: {decided_by}. Supported by the leadership team.",
|
||||
"vision": "Where we want to be in 12 months and why this change is the path to get there.",
|
||||
"communicate": "Same content, same day, same level of detail to everyone — no inner-circle leaks.",
|
||||
"empower": "Managers have a talking-points doc; ICs have an FAQ; both are linked below.",
|
||||
"wins": "The first measurable signal we expect to see in 30 days is named below.",
|
||||
"sustain": "We will re-broadcast progress at 30 / 60 / 90 days. Same channel, same sponsor.",
|
||||
"anchor": "This change is now part of how we work. The hiring rubric and review criteria are being updated to reflect it.",
|
||||
},
|
||||
"scaleup": {
|
||||
"urgency": "The decision was made now because waiting another quarter would compound the problem we're solving.",
|
||||
"coalition": "Decided by {decided_by}, with input from the leadership team and the impacted function leads.",
|
||||
"vision": "The 12-month state this change moves us toward, and the metric that will tell us we've arrived.",
|
||||
"communicate": "All segments receive the same core message today; segment-specific FAQs follow within 24 hours.",
|
||||
"empower": "Managers have talking points; an FAQ is published; office hours are scheduled.",
|
||||
"wins": "A 30-day milestone is committed; progress is shared on the same channel.",
|
||||
"sustain": "Cadence: 30 / 60 / 90-day check-ins from the named sponsor.",
|
||||
"anchor": "Operating rhythm, hiring criteria, and review rubric will reflect the change going forward.",
|
||||
},
|
||||
"enterprise": {
|
||||
"urgency": "This decision aligns with the strategic plan approved by leadership; the timing reflects readiness, not crisis.",
|
||||
"coalition": "Sponsored by {decided_by}. The change has been reviewed by the relevant business-unit leadership and applicable functions (HR, Legal, Finance).",
|
||||
"vision": "Strategic objective and the operating outcome this change advances.",
|
||||
"communicate": "Cascade plan: leadership > directors > managers > ICs, with consistent core messaging at each level.",
|
||||
"empower": "Manager toolkits, FAQ, town-hall schedule, and the change-network contacts are published on the intranet.",
|
||||
"wins": "First measurable success criterion is named for the 30-day review.",
|
||||
"sustain": "Standing change-management cadence at 30 / 60 / 90 days, reporting against the success criteria.",
|
||||
"anchor": "Operating procedures, role descriptions, and performance criteria will be updated to reflect the change.",
|
||||
},
|
||||
"public-company": {
|
||||
"urgency": "This change supports the strategic priorities communicated to shareholders; timing reflects internal readiness.",
|
||||
"coalition": "Sponsored by {decided_by}. Reviewed with the relevant leadership and functional partners.",
|
||||
"vision": "How this advances the publicly stated strategic priorities.",
|
||||
"communicate": "Internal communication is coordinated with Investor Relations; please direct external inquiries to IR.",
|
||||
"empower": "Manager toolkits and FAQ are published; office hours are scheduled. Material non-public information should be handled per the insider-trading policy.",
|
||||
"wins": "Internal milestone for the 30-day review is named; external disclosure will follow standard reporting cadence.",
|
||||
"sustain": "Internal cadence at 30 / 60 / 90 days; external reporting through normal disclosure channels.",
|
||||
"anchor": "Operating procedures will be updated through the standard policy-update workflow.",
|
||||
},
|
||||
"non-profit": {
|
||||
"urgency": "This change reflects our commitment to the mission and the constituents we serve.",
|
||||
"coalition": "Decided by {decided_by}, in consultation with leadership and (where applicable) the board.",
|
||||
"vision": "How this change advances the mission and serves our constituents better.",
|
||||
"communicate": "Staff hear from leadership today; volunteers and constituents are briefed through the channels they normally hear from.",
|
||||
"empower": "Managers and program leads have talking points; an FAQ is published; office hours are open.",
|
||||
"wins": "First mission-aligned milestone is named for the 30-day review.",
|
||||
"sustain": "Cadence at 30 / 60 / 90 days with a focus on mission outcomes.",
|
||||
"anchor": "Programs, training, and onboarding materials will be updated to reflect the change.",
|
||||
},
|
||||
}
|
||||
|
||||
# Magnitude/tone anti-pattern keywords (case-insensitive substring match).
|
||||
DISRUPTIVE_BANNED = [
|
||||
"exciting news", "thrilled to", "celebrate", "win for the team",
|
||||
"great opportunity", "happy to share",
|
||||
]
|
||||
HIGH_BANNED = [
|
||||
"minor update", "small change", "tiny tweak", "no big deal", "minor restructuring",
|
||||
]
|
||||
LAYOFF_KEYWORDS = ["layoff", "reduction in force", "rif", "let go", "eliminating", "redundanc"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Announcement:
|
||||
change_summary: str
|
||||
magnitude: str
|
||||
profile: str
|
||||
steps: list[dict] = field(default_factory=list)
|
||||
validations: list[str] = field(default_factory=list)
|
||||
blocked: bool = False
|
||||
|
||||
|
||||
def _lower(s: str) -> str:
|
||||
return (s or "").lower()
|
||||
|
||||
|
||||
def validate_tone(raw: dict, magnitude: str) -> list[str]:
|
||||
issues: list[str] = []
|
||||
combined = " ".join([
|
||||
_lower(raw.get("change_summary", "")),
|
||||
_lower(raw.get("why_this_change", "")),
|
||||
_lower(raw.get("what_changes", "")),
|
||||
_lower(raw.get("what_stays_the_same", "")),
|
||||
])
|
||||
|
||||
if magnitude == "disruptive":
|
||||
for kw in DISRUPTIVE_BANNED:
|
||||
if kw in combined:
|
||||
issues.append(
|
||||
f"REJECTED: 'disruptive' magnitude with celebratory framing "
|
||||
f"('{kw}'). Anti-pattern (Sucher & Gupta, MIT Sloan)."
|
||||
)
|
||||
if magnitude == "high":
|
||||
for kw in HIGH_BANNED:
|
||||
if kw in combined:
|
||||
issues.append(
|
||||
f"REJECTED: 'high' magnitude with minimizing framing "
|
||||
f"('{kw}'). Anti-pattern (Better.com / Vishal-Garg case)."
|
||||
)
|
||||
# Layoff-keyword vs magnitude check
|
||||
layoff_present = any(kw in combined for kw in LAYOFF_KEYWORDS)
|
||||
if layoff_present and magnitude != "disruptive":
|
||||
issues.append(
|
||||
"REJECTED: layoff/RIF language present but magnitude is not "
|
||||
"'disruptive'. Re-classify magnitude before continuing."
|
||||
)
|
||||
# Passive-voice accountability check
|
||||
if "decisions have been made" in combined or "the decision has been made" in combined:
|
||||
issues.append(
|
||||
"WARN: passive-voice accountability detected ('decisions have been "
|
||||
"made'). Name the decision-maker (Lencioni: Vulnerability-Based Trust)."
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def build_announcement(raw: dict, profile: str) -> Announcement:
|
||||
if profile not in PROFILES:
|
||||
raise SystemExit(f"--profile must be one of {sorted(PROFILES)}; got '{profile}'")
|
||||
mag = raw.get("change_magnitude", "")
|
||||
if mag not in MAGNITUDES:
|
||||
raise SystemExit(
|
||||
f"change_magnitude must be one of {sorted(MAGNITUDES)}; got '{mag}'"
|
||||
)
|
||||
decided_by = raw.get("who_decided") or "the leadership team"
|
||||
tone = PROFILE_TONE[profile]
|
||||
validations = validate_tone(raw, mag)
|
||||
blocked = any(v.startswith("REJECTED") for v in validations)
|
||||
|
||||
summary = raw.get("change_summary", "[no summary provided]")
|
||||
why = raw.get("why_this_change", "[no reason provided]")
|
||||
what_changes = raw.get("what_changes", "[change scope not specified]")
|
||||
what_stays = raw.get("what_stays_the_same", "[stability scope not specified]")
|
||||
eff = raw.get("effective_date", "[date TBD]")
|
||||
next_steps = raw.get("next_steps", "[next steps TBD]")
|
||||
qa_seed = list(raw.get("q_and_a_seed") or [])
|
||||
|
||||
steps = [
|
||||
{
|
||||
"step": 1,
|
||||
"label": "Establish Urgency",
|
||||
"body": (
|
||||
f"{tone['urgency']} The change: {summary}. The reason now: {why}. "
|
||||
f"Effective: {eff}. Magnitude: {mag}."
|
||||
),
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"label": "Build Guiding Coalition",
|
||||
"body": tone["coalition"].format(decided_by=decided_by),
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"label": "Form Strategic Vision",
|
||||
"body": (
|
||||
f"{tone['vision']} What changes: {what_changes}. What stays the "
|
||||
f"same: {what_stays}."
|
||||
),
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"label": "Communicate the Vision",
|
||||
"body": tone["communicate"],
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"label": "Empower Broad-Based Action",
|
||||
"body": (
|
||||
f"{tone['empower']} Next steps: {next_steps}."
|
||||
),
|
||||
},
|
||||
{
|
||||
"step": 6,
|
||||
"label": "Generate Short-Term Wins",
|
||||
"body": tone["wins"],
|
||||
},
|
||||
{
|
||||
"step": 7,
|
||||
"label": "Sustain Momentum",
|
||||
"body": tone["sustain"],
|
||||
},
|
||||
{
|
||||
"step": 8,
|
||||
"label": "Anchor in Culture",
|
||||
"body": tone["anchor"],
|
||||
},
|
||||
]
|
||||
|
||||
# Append seeded Q&A as appendix to Step 5 (empower-with-info).
|
||||
if qa_seed:
|
||||
qa_block = "\n\nSeed Q&A appendix (Empower stage):\n"
|
||||
for i, item in enumerate(qa_seed, 1):
|
||||
q = item.get("q", "") if isinstance(item, dict) else str(item)
|
||||
a = item.get("a", "") if isinstance(item, dict) else ""
|
||||
qa_block += f" Q{i}: {q}\n A{i}: {a}\n"
|
||||
steps[4]["body"] += qa_block
|
||||
|
||||
return Announcement(
|
||||
change_summary=summary,
|
||||
magnitude=mag,
|
||||
profile=profile,
|
||||
steps=steps,
|
||||
validations=validations,
|
||||
blocked=blocked,
|
||||
)
|
||||
|
||||
|
||||
def render_markdown(a: Announcement) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append(f"# Change Announcement (Kotter 8-step)")
|
||||
lines.append("")
|
||||
lines.append(f"**Change:** {a.change_summary} ")
|
||||
lines.append(f"**Magnitude:** {a.magnitude} ")
|
||||
lines.append(f"**Profile (tone):** {a.profile} ")
|
||||
if a.blocked:
|
||||
lines.append("")
|
||||
lines.append("> **BLOCKED — magnitude/tone anti-pattern detected. Fix before publishing.**")
|
||||
lines.append("")
|
||||
for s in a.steps:
|
||||
lines.append(f"## Step {s['step']} — {s['label']}")
|
||||
lines.append("")
|
||||
lines.append(s["body"])
|
||||
lines.append("")
|
||||
if a.validations:
|
||||
lines.append("## Validation results")
|
||||
lines.append("")
|
||||
for v in a.validations:
|
||||
lines.append(f"- {v}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def sample_input() -> dict:
|
||||
return {
|
||||
"change_summary": "Migrating internal documentation from Confluence to Notion",
|
||||
"why_this_change": "Confluence search reliability and the cost of duplicate licenses with Notion (already used by product team) make consolidation the right move now",
|
||||
"what_changes": "All net-new documentation is authored in Notion starting June 1; existing Confluence pages are read-only after Aug 1 and migrated by Oct 1",
|
||||
"what_stays_the_same": "Permissions model, document ownership, retention policy",
|
||||
"effective_date": "2026-06-01",
|
||||
"who_decided": "Head of IT and the engineering leadership team",
|
||||
"change_magnitude": "medium",
|
||||
"q_and_a_seed": [
|
||||
{"q": "Will my existing Confluence pages move automatically?",
|
||||
"a": "Yes. The IT team is handling migration through Oct 1. You will be notified when your space is migrated."},
|
||||
{"q": "Do I need to learn a new tool?",
|
||||
"a": "Notion fundamentals are covered in the office-hour series starting June 5. Self-serve docs are linked below."},
|
||||
],
|
||||
"next_steps": "Office hours start June 5; manager talking points are in the intranet under People-Ops > Change > Notion-rollout.",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Build a Kotter 8-step compliant internal change announcement."
|
||||
)
|
||||
p.add_argument("--input", type=Path, help="Path to announcement-input JSON.")
|
||||
p.add_argument(
|
||||
"--profile",
|
||||
choices=sorted(PROFILES),
|
||||
default="scaleup",
|
||||
help="Industry profile for tone calibration (default: scaleup).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--output", choices=["markdown", "json"], default="markdown",
|
||||
help="Output format (default: markdown).",
|
||||
)
|
||||
p.add_argument("--sample", action="store_true", help="Use built-in sample and exit.")
|
||||
args = p.parse_args()
|
||||
|
||||
if args.sample:
|
||||
raw = sample_input()
|
||||
else:
|
||||
if not args.input:
|
||||
p.error("--input is required unless --sample is given")
|
||||
if not args.input.exists():
|
||||
p.error(f"input file not found: {args.input}")
|
||||
with args.input.open("r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
|
||||
ann = build_announcement(raw, args.profile)
|
||||
if args.output == "json":
|
||||
print(json.dumps(asdict(ann), indent=2))
|
||||
else:
|
||||
print(render_markdown(ann))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
#!/usr/bin/env python3
|
||||
"""comms_calendar_builder.py
|
||||
|
||||
Build a 7-touchpoint sequencing calendar for an internal change event.
|
||||
|
||||
Prosci's documented floor for behavioral change adoption is 5–7 touchpoints
|
||||
across multiple channels (Prosci Best Practices in Change Management, 11th ed.).
|
||||
This tool produces a sequenced calendar with timing (T-N / T+N), channel,
|
||||
owner, ADKAR stage, and key message per touchpoint.
|
||||
|
||||
Surfaces gaps and channel mismatches as anti-pattern warnings:
|
||||
- <5 touchpoints planned for disruptive change
|
||||
- Slack-only sequencing for a layoff (synchronous channel required)
|
||||
- No manager_cascade touchpoint before the announcement
|
||||
- No follow-up touchpoint after the announcement
|
||||
|
||||
Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MAGNITUDES = {"low", "medium", "high", "disruptive"}
|
||||
KNOWN_CHANNELS = {
|
||||
"email", "slack", "allhands", "manager_cascade",
|
||||
"town_hall", "intranet",
|
||||
}
|
||||
SYNCHRONOUS_CHANNELS = {"allhands", "town_hall"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarTouchpoint:
|
||||
seq: int
|
||||
timing: str # T-3, T+0, T+7 etc.
|
||||
offset_days: int # negative = before, positive = after
|
||||
channel: str
|
||||
owner: str
|
||||
adkar_stage: str
|
||||
key_message: str
|
||||
iso_date: str # ISO date if effective_date is provided
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarReport:
|
||||
change_name: str
|
||||
magnitude: str
|
||||
effective_date: str
|
||||
audience_size: int
|
||||
channels_available: list[str]
|
||||
working_days_available: int
|
||||
touchpoints: list[CalendarTouchpoint] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _pick_channel(channels: list[str], preferred: list[str], fallback: str) -> str:
|
||||
for p in preferred:
|
||||
if p in channels:
|
||||
return p
|
||||
return fallback if fallback in channels else (channels[0] if channels else "email")
|
||||
|
||||
|
||||
def _compute_iso(eff: str, offset_days: int) -> str:
|
||||
if not eff:
|
||||
return ""
|
||||
try:
|
||||
d = datetime.fromisoformat(eff).date()
|
||||
except ValueError:
|
||||
return ""
|
||||
return (d + timedelta(days=offset_days)).isoformat()
|
||||
|
||||
|
||||
def build_calendar(raw: dict) -> CalendarReport:
|
||||
event = raw.get("change_event") or {}
|
||||
name = event.get("name", "Untitled Change")
|
||||
mag = event.get("magnitude", "medium")
|
||||
if mag not in MAGNITUDES:
|
||||
raise SystemExit(
|
||||
f"change_event.magnitude must be one of {sorted(MAGNITUDES)}; got '{mag}'"
|
||||
)
|
||||
eff = str(event.get("effective_date", "")).strip()
|
||||
audience_size = int(event.get("audience_size", 0) or 0)
|
||||
channels = list(raw.get("channels_available") or [])
|
||||
if not channels:
|
||||
raise SystemExit("channels_available must be a non-empty list")
|
||||
working_days = int(raw.get("working_days_available", 14) or 14)
|
||||
|
||||
# Validate channels
|
||||
unknown = [c for c in channels if c not in KNOWN_CHANNELS]
|
||||
warnings: list[str] = []
|
||||
if unknown:
|
||||
warnings.append(
|
||||
f"Unknown channels (will be ignored for sequencing logic): {unknown}. "
|
||||
f"Known: {sorted(KNOWN_CHANNELS)}"
|
||||
)
|
||||
|
||||
# Touchpoint plan — 7 entries, keyed to T-N / T+N
|
||||
# Sequence (Prosci 5–7 floor; we ship 7):
|
||||
# 1) T-3 manager_cascade (Awareness)
|
||||
# 2) T-1 email (Awareness — broad heads-up)
|
||||
# 3) T+0 allhands/town_hall (Knowledge — primary announcement)
|
||||
# 4) T+0 intranet/email (Knowledge — FAQ publication)
|
||||
# 5) T+1 slack (Desire — Q&A thread, sponsor reads replies)
|
||||
# 6) T+7 email (Ability — training / how-to / office hours)
|
||||
# 7) T+14 allhands/email (Reinforcement — 2-week check-in)
|
||||
plan = [
|
||||
{"seq": 1, "offset": -3, "preferred": ["manager_cascade", "email"],
|
||||
"fallback": "email", "owner": "manager_cascade_owner",
|
||||
"adkar": "Awareness", "msg": "Manager pre-brief: talking points + timing"},
|
||||
{"seq": 2, "offset": -1, "preferred": ["email"],
|
||||
"fallback": "email", "owner": "internal_comms_lead",
|
||||
"adkar": "Awareness", "msg": "Save-the-date heads-up to all-hands"},
|
||||
{"seq": 3, "offset": 0, "preferred": ["town_hall", "allhands"],
|
||||
"fallback": "allhands", "owner": "sponsor_exec",
|
||||
"adkar": "Knowledge", "msg": "Primary announcement, sponsor present"},
|
||||
{"seq": 4, "offset": 0, "preferred": ["intranet", "email"],
|
||||
"fallback": "email", "owner": "internal_comms_lead",
|
||||
"adkar": "Knowledge", "msg": "FAQ + supporting docs published"},
|
||||
{"seq": 5, "offset": 1, "preferred": ["slack", "intranet"],
|
||||
"fallback": "slack", "owner": "sponsor_exec",
|
||||
"adkar": "Desire", "msg": "Q&A thread, sponsor responding live"},
|
||||
{"seq": 6, "offset": 7, "preferred": ["email", "intranet"],
|
||||
"fallback": "email", "owner": "enablement_lead",
|
||||
"adkar": "Ability", "msg": "Training / office-hours / how-to"},
|
||||
{"seq": 7, "offset": 14, "preferred": ["allhands", "email"],
|
||||
"fallback": "email", "owner": "sponsor_exec",
|
||||
"adkar": "Reinforcement", "msg": "2-week check-in: progress + still-open items"},
|
||||
]
|
||||
|
||||
touchpoints: list[CalendarTouchpoint] = []
|
||||
for item in plan:
|
||||
chan = _pick_channel(channels, item["preferred"], item["fallback"])
|
||||
timing = f"T{item['offset']:+d}" if item["offset"] != 0 else "T+0"
|
||||
touchpoints.append(CalendarTouchpoint(
|
||||
seq=item["seq"],
|
||||
timing=timing,
|
||||
offset_days=item["offset"],
|
||||
channel=chan,
|
||||
owner=item["owner"],
|
||||
adkar_stage=item["adkar"],
|
||||
key_message=item["msg"],
|
||||
iso_date=_compute_iso(eff, item["offset"]),
|
||||
))
|
||||
|
||||
# Anti-pattern checks
|
||||
if len(touchpoints) < 5:
|
||||
warnings.append(
|
||||
f"ANTI-PATTERN: only {len(touchpoints)} touchpoints planned. "
|
||||
"Prosci floor for behavioral change is 5–7."
|
||||
)
|
||||
if mag == "disruptive" and not any(t.channel in SYNCHRONOUS_CHANNELS for t in touchpoints):
|
||||
warnings.append(
|
||||
"ANTI-PATTERN: disruptive change with no synchronous channel "
|
||||
"(town_hall / allhands). Required."
|
||||
)
|
||||
# Layoff inference: name contains layoff/RIF keyword
|
||||
name_l = name.lower()
|
||||
layoff_event = any(kw in name_l for kw in ["layoff", "rif", "reduction in force", "redundanc"])
|
||||
if layoff_event:
|
||||
if all(t.channel == "slack" for t in touchpoints):
|
||||
warnings.append(
|
||||
"ANTI-PATTERN: Slack-only sequencing for a layoff event. "
|
||||
"Synchronous channel (town_hall + manager_cascade 1:1) required."
|
||||
)
|
||||
if not any(t.channel == "manager_cascade" for t in touchpoints):
|
||||
warnings.append(
|
||||
"ANTI-PATTERN: layoff event with no manager_cascade touchpoint. "
|
||||
"Affected employees must hear from their direct manager first."
|
||||
)
|
||||
# Pre-comm check
|
||||
if not any(t.offset_days < 0 and t.channel == "manager_cascade" for t in touchpoints):
|
||||
warnings.append(
|
||||
"WARN: no manager_cascade touchpoint scheduled before announcement. "
|
||||
"Managers should hear 24–48h ahead so the cascade does not break "
|
||||
"on first contact with reports."
|
||||
)
|
||||
# Follow-up check
|
||||
if not any(t.offset_days > 7 for t in touchpoints):
|
||||
warnings.append(
|
||||
"WARN: no follow-up touchpoint scheduled >7 days after announcement. "
|
||||
"ADKAR Reinforcement stage is unstaffed."
|
||||
)
|
||||
# Working-days feasibility
|
||||
needed_days = 14 + 3 # T-3 to T+14
|
||||
if working_days < needed_days:
|
||||
warnings.append(
|
||||
f"WARN: working_days_available={working_days} is less than the "
|
||||
f"{needed_days}-day span needed (T-3 through T+14). Compress at risk."
|
||||
)
|
||||
# Audience-size sanity for channels
|
||||
if audience_size > 500 and "allhands" not in channels and "town_hall" not in channels:
|
||||
warnings.append(
|
||||
f"WARN: audience_size={audience_size} but no all-hands/town-hall channel. "
|
||||
"Large audiences require a synchronous channel for trust events."
|
||||
)
|
||||
|
||||
return CalendarReport(
|
||||
change_name=name,
|
||||
magnitude=mag,
|
||||
effective_date=eff,
|
||||
audience_size=audience_size,
|
||||
channels_available=channels,
|
||||
working_days_available=working_days,
|
||||
touchpoints=touchpoints,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def render_markdown(r: CalendarReport) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append(f"# Comms Calendar — {r.change_name}")
|
||||
lines.append("")
|
||||
lines.append(f"**Magnitude:** {r.magnitude} ")
|
||||
lines.append(f"**Effective date:** {r.effective_date or '_(not provided)_'} ")
|
||||
lines.append(f"**Audience size:** {r.audience_size} ")
|
||||
lines.append(f"**Channels available:** {', '.join(r.channels_available)} ")
|
||||
lines.append(f"**Working days available:** {r.working_days_available} ")
|
||||
lines.append("")
|
||||
lines.append("## Touchpoint sequence (7 entries)")
|
||||
lines.append("")
|
||||
lines.append("| # | Timing | ISO date | Channel | Owner | ADKAR | Key message |")
|
||||
lines.append("|---|--------|----------|---------|-------|-------|-------------|")
|
||||
for t in r.touchpoints:
|
||||
lines.append(
|
||||
f"| {t.seq} | {t.timing} | {t.iso_date or '—'} | {t.channel} | "
|
||||
f"{t.owner} | {t.adkar_stage} | {t.key_message} |"
|
||||
)
|
||||
lines.append("")
|
||||
if r.warnings:
|
||||
lines.append("## Warnings / anti-patterns")
|
||||
lines.append("")
|
||||
for w in r.warnings:
|
||||
lines.append(f"- {w}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def sample_input() -> dict:
|
||||
return {
|
||||
"change_event": {
|
||||
"name": "Reorganization: merging Platform and Infrastructure into one group",
|
||||
"magnitude": "high",
|
||||
"effective_date": "2026-06-15",
|
||||
"audience_size": 320,
|
||||
},
|
||||
"channels_available": [
|
||||
"email", "slack", "allhands", "manager_cascade", "town_hall", "intranet",
|
||||
],
|
||||
"working_days_available": 21,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Build a 7-touchpoint internal-comms sequencing calendar."
|
||||
)
|
||||
p.add_argument("--input", type=Path, help="Path to calendar-input JSON.")
|
||||
p.add_argument(
|
||||
"--output", choices=["markdown", "json"], default="markdown",
|
||||
help="Output format (default: markdown).",
|
||||
)
|
||||
p.add_argument("--sample", action="store_true", help="Use built-in sample and exit.")
|
||||
args = p.parse_args()
|
||||
|
||||
if args.sample:
|
||||
raw = sample_input()
|
||||
else:
|
||||
if not args.input:
|
||||
p.error("--input is required unless --sample is given")
|
||||
if not args.input.exists():
|
||||
p.error(f"input file not found: {args.input}")
|
||||
with args.input.open("r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
|
||||
rep = build_calendar(raw)
|
||||
if args.output == "json":
|
||||
print(json.dumps(asdict(rep), indent=2))
|
||||
else:
|
||||
print(render_markdown(rep))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
#!/usr/bin/env python3
|
||||
"""comms_template_filler.py
|
||||
|
||||
Fill a 4-artifact internal-comms package — pre-comm, primary announcement, FAQ,
|
||||
follow-up — for a specific internal change event. Each touchpoint is explicitly
|
||||
tagged with the ADKAR stage (Awareness / Desire / Knowledge / Ability /
|
||||
Reinforcement) it serves, per audience segment.
|
||||
|
||||
ADKAR (Prosci, Hiatt 2006) anchors each artifact:
|
||||
pre-comm -> Awareness + Desire (manager-first cascade)
|
||||
announcement -> Knowledge + Desire reinforcement
|
||||
FAQ -> Knowledge + Ability
|
||||
follow-up -> Ability + Reinforcement
|
||||
|
||||
Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CHANGE_TYPES = {
|
||||
"reorg",
|
||||
"tool_rollout",
|
||||
"policy_change",
|
||||
"leadership_change",
|
||||
"layoff",
|
||||
"acquisition",
|
||||
"product_launch_internal",
|
||||
"benefit_change",
|
||||
}
|
||||
|
||||
MAGNITUDES = {"low", "medium", "high", "disruptive"}
|
||||
|
||||
ADKAR_STAGES = ["Awareness", "Desire", "Knowledge", "Ability", "Reinforcement"]
|
||||
|
||||
|
||||
# Per change-type, default ADKAR emphasis order (the stage most at risk first).
|
||||
ADKAR_EMPHASIS: dict[str, list[str]] = {
|
||||
"reorg": ["Desire", "Knowledge", "Ability", "Reinforcement", "Awareness"],
|
||||
"tool_rollout": ["Knowledge", "Ability", "Desire", "Reinforcement", "Awareness"],
|
||||
"policy_change": ["Awareness", "Knowledge", "Ability", "Reinforcement", "Desire"],
|
||||
"leadership_change": ["Awareness", "Desire", "Reinforcement", "Knowledge", "Ability"],
|
||||
"layoff": ["Awareness", "Desire", "Reinforcement", "Knowledge", "Ability"],
|
||||
"acquisition": ["Awareness", "Desire", "Knowledge", "Reinforcement", "Ability"],
|
||||
"product_launch_internal": ["Knowledge", "Ability", "Desire", "Awareness", "Reinforcement"],
|
||||
"benefit_change": ["Awareness", "Knowledge", "Ability", "Desire", "Reinforcement"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Touchpoint:
|
||||
artifact: str # pre-comm | announcement | faq | follow-up
|
||||
audience_segment: str
|
||||
adkar_stage: str
|
||||
channel: str
|
||||
timing: str # e.g., T-2, T+0, T+7
|
||||
subject: str
|
||||
body: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommsPackage:
|
||||
change_type: str
|
||||
magnitude: str
|
||||
effective_date: str
|
||||
audience_segments: list[str]
|
||||
channels: list[str]
|
||||
touchpoints: list[Touchpoint] = field(default_factory=list)
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def validate_input(raw: dict) -> tuple[str, str, str, list[str], list[str]]:
|
||||
ct = raw.get("change_type", "")
|
||||
if ct not in CHANGE_TYPES:
|
||||
raise SystemExit(
|
||||
f"change_type must be one of {sorted(CHANGE_TYPES)}; got '{ct}'"
|
||||
)
|
||||
mag = raw.get("change_magnitude", "")
|
||||
if mag not in MAGNITUDES:
|
||||
raise SystemExit(
|
||||
f"change_magnitude must be one of {sorted(MAGNITUDES)}; got '{mag}'"
|
||||
)
|
||||
eff = str(raw.get("effective_date", "")).strip()
|
||||
if not eff:
|
||||
raise SystemExit("effective_date is required (ISO 8601 string)")
|
||||
segs = list(raw.get("audience_segments") or [])
|
||||
if not segs:
|
||||
raise SystemExit("audience_segments must be a non-empty list")
|
||||
chans = list(raw.get("channels") or [])
|
||||
if not chans:
|
||||
raise SystemExit("channels must be a non-empty list")
|
||||
return ct, mag, eff, segs, chans
|
||||
|
||||
|
||||
def _pick_channel(channels: list[str], preferred: list[str]) -> str:
|
||||
for p in preferred:
|
||||
if p in channels:
|
||||
return p
|
||||
return channels[0]
|
||||
|
||||
|
||||
def _segment_voice(segment: str) -> str:
|
||||
s = segment.lower()
|
||||
if "manager" in s or "lead" in s:
|
||||
return "manager"
|
||||
if "exec" in s or "leadership" in s:
|
||||
return "exec"
|
||||
if "affected" in s and "un" not in s:
|
||||
return "affected"
|
||||
if "unaffected" in s or "rest" in s:
|
||||
return "unaffected"
|
||||
return "ic"
|
||||
|
||||
|
||||
def _precomm_body(ct: str, mag: str, eff: str, segment: str) -> str:
|
||||
voice = _segment_voice(segment)
|
||||
if voice == "manager":
|
||||
return (
|
||||
f"You are receiving this pre-brief because the {ct.replace('_', ' ')} "
|
||||
f"will be announced company-wide on {eff} (magnitude: {mag}). "
|
||||
"Please review the talking points below and be ready to answer "
|
||||
"questions from your direct reports starting at the announcement time. "
|
||||
"Do not share this content before the announcement window opens. "
|
||||
"If a report asks a question you cannot answer, say 'I will follow up "
|
||||
"by end of day' rather than speculating."
|
||||
)
|
||||
if voice == "exec":
|
||||
return (
|
||||
f"This is a sponsor brief for the {ct.replace('_', ' ')} on {eff}. "
|
||||
"You are named as the accountable executive in the announcement and "
|
||||
"the town hall. Please confirm both within 24 hours. Decline now if "
|
||||
"you cannot be visibly present — invisible sponsorship is a Kotter "
|
||||
"step-1 failure."
|
||||
)
|
||||
return (
|
||||
f"Heads-up: there is a {ct.replace('_', ' ')} announcement scheduled for "
|
||||
f"{eff}. More information is being prepared. No action is required from "
|
||||
"you yet."
|
||||
)
|
||||
|
||||
|
||||
def _announcement_body(ct: str, mag: str, eff: str, segment: str) -> str:
|
||||
return (
|
||||
f"This message announces the {ct.replace('_', ' ')}, effective {eff}. "
|
||||
f"Magnitude: {mag}. The reasoning, the scope, the people affected, and "
|
||||
"the immediate next steps are below. Each section is written to answer "
|
||||
"a specific employee question. See the FAQ for the questions we have "
|
||||
"anticipated; reply to this thread (or join the town hall) to ask the "
|
||||
"ones we have not."
|
||||
)
|
||||
|
||||
|
||||
def _faq_body(ct: str, mag: str, segment: str) -> str:
|
||||
seed = [
|
||||
("Will my compensation change?",
|
||||
"State the answer plainly. If unchanged: 'No.' If changing: name the "
|
||||
"effective date and the comp-review channel."),
|
||||
("Will my reporting line change?",
|
||||
"Name the new manager (or confirm it is unchanged). If TBD, name the "
|
||||
"date by which it will be confirmed."),
|
||||
("Will my role or scope change?",
|
||||
"Describe the delta concretely. Avoid 'evolving' / 'transforming'."),
|
||||
("Is this a precursor to layoffs?",
|
||||
"Answer directly. Hedged answers here are read as 'yes'."),
|
||||
("When does this take effect?",
|
||||
"Single date; if phased, list the phase dates."),
|
||||
("Why now?",
|
||||
"One sentence on the trigger; reference the business signal, not the "
|
||||
"internal politics."),
|
||||
("Who decided this and who can I ask follow-up questions?",
|
||||
"Name a single accountable executive and a single channel for follow-up."),
|
||||
]
|
||||
lines = [f"FAQ for {segment} (change: {ct.replace('_', ' ')}, magnitude: {mag}):", ""]
|
||||
for q, a in seed:
|
||||
lines.append(f"Q: {q}")
|
||||
lines.append(f"A: {a}")
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
|
||||
def _followup_body(ct: str, eff: str, segment: str) -> str:
|
||||
return (
|
||||
f"Two weeks after {eff}, this follow-up reinforces the {ct.replace('_', ' ')}. "
|
||||
"Three signals are reported: (1) measurable outcome the change was meant "
|
||||
"to produce, (2) one specific story of an employee adapting successfully "
|
||||
"(Ability stage), and (3) what is still open and the date it will close "
|
||||
"(Reinforcement stage). Reply with feedback; the sponsor reads every reply."
|
||||
)
|
||||
|
||||
|
||||
def build(raw: dict) -> CommsPackage:
|
||||
ct, mag, eff, segs, chans = validate_input(raw)
|
||||
pkg = CommsPackage(
|
||||
change_type=ct,
|
||||
magnitude=mag,
|
||||
effective_date=eff,
|
||||
audience_segments=segs,
|
||||
channels=chans,
|
||||
)
|
||||
|
||||
emphasis = ADKAR_EMPHASIS.get(ct, ADKAR_STAGES)
|
||||
|
||||
for seg in segs:
|
||||
voice = _segment_voice(seg)
|
||||
# 1. Pre-comm (T-2): Awareness + Desire (manager-cascade priority)
|
||||
pkg.touchpoints.append(Touchpoint(
|
||||
artifact="pre-comm",
|
||||
audience_segment=seg,
|
||||
adkar_stage="Awareness" if voice != "manager" else "Desire",
|
||||
channel=_pick_channel(chans, ["manager_cascade", "email", "slack"]),
|
||||
timing="T-2",
|
||||
subject=f"[Pre-brief] {ct.replace('_', ' ').title()} announcement on {eff}",
|
||||
body=_precomm_body(ct, mag, eff, seg),
|
||||
))
|
||||
# 2. Announcement (T+0): Knowledge primary; ADKAR emphasis stage as secondary
|
||||
pkg.touchpoints.append(Touchpoint(
|
||||
artifact="announcement",
|
||||
audience_segment=seg,
|
||||
adkar_stage="Knowledge",
|
||||
channel=_pick_channel(chans, ["allhands", "town_hall", "email"]),
|
||||
timing="T+0",
|
||||
subject=f"{ct.replace('_', ' ').title()}: what's changing and why",
|
||||
body=_announcement_body(ct, mag, eff, seg),
|
||||
))
|
||||
# 3. FAQ (T+0 immediately after announcement): Knowledge + Ability
|
||||
pkg.touchpoints.append(Touchpoint(
|
||||
artifact="faq",
|
||||
audience_segment=seg,
|
||||
adkar_stage="Ability",
|
||||
channel=_pick_channel(chans, ["intranet", "email", "slack"]),
|
||||
timing="T+0",
|
||||
subject=f"FAQ — {ct.replace('_', ' ').title()}",
|
||||
body=_faq_body(ct, mag, seg),
|
||||
))
|
||||
# 4. Follow-up (T+14): Reinforcement
|
||||
pkg.touchpoints.append(Touchpoint(
|
||||
artifact="follow-up",
|
||||
audience_segment=seg,
|
||||
adkar_stage="Reinforcement",
|
||||
channel=_pick_channel(chans, ["email", "allhands", "slack"]),
|
||||
timing="T+14",
|
||||
subject=f"Two-week check-in: {ct.replace('_', ' ')}",
|
||||
body=_followup_body(ct, eff, seg),
|
||||
))
|
||||
|
||||
# Notes / anti-pattern guards
|
||||
if mag == "disruptive" and "town_hall" not in chans and "allhands" not in chans:
|
||||
pkg.notes.append(
|
||||
"ANTI-PATTERN: disruptive change without a synchronous channel "
|
||||
"(town_hall / allhands). Add one before publication."
|
||||
)
|
||||
if ct == "layoff" and "manager_cascade" not in chans:
|
||||
pkg.notes.append(
|
||||
"ANTI-PATTERN: layoff comms without manager_cascade channel. "
|
||||
"Direct-manager 1:1 is mandatory for affected employees."
|
||||
)
|
||||
if len(pkg.touchpoints) < 5:
|
||||
pkg.notes.append(
|
||||
"Prosci floor for behavioral change is 5–7 touchpoints; current "
|
||||
f"plan has {len(pkg.touchpoints)}. Add more segments or channels."
|
||||
)
|
||||
pkg.notes.append(
|
||||
f"ADKAR emphasis order for change_type='{ct}': "
|
||||
+ " > ".join(emphasis)
|
||||
)
|
||||
return pkg
|
||||
|
||||
|
||||
def render_markdown(pkg: CommsPackage) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append(f"# Internal Comms Package — {pkg.change_type.replace('_', ' ').title()}")
|
||||
lines.append("")
|
||||
lines.append(f"**Magnitude:** {pkg.magnitude} ")
|
||||
lines.append(f"**Effective date:** {pkg.effective_date} ")
|
||||
lines.append(f"**Audience segments:** {', '.join(pkg.audience_segments)} ")
|
||||
lines.append(f"**Channels available:** {', '.join(pkg.channels)} ")
|
||||
lines.append("")
|
||||
for tp in pkg.touchpoints:
|
||||
lines.append(f"## [{tp.artifact}] {tp.audience_segment} — ADKAR: {tp.adkar_stage}")
|
||||
lines.append("")
|
||||
lines.append(f"- **Timing:** {tp.timing} ")
|
||||
lines.append(f"- **Channel:** {tp.channel} ")
|
||||
lines.append(f"- **Subject:** {tp.subject}")
|
||||
lines.append("")
|
||||
lines.append(tp.body)
|
||||
lines.append("")
|
||||
if pkg.notes:
|
||||
lines.append("## Notes")
|
||||
lines.append("")
|
||||
for n in pkg.notes:
|
||||
lines.append(f"- {n}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def sample_input() -> dict:
|
||||
return {
|
||||
"change_type": "tool_rollout",
|
||||
"audience_segments": ["engineering managers", "engineering ICs", "rest of company"],
|
||||
"change_magnitude": "medium",
|
||||
"effective_date": "2026-06-01",
|
||||
"channels": ["email", "slack", "allhands", "manager_cascade", "intranet"],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Fill a 4-artifact internal-comms package with ADKAR-tagged touchpoints."
|
||||
)
|
||||
p.add_argument("--input", type=Path, help="Path to comms-brief JSON.")
|
||||
p.add_argument(
|
||||
"--output", choices=["markdown", "json"], default="markdown",
|
||||
help="Output format (default: markdown).",
|
||||
)
|
||||
p.add_argument("--sample", action="store_true", help="Use built-in sample and exit.")
|
||||
args = p.parse_args()
|
||||
|
||||
if args.sample:
|
||||
raw = sample_input()
|
||||
else:
|
||||
if not args.input:
|
||||
p.error("--input is required unless --sample is given")
|
||||
if not args.input.exists():
|
||||
p.error(f"input file not found: {args.input}")
|
||||
with args.input.open("r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
|
||||
pkg = build(raw)
|
||||
if args.output == "json":
|
||||
print(json.dumps(asdict(pkg), indent=2))
|
||||
else:
|
||||
print(render_markdown(pkg))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "commercial-skills",
|
||||
"description": "7 Commercial skills + 1 orchestrator: pricing-strategist (Van Westendorp WTP + packaging + model picker), deal-desk (margin + discount routing + redline scoring), partnerships-architect (tier + joint GTM + revshare), channel-economics (cost-to-serve + ROI), commercial-policy (discount matrix + exception flow), rfp-responder (structured RFP/RFI response + win-theme), commercial-forecaster (bookings/ARR with funnel + cohort math). Orchestrator skill uses context: fork to route. 21 stdlib-only Python tools, 28 reference docs. Distinct from business-growth (sales execution), c-level-advisor/cro-advisor (strategic CRO), finance (close-and-report).",
|
||||
"description": "7 Commercial skills + 1 orchestrator: pricing-strategist (Van Westendorp WTP + packaging + model picker), deal-desk (margin + discount routing + redline scoring), partnerships-architect (5-tier classifier + joint GTM + revshare modeler), channel-economics (cost-to-serve + ROI + channel mix optimizer), commercial-policy (data-backed discount matrix + exception flow + policy linter), rfp-responder (Shipley-method structured RFP/RFI/RFQ response + win-theme + winrate predictor; context: fork for heavy intake), commercial-forecaster (4Q-weighted bookings + cohort NRR/GRR + funnel-confidence with mandatory assumption disclosure). Orchestrator skill uses context: fork. 21 stdlib-only Python tools, 28+ reference docs. Distinct from business-growth (sales execution), c-level-advisor/cro-advisor (strategic CRO), finance (close-and-report).",
|
||||
"version": "2.8.0",
|
||||
"author": {
|
||||
"name": "Alireza Rezvani",
|
||||
|
|
@ -12,11 +12,16 @@
|
|||
"skills": [
|
||||
"./skills/commercial-skills",
|
||||
"./skills/pricing-strategist",
|
||||
"./skills/deal-desk"
|
||||
"./skills/deal-desk",
|
||||
"./skills/partnerships-architect",
|
||||
"./skills/channel-economics",
|
||||
"./skills/commercial-policy",
|
||||
"./skills/rfp-responder",
|
||||
"./skills/commercial-forecaster"
|
||||
],
|
||||
"source": {
|
||||
"spec": "documentation/implementation/bizops-commercial-expansion-plan.md",
|
||||
"build_pattern": "Path B (direct conversion) — orchestrator skill uses context: fork to chain sub-skills without polluting parent context. Sprint 1 ships orchestrator + 2 sub-skills (pricing-strategist, deal-desk). Sprint 2 adds partnerships-architect, channel-economics, commercial-policy, rfp-responder, commercial-forecaster.",
|
||||
"distinct_from": "business-growth/sales-engineer (technical sale: demos, POCs). business-growth/revenue-operations (lead routing, SDR motion). business-growth/contract-and-proposal-writer (authoring prose, not structured response). c-level-advisor/cro-advisor (strategic 'when to hire VP Sales' calls, not per-deal approval). finance/financial-analysis (close + report, not forward forecast or per-deal economics)."
|
||||
"build_pattern": "Path B (direct conversion) — orchestrator skill uses context: fork. Sprint 1 shipped orchestrator + 2 sub-skills (pricing-strategist, deal-desk). Sprint 2 adds partnerships-architect (5-tier + joint GTM + revshare), channel-economics (cost-to-serve + ROI + mix), commercial-policy (discount matrix + exception flow + linter), rfp-responder (Shipley structured response, context: fork for heavy intake), commercial-forecaster (4Q-weighted with mandatory assumption disclosure). Every SKILL.md ships a Forcing-question library section per Matt Pocock grill-with-docs discipline.",
|
||||
"distinct_from": "business-growth/sales-engineer (technical sale: demos, POCs). business-growth/revenue-operations (lead routing, SDR motion). business-growth/contract-and-proposal-writer (free-form authoring, not buyer-dictated structured response). c-level-advisor/cro-advisor (strategic 'when to hire VP Sales' calls, not per-deal approval). finance/financial-analysis (close + report, not forward forecast or per-deal economics)."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,35 +6,41 @@ This file provides domain-specific guidance for skills in `commercial/`.
|
|||
|
||||
The Commercial domain ships skills that help **deal-desk operators, pricing teams, partner managers, RFP responders, and commercial forecasters** make per-deal and packaging decisions. This is **not strategy** (`c-level-advisor/cro-advisor`), **not sales execution** (`business-growth/sales-engineer`), and **not financial close** (`finance/financial-analysis`).
|
||||
|
||||
## Skills (Sprint 1, v2.8.0)
|
||||
## Skills (v2.8.0 complete)
|
||||
|
||||
| Skill | Purpose | `context: fork`? |
|
||||
|---|---|---|
|
||||
| `commercial-skills` | Domain orchestrator — routes to 7 sub-skills | YES |
|
||||
| `pricing-strategist` | Pricing model picker + Van Westendorp WTP + packaging | NO |
|
||||
| `deal-desk` | Per-deal scorer + discount approval routing + redline | NO |
|
||||
|
||||
Sprint 2: `partnerships-architect`, `channel-economics`, `commercial-policy`, `rfp-responder`, `commercial-forecaster`.
|
||||
| `partnerships-architect` | 5-tier classifier + joint GTM + revshare modeler | NO |
|
||||
| `channel-economics` | Cost-to-serve + ROI + channel mix optimizer | NO |
|
||||
| `commercial-policy` | Data-backed discount matrix + exception flow + policy linter | NO |
|
||||
| `rfp-responder` | Shipley-method structured RFP/RFI/RFQ response | YES |
|
||||
| `commercial-forecaster` | 4Q-weighted bookings + cohort NRR/GRR + funnel-confidence | NO |
|
||||
|
||||
## Hard rules (domain-specific)
|
||||
|
||||
1. **Pricing outputs: model + range, never a specific number.** The human picks the number.
|
||||
2. **Deal outputs: score + named human approver. Never auto-approve.** Even at 0% discount.
|
||||
3. **Forecast outputs: surface the conversion assumption explicitly.** Pipeline math without disclosed assumptions is theatre.
|
||||
4. **Stdlib-only Python.** Deterministic logic, no LLM calls in scripts.
|
||||
5. **Industry tuning** via `--profile {saas,api,enterprise,marketplace,services}` on every scoring tool.
|
||||
6. **Matt Pocock grill discipline** — `/cs:grill-commercial` interrogates plan against SaaS pricing canon before any sub-skill runs.
|
||||
4. **RFP responses: never invent claims for GAP requirements.** Surface the gap; leadership decides bid/no-bid.
|
||||
5. **Partnership tiers: insist on independent-demand evidence for STRATEGIC.** Forrester: channel-led deals from your own pipeline cost more than direct.
|
||||
6. **Channel ROI requires retention differential.** CAC alone is meaningless without channel-level retention.
|
||||
7. **Stdlib-only Python.** Deterministic logic, no LLM calls in scripts.
|
||||
8. **Industry tuning** via `--profile {saas,api,enterprise-software,marketplace,services,hardware}` on every scoring tool.
|
||||
9. **Matt Pocock grill discipline** — `/cs:grill-commercial` interrogates plan against SaaS pricing canon before any sub-skill runs.
|
||||
|
||||
## Build pattern
|
||||
|
||||
Path-B 11-file contract per skill. SKILL.md includes a "Forcing-question library" section that grills the user with cited canon (Skok, Tunguz, Bessemer, Ramanujam, ProfitWell, Winning by Design).
|
||||
Path-B 11-file contract per skill. SKILL.md includes a "Forcing-question library" section that grills the user with cited canon (Skok, Tunguz, Bessemer, Ramanujam, ProfitWell, Winning by Design, Shipley, APMP).
|
||||
|
||||
## Agent + command pattern
|
||||
|
||||
- `cs-commercial-orchestrator` — margin-protective Commercial lead. Voice: "What's the margin at full discount, AND what does next quarter's pipeline look like at the same terms?"
|
||||
- `/cs:commercial <inquiry>` — top-level router
|
||||
- `/cs:grill-commercial <plan>` — Matt-style grilling first
|
||||
- `/cs:pricing-strategy`, `/cs:deal-review` — direct invocation
|
||||
- `/cs:pricing-strategy`, `/cs:deal-review`, `/cs:partner-tier`, `/cs:channel-econ`, `/cs:commercial-policy`, `/cs:rfp-respond`, `/cs:commercial-forecast` — direct per-skill invocation
|
||||
|
||||
## Anti-patterns (domain-level)
|
||||
|
||||
|
|
@ -43,6 +49,8 @@ Path-B 11-file contract per skill. SKILL.md includes a "Forcing-question library
|
|||
- ❌ Skills that recommend a specific price — recommend model + range
|
||||
- ❌ Skills that auto-approve deals — score + route to named human
|
||||
- ❌ Forecasting tools that hide conversion assumptions
|
||||
- ❌ RFP responder that invents claims — surface GAPs, leadership decides
|
||||
- ❌ Partnership classifier that grants STRATEGIC tier without independent-demand evidence
|
||||
|
||||
## References
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,27 @@
|
|||
# commercial
|
||||
|
||||
**Per-deal-and-packaging Commercial skills: pricing, deal desk, partnerships, channel economics, RFP, forecast.**
|
||||
**Per-deal-and-packaging Commercial skills: pricing, deal desk, partnerships, channel economics, policy, RFP, forecast.**
|
||||
|
||||
v2.8.0 — 3 skills (Sprint 1) + 5 more in Sprint 2.
|
||||
v2.8.0 — 8 skills (orchestrator + 7 sub-skills), 21 stdlib Python tools, 28 references citing 7+ authoritative sources each.
|
||||
|
||||
## Skills
|
||||
|
||||
| Skill | Job-to-be-done |
|
||||
|---|---|
|
||||
| [`commercial-skills`](skills/commercial-skills/) | Orchestrator — routes via `context: fork` |
|
||||
| [`commercial-skills`](skills/commercial-skills/) | Orchestrator — routes via `context: fork` + Matt Pocock grill discipline |
|
||||
| [`pricing-strategist`](skills/pricing-strategist/) | "What pricing model fits us, and what's the WTP range?" — model picker + Van Westendorp + packaging |
|
||||
| [`deal-desk`](skills/deal-desk/) | "Should we approve this discount, and what's the redline?" — score + route + redline |
|
||||
| [`partnerships-architect`](skills/partnerships-architect/) | "What tier is this partner, and what should the revshare be?" — 5-tier + joint GTM + revshare |
|
||||
| [`channel-economics`](skills/channel-economics/) | "Is partner-led actually profitable after full-load cost-to-serve?" — mix + CTS + ROI |
|
||||
| [`commercial-policy`](skills/commercial-policy/) | "What does our discount matrix and exception flow look like?" — matrix + exception + linter |
|
||||
| [`rfp-responder`](skills/rfp-responder/) | "Should we bid this RFP, and with what win-themes + proof points?" — Shipley method + winrate predictor |
|
||||
| [`commercial-forecaster`](skills/commercial-forecaster/) | "What's our quarter commit, and what assumption is it resting on?" — 4Q-weighted + cohort + confidence |
|
||||
|
||||
## Commands
|
||||
|
||||
- `/cs:commercial <inquiry>` — top-level router
|
||||
- `/cs:grill-commercial <plan>` — Matt Pocock-style grilling against SaaS pricing canon
|
||||
- `/cs:pricing-strategy`, `/cs:deal-review` — direct per-skill invocation
|
||||
- `/cs:pricing-strategy`, `/cs:deal-review`, `/cs:partner-tier`, `/cs:channel-econ`, `/cs:commercial-policy`, `/cs:rfp-respond`, `/cs:commercial-forecast` — direct per-skill invocation
|
||||
|
||||
## Agent
|
||||
|
||||
|
|
@ -26,9 +31,10 @@ v2.8.0 — 3 skills (Sprint 1) + 5 more in Sprint 2.
|
|||
|
||||
- `business-growth/sales-engineer` — technical sale (demos, POCs)
|
||||
- `business-growth/revenue-operations` — process (lead routing, SDR motion)
|
||||
- `business-growth/contract-and-proposal-writer` — authoring prose, not structured response
|
||||
- `c-level-advisor/cro-advisor` — strategic CRO ("when do we hire VP Sales?"), not tactical
|
||||
- `finance/financial-analysis` — close + report, not forward forecast
|
||||
- `business-growth/contract-and-proposal-writer` — free-form authoring; rfp-responder is structured response
|
||||
- `c-level-advisor/cro-advisor` — strategic CRO ("when do we hire VP Sales?"), not tactical per-deal/per-policy
|
||||
- `c-level-advisor/cfo-advisor` — strategic financial planning; commercial-forecaster is tactical quarterly pipeline
|
||||
- `finance/financial-analysis` — close + report (backward); commercial-forecaster is forward
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
29
commercial/commands/cs-channel-econ.md
Normal file
29
commercial/commands/cs-channel-econ.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
description: Direct vs partner-led channel economics — fully-loaded cost-to-serve, channel ROI, optimal channel mix. NOT partnership structure (sibling partnerships-architect). Direct invocation of the channel-economics skill.
|
||||
argument-hint: "<channels with deal count + ARR + costs, or path to channel JSON>"
|
||||
---
|
||||
|
||||
# /cs:channel-econ — Direct vs partner economics
|
||||
|
||||
Run the `channel-economics` skill on this input:
|
||||
|
||||
**$ARGUMENTS**
|
||||
|
||||
## Three-tool workflow
|
||||
|
||||
1. **`channel_mix_optimizer.py`** — Per-channel effective LTV, payback period (CAC / monthly margin), LTV/CAC efficiency ratio. Recommends mix maximizing effective ARR subject to constraints (min_direct_pct, max_partner_concentration). Sensitivity table (what if direct CAC rises 20%?).
|
||||
|
||||
2. **`cost_to_serve_calculator.py`** — Fully-loaded cost-to-serve per deal AND per $ ARR. Breaks out direct costs vs allocated overhead. Computes "true gross margin" after channel-specific load. Surfaces hidden costs (partner enablement time, certification investment, conflict resolution overhead).
|
||||
|
||||
3. **`channel_roi_analyzer.py`** — ROI per channel with 3 lenses: cash ROI year-1, LTV ROI, marginal ROI (diminishing-returns curve). Verdict: DOUBLE-DOWN / MAINTAIN / DEFUND / EXIT + diminishing-returns inflection point.
|
||||
|
||||
## Hard rule
|
||||
|
||||
**No channel ROI computation without retention differential.** Channel CAC alone is meaningless — partner-channel customers often have different retention than direct.
|
||||
|
||||
## Distinct from
|
||||
|
||||
- Sibling `partnerships-architect` — partnership **structure** (tier, GTM, revshare). Channel-economics is the **math**.
|
||||
- `business-growth/revenue-operations` — process (lead routing, SDR motion)
|
||||
- `c-level-advisor/cro-advisor` — strategic
|
||||
- `finance/financial-analysis` — close + report (backward-looking); channel-economics is **forward** per-channel economics
|
||||
29
commercial/commands/cs-commercial-forecast.md
Normal file
29
commercial/commands/cs-commercial-forecast.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
description: Forward bookings / billings / ARR forecast with funnel + cohort math + conversion-assumption disclosure. NOT financial close (finance). Direct invocation of the commercial-forecaster skill.
|
||||
argument-hint: "<pipeline export path or cohort + funnel data>"
|
||||
---
|
||||
|
||||
# /cs:commercial-forecast — Forward bookings + cohort ARR + funnel confidence
|
||||
|
||||
Run the `commercial-forecaster` skill on this input:
|
||||
|
||||
**$ARGUMENTS**
|
||||
|
||||
## Three-tool workflow
|
||||
|
||||
1. **`bookings_forecaster.py`** — Stage-conversion based bookings forecast using last-4-quarters weighted (most recent heavier). Outputs commit / best-case / pipe-only. Industry tuning `--profile {saas,api,enterprise-software,marketplace,services}`.
|
||||
|
||||
2. **`cohort_arr_projector.py`** — NRR + GRR projection by acquisition cohort. Surfaces leaky cohorts before they show up in the consolidated NRR number.
|
||||
|
||||
3. **`funnel_confidence_scorer.py`** — Confidence band per stage: how stable is the conversion rate q-over-q? High variance = low confidence forecast.
|
||||
|
||||
## Hard rule
|
||||
|
||||
**Conversion assumption ALWAYS surfaced explicitly.** Forecasts without disclosed assumptions are theatre. The output names the conversion rate used and the data window it's based on.
|
||||
|
||||
## Distinct from
|
||||
|
||||
- `finance/financial-analysis` — **close + report** (backward-looking). Commercial-forecaster is **forward** commercial pipeline.
|
||||
- `c-level-advisor/cfo-advisor` — strategic financial planning. Commercial-forecaster is tactical, per-quarter.
|
||||
- `c-level-advisor/cro-advisor` — strategic CRO. Commercial-forecaster feeds CRO judgment.
|
||||
- Sibling `pricing-strategist` — sets prices; commercial-forecaster projects revenue at those prices.
|
||||
29
commercial/commands/cs-commercial-policy.md
Normal file
29
commercial/commands/cs-commercial-policy.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
description: Discount matrix designer + T&C library + exception policy. New ground — designs the policy that deal-desk applies per deal. Direct invocation of the commercial-policy skill.
|
||||
argument-hint: "<inputs: deal-band data, current discount distribution, target NRR>"
|
||||
---
|
||||
|
||||
# /cs:commercial-policy — Discount matrix + exception flow
|
||||
|
||||
Run the `commercial-policy` skill on this input:
|
||||
|
||||
**$ARGUMENTS**
|
||||
|
||||
## Three-tool workflow
|
||||
|
||||
1. **`discount_matrix_builder.py`** — Data-backed discount bands (by ARR band × term length × payment terms × strategic value). Outputs the matrix + the approver tier per cell. Industry tuning `--profile {saas,enterprise-software,api,marketplace,services}`.
|
||||
|
||||
2. **`exception_router.py`** — Exception flow: when a deal asks for terms outside the matrix, who approves and what compensating commitments are required (multi-year + prepay + named expansion path).
|
||||
|
||||
3. **`policy_linter.py`** — Consistency check across the matrix: no contradictions (e.g., "Manager approves up to 25%" but "VP approves up to 20%"), no gaps (deal band with no defined approver), no obvious gaming surface (cliff at 99 ARR vs 100 ARR).
|
||||
|
||||
## Hard rule
|
||||
|
||||
**No discount band without data backing.** Pull win-rate and NRR by current band before recommending changes.
|
||||
|
||||
## Distinct from
|
||||
|
||||
- Sibling `commercial/skills/deal-desk` — **applies** the policy to individual deals. Commercial-policy **designs** the policy.
|
||||
- Sibling `commercial/skills/pricing-strategist` — sets the **pricing model + tier list price**. Commercial-policy governs **discounts off list**.
|
||||
- `c-level-advisor/cro-advisor` — strategic
|
||||
- `c-level-advisor/cfo-advisor` — financial guardrails (margin floor); commercial-policy operationalizes those
|
||||
29
commercial/commands/cs-partner-tier.md
Normal file
29
commercial/commands/cs-partner-tier.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
description: Partner tier classification (Referral / Reseller / OEM / SI / Strategic) + joint GTM plan + revshare model. NOT technical sale and NOT channel economics math. Direct invocation of the partnerships-architect skill.
|
||||
argument-hint: "<partner intake: type, evidence of independent demand, commitments>"
|
||||
---
|
||||
|
||||
# /cs:partner-tier — Partner tier + joint GTM + revshare
|
||||
|
||||
Run the `partnerships-architect` skill on this input:
|
||||
|
||||
**$ARGUMENTS**
|
||||
|
||||
## Three-tool workflow
|
||||
|
||||
1. **`partner_tier_classifier.py`** — 5-tier deterministic classification: REFERRAL (informal) / RESELLER (transactional + margin) / OEM (white-label + integration) / SI/CONSULTING (services attach) / STRATEGIC (multi-year + co-investment). Hard floors per tier (STRATEGIC requires ≥5 named accounts sourced + multi-year commit + dedicated resources). Tier verdict + **kill criteria** for when partnership should unwind.
|
||||
|
||||
2. **`joint_gtm_planner.py`** — 90-day joint GTM plan with pre-launch milestones, launch motion, mid-quarter checkpoint, 90-day success criteria. Validates: cannot plan "channel-led" for REFERRAL tier.
|
||||
|
||||
3. **`revshare_modeler.py`** — Recommended revshare % band based on contribution depth (REFERRAL 5-10%, RESELLER 20-35%, OEM 40-55%) + break-even partner-program ROI + long-term economics crossover.
|
||||
|
||||
## Hard rule
|
||||
|
||||
**Insist on independent-demand evidence before classifying STRATEGIC.** Forrester: channel-led deals from your own pipeline cost more than direct.
|
||||
|
||||
## Distinct from
|
||||
|
||||
- `business-growth/sales-engineer` — technical sale (demos, POCs)
|
||||
- Sibling `channel-economics` — cost-to-serve + ROI math, not partnership structure
|
||||
- `c-level-advisor/cro-advisor` — strategic CRO
|
||||
- `c-level-advisor/ma-playbook` — acquisition, not partnership
|
||||
28
commercial/commands/cs-rfp-respond.md
Normal file
28
commercial/commands/cs-rfp-respond.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
description: Structured RFP/RFI/RFQ response with win-theme injection and proof-point matrix. NOT free-form proposal authoring (that's business-growth/contract-and-proposal-writer). Direct invocation of the rfp-responder skill.
|
||||
argument-hint: "<RFP document path or RFP requirements list>"
|
||||
---
|
||||
|
||||
# /cs:rfp-respond — RFP structured response + win-theme + winrate prediction
|
||||
|
||||
Run the `rfp-responder` skill on this input:
|
||||
|
||||
**$ARGUMENTS**
|
||||
|
||||
## Three-tool workflow
|
||||
|
||||
1. **`rfp_parser.py`** — Extracts sections + requirements + scoring criteria from RFP text. Tags each requirement: MANDATORY / WEIGHTED / NICE-TO-HAVE. Surfaces scoring weight if disclosed.
|
||||
|
||||
2. **`response_drafter.py`** — Proof-point matrix per requirement (case studies, certs, customer quotes, technical attestations). Refuses to invent claims — requires either a verifiable source or an explicit "GAP" label. Win-theme injection per Shipley methodology.
|
||||
|
||||
3. **`winrate_predictor.py`** — Shipley-derived winrate estimate from: incumbent advantage, requirement-fit %, relationship strength with buyer, decision-criteria alignment, late-entry penalty.
|
||||
|
||||
## Hard rule
|
||||
|
||||
**Every proof point must have a verifiable source.** No invented claims. GAP labels surface explicitly so leadership decides whether to address or no-bid.
|
||||
|
||||
## Distinct from
|
||||
|
||||
- `business-growth/contract-and-proposal-writer` — **free-form** proposal authoring (your-narrative-driven). RFP-responder handles **structured** response where the buyer dictates the format and the questions.
|
||||
- `c-level-advisor/general-counsel-advisor` — contract redline. RFP-responder is the response **before** the contract.
|
||||
- `marketing-skill/*` — external marketing assets (web, ads, content). RFP-responder is a sales-enablement artifact tied to one buyer.
|
||||
149
commercial/skills/channel-economics/SKILL.md
Normal file
149
commercial/skills/channel-economics/SKILL.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
---
|
||||
name: channel-economics
|
||||
description: "Use when reviewing or rebalancing direct vs. partner-led channel economics — computing fully-loaded cost-to-serve per channel, channel ROI with cash / LTV / marginal lenses, and optimal channel mix subject to constraints. For Head of Commercial, RevOps, and VP Sales doing quarterly channel review when pipeline is mixed (e.g., 60% direct + 40% partner-led) and nobody actually knows which channel makes money after CAC, support load, partner discount, deal-velocity differences, retention differential, and overhead allocation are all loaded in. Outputs cost to serve, channel ROI verdicts (DOUBLE-DOWN / MAINTAIN / DEFUND / EXIT), a sensitivity-tested channel-mix recommendation, and the diminishing-returns inflection. Not channel structure (that's partnerships-architect — tiers, joint GTM, revshare). Not RevOps process (that's business-growth/revenue-operations — lead routing, SDR motion). Not strategic CRO judgment (that's c-level-advisor/cro-advisor — comp plans, when-to-hire-a-VP-Sales). Not historical close-and-report (that's finance/financial-analysis). This skill answers: direct vs partner profitability, channel profitability, channel mix, channel economics."
|
||||
version: 2.8.0
|
||||
author: claude-code-skills
|
||||
license: MIT
|
||||
tags: [commercial, channel-economics, cost-to-serve, channel-mix, channel-roi, direct-vs-partner, unit-economics]
|
||||
compatible_tools: [claude-code, codex-cli, cursor, antigravity, opencode, gemini-cli]
|
||||
---
|
||||
|
||||
# channel-economics
|
||||
|
||||
## Purpose
|
||||
|
||||
Help Head of Commercial / RevOps / VP Sales answer three questions at the quarterly channel review:
|
||||
|
||||
1. **What does each channel actually cost to serve, fully loaded?** (direct headcount, channel manager attribution, partner discount, MDF, enablement time, support load, allocated overhead)
|
||||
2. **What is the ROI of each channel under three lenses?** (cash ROI year-1, LTV-adjusted ROI, marginal ROI — next dollar of investment)
|
||||
3. **What is the optimal channel mix subject to our strategic constraints?** (minimum direct floor, maximum partner concentration ceiling, sensitivity to CAC shifts)
|
||||
|
||||
The skill emits **per-channel verdicts** (DOUBLE-DOWN / MAINTAIN / DEFUND / EXIT), a **sensitivity-tested mix recommendation**, and **the diminishing-returns inflection point**. It does not pick the strategy — humans do, with the numbers loaded honestly for the first time.
|
||||
|
||||
## When to use
|
||||
|
||||
- Quarterly channel review: pipeline is 60/40 or 50/50 direct vs partner and you don't actually know which one is profitable
|
||||
- Considering hiring a channel manager — need to know if the channel can clear the loaded-cost bar
|
||||
- Partner program ROI question from the board ("we spent $X on MDF — what did we get?")
|
||||
- A segment is over-indexed to one channel and you suspect mix dogma is blocking the other
|
||||
- About to expand into a new region and need to decide direct-first vs partner-first
|
||||
- M&A diligence: target company claims "partner-led at 70% gross margin" — need to validate after loading
|
||||
|
||||
**Do not use for:**
|
||||
- Designing partner tiers, joint GTM motion, revshare splits → `partnerships-architect`
|
||||
- SDR-to-AE routing, lead scoring, MQL definitions → `business-growth/revenue-operations`
|
||||
- Strategic CRO decisions ("should we hire a VP Sales?", comp plan design) → `c-level-advisor/cro-advisor`
|
||||
- Quarterly close, GAAP revenue recognition, channel-level P&L for historical reporting → `finance/financial-analysis`
|
||||
- Per-deal discount approval → `deal-desk`
|
||||
- Pricing model design → `pricing-strategist`
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Intake channel data
|
||||
|
||||
Fill `assets/channel_data_template.md` (≈ 20 min). Capture per channel: deal count TTM, ARR TTM, avg deal size, gross margin %, CAC, sales-cycle days, retention rate, expansion rate, partner discount %, all attributable costs (SDR / AE / SE / channel manager / CS / support / marketing / partner MDF / tooling / overhead allocation %).
|
||||
|
||||
The template surfaces the costs teams most often forget: partner enablement time, certification investment, channel-conflict resolution overhead, channel-manager headcount cost.
|
||||
|
||||
### Step 2 — Compute cost-to-serve per channel
|
||||
|
||||
Run `scripts/cost_to_serve_calculator.py --input channel.json --output markdown`.
|
||||
|
||||
Output: fully-loaded cost-to-serve **per deal** AND **per dollar of ARR**, with direct costs broken out from allocated overhead, and a "true gross margin" line after channel-specific load. Flags double-counting and surfaces hidden costs.
|
||||
|
||||
Run once per channel. The "true gross margin" line is the input the next two scripts care about.
|
||||
|
||||
### Step 3 — Compute ROI per channel under three lenses
|
||||
|
||||
Run `scripts/channel_roi_analyzer.py --input roi.json --profile saas --output markdown`.
|
||||
|
||||
Output: per channel, three ROI numbers (Cash year-1, LTV-adjusted, Marginal), the diminishing-returns inflection point, and a verdict: DOUBLE-DOWN / MAINTAIN / DEFUND / EXIT.
|
||||
|
||||
Verdict logic is deterministic and surfaced in the report. Humans can override; the skill won't.
|
||||
|
||||
### Step 4 — Optimize channel mix subject to constraints
|
||||
|
||||
Run `scripts/channel_mix_optimizer.py --input mix.json --profile saas --output markdown`.
|
||||
|
||||
Output: recommended mix that maximizes effective ARR subject to constraints (min direct %, max partner concentration), plus a sensitivity table (what if direct CAC rises 20%? what if partner discount widens 5 points?).
|
||||
|
||||
### Step 5 — Decide
|
||||
|
||||
Take the three reports into the quarterly channel review. The skill recommends; the human commits.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/cost_to_serve_calculator.py` — fully-loaded cost-to-serve per deal AND per $ ARR, with hidden-cost surfacing
|
||||
- `scripts/channel_roi_analyzer.py` — 3-lens ROI (Cash / LTV / Marginal) with verdicts and diminishing-returns inflection
|
||||
- `scripts/channel_mix_optimizer.py` — constrained mix optimizer with sensitivity scenarios
|
||||
|
||||
All scripts: stdlib only. `--help`, `--sample`, `--input`, `--output` work on all three. Industry tuning via `--profile {saas,api,enterprise-software,marketplace,hardware}` on the two analyzers.
|
||||
|
||||
## References
|
||||
|
||||
- `references/channel_economics_canon.md` — Skok, Bessemer State of the Cloud, Tunguz, Pacific Crest / KeyBanc SaaS Survey, Ramanujam, Jay McBain (Canalys)
|
||||
- `references/cost_to_serve_canon.md` — Kaplan & Cooper (ABC), Horngren, Jeremy Hope, IBM CTS case studies, McKinsey, Gartner, BCG
|
||||
- `references/channel_anti_patterns.md` — Forrester, Tunguz, Hessling, HBR, SiriusDecisions, MIT Sloan, Gartner
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Channel economics is a **forward-looking** question. Historical channel P&L is finance's job; this skill loads forward economics for a decision.
|
||||
- "Channel" means a coherent go-to-market motion (direct outbound, partner-led, marketplace, reseller, OEM). It does not mean a marketing source.
|
||||
- Cost-to-serve requires **honest overhead allocation**. The script validates that overhead % is consistent across channels — false partner-margin lift from inconsistent allocation is the #1 anti-pattern.
|
||||
- LTV inputs (retention, expansion) are per-channel, not pooled. Partner-sourced customers often retain differently than direct-sourced — this difference is usually the largest economic variable and the most ignored.
|
||||
- Industry profiles (`--profile`) tune defaults for benchmarks (e.g., SaaS direct CAC payback target ~12mo, enterprise ~18mo) — they don't override your numbers.
|
||||
- This is a decision-support skill. Output is verdicts and a recommended mix, never an automatic resource reallocation.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Treating "influenced" deals as "sourced" deals.** A partner that touched a deal your AE already had is not channel-sourced revenue. Loading this as partner revenue inflates partner ROI and inflates direct CAC simultaneously.
|
||||
- **Inconsistent overhead allocation.** Allocating 25% overhead to direct deals and 5% to partner deals because "the partner handles the overhead" is false. The partner manager, partner program, MDF, certification, and conflict-resolution all live in your P&L.
|
||||
- **Ignoring enablement time as a cost.** Every hour your AE spends co-selling with a partner is a direct cost charged to the partner channel — most teams forget to load it.
|
||||
- **MDF without ROI tracking.** Market Development Funds disbursed without an attributable pipeline ROI are just a partner-discount extension. The skill flags MDF with no return.
|
||||
- **Channel-mix dogma.** "We're a partner-first company" / "we don't sell direct" blocks profitable segments. Mix should follow the math, not the slogan.
|
||||
- **Computing channel ROI without retention differential.** If partner-sourced customers churn 5 points higher than direct, ignoring it overstates partner LTV by 30-50%. Per-channel retention is mandatory input.
|
||||
- **No cost-attribution for channel-manager headcount.** A $200k channel manager managing $4M of partner ARR is $50 of channel-manager cost per $1k ARR — material to the verdict.
|
||||
- **Confusing this skill with partnerships-architect.** That skill designs the partner program. This skill tells you whether the program pays for itself.
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **commercial/partnerships-architect** — partner tier design, joint GTM motion, revshare splits, partner enablement. Partner program *structure*, not partner program *economics*. This skill consumes the program structure as input and emits the economic verdict.
|
||||
- **business-growth/revenue-operations** — lead routing, SDR motion, MQL definition, pipeline operations. RevOps owns the funnel mechanics; this skill loads the channel-level economic outcome.
|
||||
- **c-level-advisor/cro-advisor** — strategic CRO judgment: when to hire a VP Sales, comp plan philosophy, territory design, multi-year revenue strategy. CRO advisor consumes channel-economics output as one input among many.
|
||||
- **finance/financial-analysis** — close-and-report on historical channel P&L per GAAP. This skill is forward-looking decision support; finance is historical record. Different time horizon, different audience, different output.
|
||||
- **commercial/deal-desk** — per-deal discount approval. Operates daily; this skill operates quarterly.
|
||||
- **commercial/pricing-strategist** — pricing model and tier design. Pricing is input; channel economics is what happens at that pricing across channels.
|
||||
|
||||
## Forcing-question library (Matt Pocock grill discipline)
|
||||
|
||||
Walked one at a time by `/cs:grill-commercial` or the orchestrator. Recommended answer + canon citation per question. Never bundled.
|
||||
|
||||
1. **"What's your fully-loaded cost-to-serve per channel — including channel-manager headcount, MDF, partner enablement time, and overhead allocation?"**
|
||||
Recommended: load all four. Most teams load partner discount but forget the channel-manager headcount and the enablement time, inflating partner margin by 8-15 points.
|
||||
Canon: Kaplan & Cooper (HBR 1988) — *Measure Costs Right: Make the Right Decisions*. Activity-Based Costing was invented precisely because channel costs hide in overhead and distort margin comparisons.
|
||||
|
||||
2. **"What is the retention differential between direct-sourced and partner-sourced customers?"**
|
||||
Recommended: instrument per-channel retention BEFORE running channel ROI. A 5-point retention gap moves LTV by 30-50%.
|
||||
Canon: David Skok (*For Entrepreneurs* — SaaS Metrics 2.0). LTV = (ARPA × Gross Margin) / Churn. Channel-blind churn is the most common source of false channel ROI.
|
||||
|
||||
3. **"What share of 'channel-sourced' pipeline did your team actually originate?"**
|
||||
Recommended: if your AE already had the account, it's not channel-sourced — it's channel-influenced. Influence and source are different economic lines.
|
||||
Canon: SiriusDecisions / Forrester channel attribution research — confused source vs. influence is the #1 reason partner ROI is overstated industry-wide.
|
||||
|
||||
4. **"What is the marginal ROI of the next dollar invested in partner program vs. direct sales?"**
|
||||
Recommended: compute the diminishing-returns curve on both. Average ROI hides the fact that the next dollar might earn 0.3x while the average earns 2.1x.
|
||||
Canon: Tomasz Tunguz (*Tomasz Tunguz blog* — channel CAC analyses). Average ROI is a vanity metric; marginal ROI drives investment decisions.
|
||||
|
||||
5. **"What's your MDF-to-attributable-pipeline ratio in the last 4 quarters?"**
|
||||
Recommended: < 5:1 (every $1 of MDF should generate ≥ $5 of attributable pipeline within 2 quarters). Anything looser is partner-discount theatre.
|
||||
Canon: Jay McBain (Canalys) — *State of the Channel* research. MDF without attribution discipline is the most expensive form of channel subsidy.
|
||||
|
||||
6. **"Is your channel-mix dogma blocking a profitable segment?"**
|
||||
Recommended: surface the dogma ("we're partner-first", "we don't sell direct in SMB") explicitly. Mix should follow the segment math.
|
||||
Canon: MIT Sloan Management Review — *When Channel Conflict Means Growth*. Dogmatic single-channel strategies forfeit 15-25% of TAM in mid-market specifically.
|
||||
|
||||
7. **"What overhead-allocation methodology are you applying — and is it consistent across direct and partner?"**
|
||||
Recommended: same methodology, same denominator, both channels. Inconsistent allocation is the silent killer of channel-economics analysis.
|
||||
Canon: Charles Horngren (*Cost Accounting: A Managerial Emphasis*) — allocation consistency is the precondition for cross-segment margin comparison. Without it, every conclusion is contaminated.
|
||||
|
||||
Walk depth-first. Lock 1-3 before opening 4-7. After all 7 are answered, invoke `cost_to_serve_calculator.py` → `channel_roi_analyzer.py` → `channel_mix_optimizer.py` in sequence.
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
# Channel Data Template
|
||||
|
||||
Fill this out in ~20 minutes. The three scripts in this skill all consume JSON; this template gives you the schema with annotations on **what to put** and **why**.
|
||||
|
||||
If you don't know a value, **leave it `null` (or the explicit "$0 unknown") and note it** — the scripts surface unknowns explicitly rather than silently substituting.
|
||||
|
||||
---
|
||||
|
||||
## Intake checklist (before you fill anything)
|
||||
|
||||
- [ ] Define "channel" — a coherent go-to-market motion (e.g., `direct`, `partner-led`, `marketplace`, `reseller`, `oem`). NOT a marketing source.
|
||||
- [ ] Confirm allocation methodology is the **same** across all channels (revenue-share or activity-driver, not mixed)
|
||||
- [ ] Confirm retention numbers are **per-channel**, not pooled
|
||||
- [ ] Confirm "channel-sourced" deals meet the strict definition: partner originated the opportunity AND brought it unqualified
|
||||
- [ ] Identify your industry profile: `saas | api | enterprise-software | marketplace | hardware`
|
||||
|
||||
---
|
||||
|
||||
## Template 1 — Input for `cost_to_serve_calculator.py`
|
||||
|
||||
Run **once per channel**.
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_name": "partner-led-EMEA",
|
||||
"deal_volume": 80,
|
||||
"gross_revenue": 4000000,
|
||||
"costs": {
|
||||
"sdr_attribution": 60000,
|
||||
"ae_attribution": 240000,
|
||||
"sales_engineer_attribution": 90000,
|
||||
"channel_manager_attribution": 180000,
|
||||
"customer_success_attribution": 120000,
|
||||
"support_attribution": 70000,
|
||||
"marketing_attribution": 50000,
|
||||
"partner_discount": 600000,
|
||||
"partner_MDF": 80000,
|
||||
"partner_enablement_time": 40000,
|
||||
"certification_investment": 20000,
|
||||
"channel_conflict_overhead": 15000,
|
||||
"tooling_attribution": 25000,
|
||||
"overhead_allocation_pct": 15.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Field-by-field guidance
|
||||
|
||||
| Field | What to put |
|
||||
|---|---|
|
||||
| `channel_name` | Coherent GTM motion. Examples: `direct`, `partner-led`, `marketplace`, `reseller-NA`, `oem`. Naming matters — the optimizer recognizes `direct` and `partner` substrings for constraint enforcement. |
|
||||
| `deal_volume` | Closed-won deal count, trailing-twelve-months (TTM). |
|
||||
| `gross_revenue` | ARR (or annualized contracted revenue) closed in same TTM window. |
|
||||
| `sdr_attribution` | Loaded cost of SDR time on this channel. If 30% of SDR team works on this channel, allocate 30% of total SDR loaded cost. |
|
||||
| `ae_attribution` | Same logic for AE time. |
|
||||
| `sales_engineer_attribution` | SE / solution architect time. Frequently underestimated for partner-led — includes partner technical enablement. |
|
||||
| `channel_manager_attribution` | Loaded cost of channel-manager headcount. Direct channel = $0; partner channel = full loaded cost of channel team allocated by channel. **Do not leave $0 for partner channels** — the script flags it. |
|
||||
| `customer_success_attribution` | CS team allocation. |
|
||||
| `support_attribution` | Tier-1 / tier-2 support allocation. Partner-sourced customers often escalate to vendor faster — instrument support tickets by channel. |
|
||||
| `marketing_attribution` | Demand-gen, content, events allocated to this channel. |
|
||||
| `partner_discount` | Total $ given up in partner discount/margin for the TTM. |
|
||||
| `partner_MDF` | Market Development Funds disbursed. |
|
||||
| `partner_enablement_time` | Loaded $ of YOUR team's time spent on partner enablement. Frequently $0 in practice; should not be. |
|
||||
| `certification_investment` | Partner certification programs, training events, ongoing enablement spend. |
|
||||
| `channel_conflict_overhead` | Time/cost spent resolving deal conflicts between direct and channel teams. Industry: 5-8% of channel-team time. |
|
||||
| `tooling_attribution` | CRM seats, PRM (Partner Relationship Management) tools, channel-specific tooling. |
|
||||
| `overhead_allocation_pct` | Shared overhead allocated to this channel, as % of channel revenue. **Must be consistent across channels.** |
|
||||
|
||||
---
|
||||
|
||||
## Template 2 — Input for `channel_roi_analyzer.py`
|
||||
|
||||
Run **once across all channels**.
|
||||
|
||||
```json
|
||||
{
|
||||
"profile": "saas",
|
||||
"channels": [
|
||||
{
|
||||
"channel": "direct",
|
||||
"investment_ttm": {
|
||||
"programs": 200000,
|
||||
"headcount_cost": 1600000,
|
||||
"partner_program_cost": 0,
|
||||
"mdf": 0,
|
||||
"tooling": 80000,
|
||||
"training": 60000
|
||||
},
|
||||
"returns_ttm": {
|
||||
"new_arr": 3800000,
|
||||
"expansion_arr": 900000,
|
||||
"retained_arr_attributable": 2400000
|
||||
}
|
||||
},
|
||||
{
|
||||
"channel": "partner-led",
|
||||
"investment_ttm": {
|
||||
"programs": 150000,
|
||||
"headcount_cost": 360000,
|
||||
"partner_program_cost": 280000,
|
||||
"mdf": 120000,
|
||||
"tooling": 30000,
|
||||
"training": 80000
|
||||
},
|
||||
"returns_ttm": {
|
||||
"new_arr": 1400000,
|
||||
"expansion_arr": 200000,
|
||||
"retained_arr_attributable": 900000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Field guidance
|
||||
|
||||
| Field | What to put |
|
||||
|---|---|
|
||||
| `profile` | One of `saas`, `api`, `enterprise-software`, `marketplace`, `hardware`. Tunes LTV multiplier and marginal-decay alpha. |
|
||||
| `investment_ttm.programs` | One-time program spend (events, content, campaigns). |
|
||||
| `investment_ttm.headcount_cost` | Loaded headcount cost dedicated to this channel. |
|
||||
| `investment_ttm.partner_program_cost` | Partner-program operating cost (PRM tooling, partner-portal infra, partner-only marketing). Distinct from MDF. |
|
||||
| `investment_ttm.mdf` | Market Development Funds. |
|
||||
| `investment_ttm.tooling` | Channel-specific tools. |
|
||||
| `investment_ttm.training` | Internal training + partner training cost. |
|
||||
| `returns_ttm.new_arr` | New ARR sourced by this channel, TTM. Strict definition: channel originated AND qualified. |
|
||||
| `returns_ttm.expansion_arr` | Expansion ARR from customers sourced by this channel. |
|
||||
| `returns_ttm.retained_arr_attributable` | Renewed ARR from customers sourced by this channel. |
|
||||
|
||||
---
|
||||
|
||||
## Template 3 — Input for `channel_mix_optimizer.py`
|
||||
|
||||
Run **once across all channels** with constraints.
|
||||
|
||||
```json
|
||||
{
|
||||
"profile": "saas",
|
||||
"channels": [
|
||||
{
|
||||
"name": "direct",
|
||||
"deal_count_ttm": 120,
|
||||
"arr_ttm": 6000000,
|
||||
"avg_deal_size": 50000,
|
||||
"gross_margin_pct": 75,
|
||||
"cac": 18000,
|
||||
"sales_cycle_days": 75,
|
||||
"retention_rate": 0.92,
|
||||
"expansion_rate": 1.18,
|
||||
"partner_discount_pct": 0
|
||||
},
|
||||
{
|
||||
"name": "partner-led",
|
||||
"deal_count_ttm": 80,
|
||||
"arr_ttm": 4000000,
|
||||
"avg_deal_size": 50000,
|
||||
"gross_margin_pct": 75,
|
||||
"cac": 10000,
|
||||
"sales_cycle_days": 90,
|
||||
"retention_rate": 0.86,
|
||||
"expansion_rate": 1.08,
|
||||
"partner_discount_pct": 20
|
||||
}
|
||||
],
|
||||
"constraints": {
|
||||
"min_direct_pct": 30,
|
||||
"max_partner_concentration_pct": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Field guidance
|
||||
|
||||
| Field | What to put |
|
||||
|---|---|
|
||||
| `name` | Channel name. Use `direct` / `partner` substrings for constraint enforcement to work. |
|
||||
| `gross_margin_pct` | Use the **true gross margin** from `cost_to_serve_calculator.py` output, not the headline number. |
|
||||
| `cac` | Fully loaded CAC. Includes the channel-specific costs from the cost-to-serve calculator. |
|
||||
| `retention_rate` | **Per-channel** retention rate, not pooled. Critical input. |
|
||||
| `expansion_rate` | Net expansion (1.0 = flat, 1.20 = 120% NRR). |
|
||||
| `partner_discount_pct` | The discount % given up at sale (0 for direct channels). |
|
||||
| `constraints.min_direct_pct` | Floor on direct-channel share (e.g., 30 = "at least 30% of investment must go to direct"). |
|
||||
| `constraints.max_partner_concentration_pct` | Ceiling on any single partner channel (e.g., 50 = "no single partner channel may exceed 50%"). |
|
||||
|
||||
---
|
||||
|
||||
## After filling
|
||||
|
||||
1. Save each template as a JSON file (e.g., `channel-cts-partner.json`, `channel-roi.json`, `channel-mix.json`)
|
||||
2. Run in sequence:
|
||||
```bash
|
||||
python scripts/cost_to_serve_calculator.py --input channel-cts-partner.json --output markdown > out-cts-partner.md
|
||||
python scripts/channel_roi_analyzer.py --input channel-roi.json --profile saas --output markdown > out-roi.md
|
||||
python scripts/channel_mix_optimizer.py --input channel-mix.json --profile saas --output markdown > out-mix.md
|
||||
```
|
||||
3. Bring all three reports to the quarterly channel review.
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
# Channel Anti-Patterns
|
||||
|
||||
The eight anti-patterns this skill is built to detect, with citations. Most channel-economics decisions fail because of these patterns, not because the math is wrong.
|
||||
|
||||
---
|
||||
|
||||
## 1. Channel-led deals from your own pipeline = direct cost + partner cut
|
||||
|
||||
**Pattern:** Your AE sources an account, qualifies it, runs discovery, scopes the solution — and then a partner gets attached at the contract stage for the partner cut. The deal closes, is reported as "channel-sourced", and the partner gets margin.
|
||||
|
||||
**Why it kills:** You paid full direct cost (AE time, SE time, marketing) AND gave away partner margin. The deal looks profitable as "channel-led" but is value-destroying in reality.
|
||||
|
||||
**Detection:** require **first-touch attribution** in CRM. If the first-touch is internal but the deal closes as channel-sourced, flag it.
|
||||
|
||||
Source: Forrester Research, *The Channel-Influence vs. Channel-Source Gap*, 2019. Industry data: 25-40% of "channel-sourced" deals are actually channel-influenced direct deals.
|
||||
|
||||
---
|
||||
|
||||
## 2. No overhead allocation = false partner-margin lift
|
||||
|
||||
**Pattern:** Partner channel reports 75% gross margin while direct reports 60%. Look closer: direct channel gets 25% overhead allocation; partner channel gets 5% "because the partner handles overhead." The partner does not, in fact, handle overhead — your channel manager, partner program, MDF, and certification are all in YOUR P&L.
|
||||
|
||||
**Why it kills:** Apparent partner-margin lift drives over-investment in partner program. When the executive team eventually does honest allocation, partner margin collapses 8-15 points.
|
||||
|
||||
**Detection:** validate overhead-% is **consistent** across channels. If partner overhead allocation is <50% of direct, flag for review.
|
||||
|
||||
Source: Tomasz Tunguz, *The Hidden Costs of Channel Programs*, tomtunguz.com analyses 2021-2023. See also Horngren on allocation consistency.
|
||||
|
||||
---
|
||||
|
||||
## 3. Ignoring enablement time as cost
|
||||
|
||||
**Pattern:** Your AE spends 4 hours/week on partner co-selling, your SE spends 6 hours/week on partner technical enablement, your CS team handles tier-2 support that partners offload. None of this is loaded into channel cost.
|
||||
|
||||
**Why it kills:** Partner enablement time is often 15-30% of total channel cost, completely unattributed. The channel looks far more efficient than it is.
|
||||
|
||||
**Detection:** `cost_to_serve_calculator.py` flags `partner_enablement_time` and `certification_investment` when left at $0.
|
||||
|
||||
Source: Jay McBain (Canalys), *State of the Channel* research; Joe Hessling, *Partner Program ROI Studies* (channeltivity.com). Industry data: time-tracked enablement attribution increases partner channel cost by 15-30% over naive accounting.
|
||||
|
||||
---
|
||||
|
||||
## 4. MDF without ROI tracking
|
||||
|
||||
**Pattern:** Market Development Funds disbursed to partners without an attributable pipeline ROI. Partners take the MDF, deliver an event or campaign of dubious value, and no pipeline is traceable to the spend.
|
||||
|
||||
**Why it kills:** MDF without attribution is just a partner discount in disguise — and undisciplined. Industry-median MDF-to-pipeline ratio is 3.5:1; best-in-class is >7:1. If yours is <3:1 (or untracked), you have an unbudgeted discount line.
|
||||
|
||||
**Detection:** require MDF requests to commit to attributable pipeline targets BEFORE disbursement. Reconcile quarterly.
|
||||
|
||||
Source: Jay McBain (Canalys), MDF discipline research. SiriusDecisions (now Forrester) MDF benchmarks: 60% of MDF spend has no attributable pipeline tracking at all.
|
||||
|
||||
---
|
||||
|
||||
## 5. Channel-mix dogma ("we don't sell direct") blocks profitable segments
|
||||
|
||||
**Pattern:** A founder or CRO has a strong belief — "we're a partner-first company", "we don't sell direct in SMB", "we never sell direct in EMEA" — that overrides the segment-level economics. Profitable segments get starved because the strategy slogan doesn't allow direct motion there.
|
||||
|
||||
**Why it kills:** Mix should follow the math. Industry data shows dogmatic single-channel strategies forfeit 15-25% of TAM in mid-market specifically.
|
||||
|
||||
**Detection:** force the explicit articulation of the dogma in the planning conversation. "What's the segment we DON'T sell into, and why?"
|
||||
|
||||
Source: MIT Sloan Management Review, *When Channel Conflict Means Growth*, Frazier & Lassar (1996, updated 2019). Also: HBR on channel-conflict mismanagement, Cespedes (2014).
|
||||
|
||||
---
|
||||
|
||||
## 6. Treating influenced as sourced
|
||||
|
||||
**Pattern:** Partner is involved somewhere in a deal cycle — sometimes only at signature — and the deal is reported as "channel-sourced." Influence and source get conflated.
|
||||
|
||||
**Why it kills:** Inflates partner contribution by 25-40%. Drives mis-allocation of channel investment. Channel-program ROI becomes uninterpretable.
|
||||
|
||||
**Detection:** require strict first-touch + qualified-source criteria. Channel-sourced = partner originated the opportunity AND brought it to your team unqualified.
|
||||
|
||||
Source: SiriusDecisions (now Forrester), *Channel Attribution Models*, 2018-2022 research. Single most-cited source-vs-influence taxonomy in B2B SaaS.
|
||||
|
||||
---
|
||||
|
||||
## 7. No cost-attribution for channel-manager headcount
|
||||
|
||||
**Pattern:** Channel manager salary ($150-$250k loaded) is bucketed under "G&A" or "Sales Overhead" rather than attributed to the channel they manage. The channel reports better economics because its biggest cost line is hidden.
|
||||
|
||||
**Why it kills:** A $200k channel manager managing $4M of partner ARR is $50 of channel-manager cost per $1k ARR — material to the channel verdict. Hiding it is the most common single-line distortion in channel economics.
|
||||
|
||||
**Detection:** `cost_to_serve_calculator.py` flags `channel_manager_attribution` at $0 as a hidden-cost line.
|
||||
|
||||
Source: Gartner, *Service Delivery Cost Allocation in Multi-Channel Technology Vendors*, 2022. McKinsey CTS research.
|
||||
|
||||
---
|
||||
|
||||
## 8. Channel ROI computed without retention differential
|
||||
|
||||
**Pattern:** Channel ROI calculation uses pooled retention assumption (e.g., 90% across all channels) when in fact partner-sourced customers retain at 84% and direct-sourced retain at 92%. LTV calculation is inflated for the partner channel.
|
||||
|
||||
**Why it kills:** A 5-point retention gap moves LTV by 30-50%. Most channel investment decisions are made on LTV, so the wrong retention assumption produces the wrong investment decision.
|
||||
|
||||
**Detection:** require **per-channel retention** as mandatory input. `channel_mix_optimizer.py` will not compute effective LTV without a per-channel retention number.
|
||||
|
||||
Source: David Skok (*For Entrepreneurs* — SaaS Metrics 2.0). LTV = (ARPA × Gross Margin) / Churn — channel-blind churn is the most common source of false channel ROI.
|
||||
|
||||
---
|
||||
|
||||
## Bonus anti-pattern: the "we'll figure out attribution later" trap
|
||||
|
||||
**Pattern:** Channel program launches without an attribution model. Six quarters later, no one can answer "did this work?" because the data was never structured.
|
||||
|
||||
**Why it kills:** Attribution must be designed at program-launch, not retrofit. Retroactive attribution is always contested.
|
||||
|
||||
**Detection:** force the attribution model to be in writing BEFORE the channel program is launched.
|
||||
|
||||
Source: HBR, *Why Channel Programs Fail* (Cespedes, 2014). Also: Tomasz Tunguz on channel-trap analyses.
|
||||
|
||||
---
|
||||
|
||||
## How this skill detects the anti-patterns
|
||||
|
||||
| Anti-pattern | Detection mechanism |
|
||||
|---|---|
|
||||
| 1. Channel-led from own pipeline | Forcing question #3 (influence vs. source) |
|
||||
| 2. No overhead allocation | `cost_to_serve_calculator.py` warns on inconsistent overhead-% |
|
||||
| 3. Ignoring enablement time | Hidden-cost flag on `partner_enablement_time` |
|
||||
| 4. MDF without ROI | Forcing question #5 (MDF ratio) |
|
||||
| 5. Mix dogma | Forcing question #6 |
|
||||
| 6. Influenced as sourced | Forcing question #3 |
|
||||
| 7. No channel-manager attribution | Hidden-cost flag on `channel_manager_attribution` |
|
||||
| 8. No retention differential | Forcing question #2; mandatory per-channel input |
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
# Channel Economics Canon
|
||||
|
||||
The authoritative reference set for direct-vs-partner economics, channel ROI computation, and channel-mix decision-making. Use this when validating the assumptions inside `cost_to_serve_calculator.py`, `channel_roi_analyzer.py`, and `channel_mix_optimizer.py`.
|
||||
|
||||
---
|
||||
|
||||
## 1. David Skok — *For Entrepreneurs*: SaaS Metrics 2.0
|
||||
|
||||
Skok's framework gives the LTV / CAC equation the industry treats as canonical:
|
||||
|
||||
- **LTV = (ARPA × Gross Margin %) / Churn Rate**
|
||||
- **LTV / CAC ≥ 3.0** is the floor for sustainable channel investment
|
||||
- **CAC Payback ≤ 12 months** is the SaaS target (longer for enterprise)
|
||||
|
||||
The channel-economics application: **per-channel LTV/CAC and per-channel payback, never pooled**. Pooled metrics hide the fact that one channel is funding another.
|
||||
|
||||
Source: `forentrepreneurs.com` — *SaaS Metrics 2.0 — A Guide to Measuring and Improving What Matters* (2014, updated 2018).
|
||||
|
||||
---
|
||||
|
||||
## 2. Bessemer Venture Partners — *State of the Cloud* (annual)
|
||||
|
||||
BVP's annual benchmark report is the single most-cited source for channel mix and CAC benchmarks across public + private SaaS:
|
||||
|
||||
- Public SaaS gross margins cluster 70-80%; partner-led channels typically run 5-10pts lower after load
|
||||
- Sales efficiency (Magic Number) ≥ 0.7 is the funding bar; channel inefficiency drags this below the bar fastest
|
||||
- **Partner-led** companies that scale past $100M ARR almost universally have <40% partner concentration — single-partner risk dominates above this line
|
||||
|
||||
Source: Bessemer Venture Partners, *State of the Cloud* report series, 2014-2024 editions.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tomasz Tunguz — Channel CAC analyses
|
||||
|
||||
Tunguz's blog has the most rigorous public series on channel CAC and the **diminishing-returns curve** specifically. Key findings replicated across cohorts:
|
||||
|
||||
- **Marginal CAC rises non-linearly** with investment scale. The first $1M in channel program returns ~3x; the next $1M returns ~1.5x; the next $1M often <1.0x.
|
||||
- **Average ROI is a vanity metric.** Investment decisions must be made on marginal ROI.
|
||||
- Channel programs that "work on paper" but fail in practice usually fail because the team funded them past the marginal-ROI inflection point without realizing it.
|
||||
|
||||
Source: `tomtunguz.com` — channel CAC posts including *The Channel CAC Premium*, *Diminishing Returns in SaaS Sales*.
|
||||
|
||||
---
|
||||
|
||||
## 4. Pacific Crest / KeyBanc Capital Markets — Annual SaaS Survey
|
||||
|
||||
The Pacific Crest survey (continued by KeyBanc) is the longest-running channel-economics benchmark — 350+ private SaaS companies surveyed annually since 2008. The channel-specific findings used in this skill:
|
||||
|
||||
- Median **direct CAC payback**: 14 months. Partner-led: 11 months (lower nominal but understates loaded cost).
|
||||
- Channel-led companies with <70% true (loaded) gross margin in partner channel materially underperform direct-led peers on Rule of 40
|
||||
- **Mixed-motion** companies (40-60% direct, balance partner) outperform single-motion peers on growth efficiency by ~15-20%
|
||||
|
||||
Source: KeyBanc Capital Markets, *SaaS Survey* annual report (most recent 2024).
|
||||
|
||||
---
|
||||
|
||||
## 5. Madhavan Ramanujam — *Monetizing Innovation* — channel chapter
|
||||
|
||||
Ramanujam's channel chapter introduces the "value-flow" framework:
|
||||
|
||||
- Every channel splits **economic value** between vendor, partner, and customer
|
||||
- The partner-cut must be **earned** by partner-delivered value (lead gen, technical sale, implementation, support) — not granted by program-tier convention
|
||||
- Channels where the partner-cut exceeds the value the partner delivers are **economic transfers, not channel programs**
|
||||
|
||||
Source: Madhavan Ramanujam and Georg Tacke, *Monetizing Innovation* (Wiley, 2016) — Chapter 8 on channel & pricing alignment.
|
||||
|
||||
---
|
||||
|
||||
## 6. Jay McBain (Canalys) — Channel research
|
||||
|
||||
McBain is the most-cited channel analyst working today. The Canalys research the skill draws on:
|
||||
|
||||
- **MDF discipline.** Industry median MDF-to-attributable-pipeline ratio is 3.5:1; best-in-class >7:1. Anything below 3:1 is undisciplined.
|
||||
- **Influence vs. source.** Channel-influenced ≠ channel-sourced. Industry conflation overstates partner contribution by 25-40% on average.
|
||||
- **Channel-conflict overhead** is a real and measurable cost; mature channel programs allocate 5-8% of channel-team time to conflict resolution and surface it as a P&L line.
|
||||
|
||||
Source: Canalys research notes by Jay McBain (formerly Forrester), 2020-2024 — see also McBain's LinkedIn newsletter *Channel Insights*.
|
||||
|
||||
---
|
||||
|
||||
## 7. KeyBanc + OpenView — Joint *Channel Maturity Benchmark*
|
||||
|
||||
Joint research between KeyBanc Capital Markets and OpenView Partners (2022-2024) establishing the **channel maturity** scale used in this skill's verdict logic:
|
||||
|
||||
- Stage 1 (Discovery): channel < 15% of revenue, <2x LTV/CAC — DEFUND or EXIT verdict
|
||||
- Stage 2 (Scale): channel 15-35% of revenue, 2-3x LTV/CAC — MAINTAIN verdict
|
||||
- Stage 3 (Optimization): channel 35-50% of revenue, 3-5x LTV/CAC — DOUBLE-DOWN verdict candidate
|
||||
- Stage 4 (Mature): channel >50%, but check single-partner concentration — risk verdict
|
||||
|
||||
Source: OpenView Partners + KeyBanc Capital Markets, *Channel Maturity Benchmark* 2023.
|
||||
|
||||
---
|
||||
|
||||
## How this skill uses the canon
|
||||
|
||||
- **`channel_roi_analyzer.py`** verdict thresholds derive from Skok (LTV/CAC ≥ 3.0 floor) and BVP cash-ROI target ranges
|
||||
- **`channel_mix_optimizer.py`** payback targets per profile follow KeyBanc/Pacific Crest survey medians
|
||||
- **Diminishing-returns curve** in the marginal-ROI computation traces directly to Tunguz's channel-CAC posts
|
||||
- **Influence-vs-source discipline** in the forcing-question library comes from McBain (Canalys) and SiriusDecisions
|
||||
|
||||
When the user's data contradicts these benchmarks, the data wins — these are reference anchors, not rules.
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
# Cost-to-Serve Canon
|
||||
|
||||
The authoritative reference set for fully-loaded cost-to-serve methodology. Use this when validating cost categories, allocation methodology, and the "hidden costs" `cost_to_serve_calculator.py` surfaces.
|
||||
|
||||
The core principle across every source below: **without consistent overhead allocation, every cross-channel margin comparison is contaminated**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Robert Kaplan & Robin Cooper — *Measure Costs Right: Make the Right Decisions* (HBR, 1988)
|
||||
|
||||
The foundational paper for Activity-Based Costing (ABC). Kaplan & Cooper observed that traditional cost-allocation methods systematically distort channel and product margins:
|
||||
|
||||
- **High-volume, low-complexity** channels appear unprofitable under traditional allocation (they over-absorb overhead)
|
||||
- **Low-volume, high-complexity** channels appear profitable (they under-absorb)
|
||||
- The fix: allocate overhead **by activity driver**, not by revenue share
|
||||
|
||||
For channel economics: partner-led channels typically appear higher-margin under naïve allocation precisely because they're lower-volume + higher-complexity. ABC corrects this.
|
||||
|
||||
Source: Kaplan, R.S. & Cooper, R., *Measure Costs Right: Make the Right Decisions*, Harvard Business Review, September-October 1988.
|
||||
|
||||
---
|
||||
|
||||
## 2. Charles Horngren — *Cost Accounting: A Managerial Emphasis*
|
||||
|
||||
The canonical textbook (now in 16th edition, Pearson). The chapters this skill draws on:
|
||||
|
||||
- **Chapter 14 (Cost allocation)**: the rule of *allocation consistency* — same methodology, same denominator, every comparable segment. Inconsistent allocation invalidates downstream comparison.
|
||||
- **Chapter 15 (Customer-profitability analysis)**: the channel-economics application — customer (and channel) profitability is a function of *both* revenue *and* fully-loaded cost-to-serve, never just gross margin.
|
||||
|
||||
The most common channel-economics error this textbook anchors: **allocating overhead at 25% to direct and 5% to partner** "because the partner handles the overhead." The partner does not, in fact, handle the channel manager, the partner program, the certification, the MDF, the conflict resolution — all of which sit in YOUR P&L.
|
||||
|
||||
Source: Horngren, Datar & Rajan, *Cost Accounting: A Managerial Emphasis*, 16th ed., Pearson.
|
||||
|
||||
---
|
||||
|
||||
## 3. Jeremy Hope — *Beyond Budgeting* + channel-allocation writings
|
||||
|
||||
Hope's *Beyond Budgeting* movement contributed the framework for **rolling channel-cost allocation** rather than annual fixed allocation. Key principle:
|
||||
|
||||
- **Channel cost allocation must update at the same cadence as channel investment decisions** (quarterly minimum)
|
||||
- Annual fixed allocations lock in last year's channel mix and prevent learning
|
||||
- Use rolling 4-quarter cost-to-serve for forward decisions
|
||||
|
||||
Source: Hope, J. & Fraser, R., *Beyond Budgeting* (Harvard Business School Press, 2003); BBRT (Beyond Budgeting Round Table) channel-allocation guidance papers.
|
||||
|
||||
---
|
||||
|
||||
## 4. IBM Cost-to-Serve transformation case studies
|
||||
|
||||
IBM Institute for Business Value has published a sequence of cost-to-serve transformation case studies (2010-2022). Findings replicated across cases:
|
||||
|
||||
- **5-15% of "gross margin"** at large enterprises evaporates when partner-channel overhead is loaded honestly
|
||||
- The single largest unattributed cost is **technical-sale resource time** (sales engineering / solution architects co-selling with partners)
|
||||
- Companies that move from naive to ABC-style channel allocation typically **defund 1-2 channels** within 6 months — and grow the remaining channels faster
|
||||
|
||||
Source: IBM Institute for Business Value, *Cost-to-Serve Transformation* case study series.
|
||||
|
||||
---
|
||||
|
||||
## 5. McKinsey & Company — Cost-to-Serve research
|
||||
|
||||
McKinsey's go-to-market practice publishes regular CTS research. The findings this skill leans on:
|
||||
|
||||
- **Customer-level CTS variance** within a single channel is often 5-10x — meaning a channel-average CTS hides material per-customer variance
|
||||
- The hidden-cost line items most teams omit, in order of impact: technical-sale time, channel-manager attribution, partner enablement time, certification investment, conflict-resolution overhead
|
||||
- McKinsey's recommended cadence: refresh CTS quarterly minimum, annually at the customer level, continuously for top-decile accounts
|
||||
|
||||
Source: McKinsey & Company, *Cost-to-Serve: Reducing complexity and increasing profitability* (operations practice white papers).
|
||||
|
||||
---
|
||||
|
||||
## 6. Gartner — Service Delivery Cost research
|
||||
|
||||
Gartner's research on service-delivery cost allocation, particularly for technology vendors with mixed direct + partner motion:
|
||||
|
||||
- The **service-delivery overhead** (customer success, support, professional services) often differs by 30-50% between direct-sourced and partner-sourced customers
|
||||
- Reasons: partner-sourced customers often arrive less qualified, requiring more onboarding; partner-sourced customers expand less, reducing CS leverage; partner-sourced customers escalate to vendor support faster because the partner offloads tier-2 support back
|
||||
- Gartner's recommendation: instrument support-ticket-volume-per-customer **by sourcing channel**, not by customer size
|
||||
|
||||
Source: Gartner, *Service Delivery Cost Allocation in Multi-Channel Technology Vendors* research notes, 2021-2024.
|
||||
|
||||
---
|
||||
|
||||
## 7. Boston Consulting Group — Channel allocation methodology
|
||||
|
||||
BCG's channel-allocation methodology (from their TMT and software practices) introduces the **dual-axis** cost framework this skill implements:
|
||||
|
||||
- **Direct costs**: incurred specifically because of this channel (channel manager headcount, MDF, partner discount, certification spend)
|
||||
- **Allocated overhead**: shared costs apportioned by activity driver (revenue share, deal count, or time-tracked attribution)
|
||||
- The two must always be reported separately so executives can see the lever they control directly
|
||||
|
||||
This is the framework `cost_to_serve_calculator.py` enforces by breaking out direct cost lines from allocated overhead — and validating overhead-% consistency across channels.
|
||||
|
||||
Source: BCG, *Channel Economics in Software & Subscription Businesses* practitioner publications.
|
||||
|
||||
---
|
||||
|
||||
## How this skill uses the canon
|
||||
|
||||
- **Direct-cost line items** in `cost_to_serve_calculator.py` follow BCG's dual-axis framework
|
||||
- **Hidden-cost surfacing** (the `HIDDEN_COST_KEYS` list flagged when $0) follows McKinsey's most-forgotten-cost ranking
|
||||
- **Allocation consistency validation** (warns when partner channel has <5% overhead while direct has >20%) implements Horngren's allocation-consistency rule
|
||||
- **Per-channel retention differential** (used in `channel_roi_analyzer.py`) follows Gartner's service-delivery findings — channel-blind retention is the most common source of wrong channel ROI
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
#!/usr/bin/env python3
|
||||
"""channel_mix_optimizer.py
|
||||
|
||||
Computes per-channel effective LTV, payback period, and efficiency ratio
|
||||
(LTV/CAC), then recommends a channel mix that maximizes effective ARR
|
||||
subject to constraints (min direct %, max partner concentration %).
|
||||
|
||||
Includes a sensitivity table: what happens if direct CAC rises 20%, partner
|
||||
discount widens 5 points, or retention drops 3 points?
|
||||
|
||||
Stdlib-only. Deterministic. No external solver — uses a discrete grid search
|
||||
over feasible mixes, which is sufficient for 2-6 channel problems.
|
||||
|
||||
Usage:
|
||||
python channel_mix_optimizer.py --sample
|
||||
python channel_mix_optimizer.py --input mix.json --profile saas --output markdown
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
# Industry profiles tune assumed gross-margin-to-monthly conversion and
|
||||
# benchmark payback targets (months).
|
||||
PROFILES = {
|
||||
"saas": {"payback_target_months": 12, "ltv_cac_floor": 3.0},
|
||||
"api": {"payback_target_months": 9, "ltv_cac_floor": 4.0},
|
||||
"enterprise-software": {"payback_target_months": 18, "ltv_cac_floor": 3.0},
|
||||
"marketplace": {"payback_target_months": 6, "ltv_cac_floor": 2.5},
|
||||
"hardware": {"payback_target_months": 24, "ltv_cac_floor": 2.0},
|
||||
}
|
||||
|
||||
|
||||
def _num(v: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def compute_channel_metrics(ch: dict, profile_cfg: dict) -> dict:
|
||||
name = ch.get("name", "unnamed")
|
||||
deal_count = _num(ch.get("deal_count_ttm"))
|
||||
arr_ttm = _num(ch.get("arr_ttm"))
|
||||
avg_deal = _num(ch.get("avg_deal_size"))
|
||||
gm_pct = _num(ch.get("gross_margin_pct"), 70.0)
|
||||
cac = _num(ch.get("cac"))
|
||||
cycle_days = _num(ch.get("sales_cycle_days"), 60)
|
||||
retention = _num(ch.get("retention_rate"), 0.85)
|
||||
expansion = _num(ch.get("expansion_rate"), 1.05)
|
||||
partner_discount = _num(ch.get("partner_discount_pct"), 0)
|
||||
|
||||
if avg_deal <= 0 or cac <= 0:
|
||||
return {"name": name, "error": "avg_deal_size and cac must both be > 0"}
|
||||
|
||||
# Effective margin after partner discount
|
||||
effective_margin_pct = gm_pct * (1.0 - partner_discount / 100.0)
|
||||
|
||||
# Effective LTV — geometric-series approximation:
|
||||
# LTV = avg_deal * (effective_margin/100) * expansion / (1 - retention)
|
||||
# If retention >= 1.0, cap denominator at 0.05 to avoid blowup (means
|
||||
# "indefinite retention" — we don't reward unrealistically).
|
||||
denom = max(1.0 - retention, 0.05)
|
||||
effective_ltv = avg_deal * (effective_margin_pct / 100.0) * expansion / denom
|
||||
|
||||
# Payback period: months to recoup CAC at monthly gross margin
|
||||
monthly_gross_margin = (avg_deal / 12.0) * (effective_margin_pct / 100.0)
|
||||
payback_months = cac / monthly_gross_margin if monthly_gross_margin > 0 else float("inf")
|
||||
|
||||
# Efficiency ratio
|
||||
ltv_cac = effective_ltv / cac if cac > 0 else 0.0
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"deal_count_ttm": deal_count,
|
||||
"arr_ttm": arr_ttm,
|
||||
"avg_deal_size": avg_deal,
|
||||
"gross_margin_pct": gm_pct,
|
||||
"effective_margin_pct": round(effective_margin_pct, 2),
|
||||
"cac": cac,
|
||||
"sales_cycle_days": cycle_days,
|
||||
"retention_rate": retention,
|
||||
"expansion_rate": expansion,
|
||||
"partner_discount_pct": partner_discount,
|
||||
"effective_ltv": round(effective_ltv, 2),
|
||||
"payback_months": round(payback_months, 2),
|
||||
"ltv_cac": round(ltv_cac, 2),
|
||||
"meets_payback_target": payback_months <= profile_cfg["payback_target_months"],
|
||||
"meets_ltv_cac_floor": ltv_cac >= profile_cfg["ltv_cac_floor"],
|
||||
}
|
||||
|
||||
|
||||
def _is_partner_channel(name: str) -> bool:
|
||||
n = name.lower()
|
||||
return any(tag in n for tag in ("partner", "reseller", "channel", "oem", "marketplace"))
|
||||
|
||||
|
||||
def _is_direct_channel(name: str) -> bool:
|
||||
return "direct" in name.lower() or "inside" in name.lower() or "outbound" in name.lower()
|
||||
|
||||
|
||||
def optimize_mix(metrics: list, constraints: dict) -> dict:
|
||||
"""Discrete grid search over channel-mix percentages (5% increments)."""
|
||||
n = len(metrics)
|
||||
if n == 0:
|
||||
return {"error": "no channels provided"}
|
||||
|
||||
min_direct = _num(constraints.get("min_direct_pct"), 0)
|
||||
max_partner_conc = _num(constraints.get("max_partner_concentration_pct"), 100)
|
||||
|
||||
# Score = effective_ltv / cac (use LTV/CAC as the per-$-CAC efficiency).
|
||||
# We allocate a normalized 100 "investment units" across channels and maximize
|
||||
# sum(units_i * ltv_cac_i) subject to constraints.
|
||||
best_score = -1.0
|
||||
best_mix = None
|
||||
|
||||
step = 5
|
||||
# generate compositions of 100 over n channels in 5% steps
|
||||
def gen(remaining: int, slots: int):
|
||||
if slots == 1:
|
||||
yield (remaining,)
|
||||
return
|
||||
for v in range(0, remaining + 1, step):
|
||||
for tail in gen(remaining - v, slots - 1):
|
||||
yield (v,) + tail
|
||||
|
||||
for mix in gen(100, n):
|
||||
# constraint checks
|
||||
direct_share = sum(mix[i] for i, m in enumerate(metrics) if _is_direct_channel(m["name"]))
|
||||
partner_share_max = max(
|
||||
(mix[i] for i, m in enumerate(metrics) if _is_partner_channel(m["name"])),
|
||||
default=0,
|
||||
)
|
||||
if direct_share < min_direct:
|
||||
continue
|
||||
if partner_share_max > max_partner_conc:
|
||||
continue
|
||||
score = sum(mix[i] * metrics[i].get("ltv_cac", 0) for i in range(n))
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_mix = mix
|
||||
|
||||
if best_mix is None:
|
||||
return {"error": "no feasible mix under given constraints"}
|
||||
return {
|
||||
"best_mix_pct": {metrics[i]["name"]: best_mix[i] for i in range(n)},
|
||||
"score": round(best_score, 2),
|
||||
}
|
||||
|
||||
|
||||
def sensitivity_scenarios(channels: list, profile_cfg: dict, constraints: dict) -> list:
|
||||
"""Re-run optimization under perturbed inputs."""
|
||||
scenarios = []
|
||||
|
||||
def perturb(perturbation_fn, label: str):
|
||||
perturbed = []
|
||||
for c in channels:
|
||||
cc = dict(c)
|
||||
perturbation_fn(cc)
|
||||
perturbed.append(cc)
|
||||
ms = [compute_channel_metrics(c, profile_cfg) for c in perturbed]
|
||||
ms = [m for m in ms if "error" not in m]
|
||||
opt = optimize_mix(ms, constraints)
|
||||
scenarios.append({"scenario": label, "mix": opt.get("best_mix_pct"), "note": opt.get("error")})
|
||||
|
||||
def bump_direct_cac(c):
|
||||
if _is_direct_channel(c.get("name", "")):
|
||||
c["cac"] = _num(c.get("cac")) * 1.20
|
||||
|
||||
def widen_partner_discount(c):
|
||||
if _is_partner_channel(c.get("name", "")):
|
||||
c["partner_discount_pct"] = _num(c.get("partner_discount_pct")) + 5
|
||||
|
||||
def drop_retention(c):
|
||||
c["retention_rate"] = max(0.0, _num(c.get("retention_rate"), 0.85) - 0.03)
|
||||
|
||||
perturb(bump_direct_cac, "Direct CAC +20%")
|
||||
perturb(widen_partner_discount, "Partner discount +5pts")
|
||||
perturb(drop_retention, "All retention -3pts")
|
||||
return scenarios
|
||||
|
||||
|
||||
def render_markdown(report: dict, profile: str) -> str:
|
||||
lines = [
|
||||
f"# Channel Mix Optimization — profile: `{profile}`",
|
||||
"",
|
||||
"## Per-channel economics",
|
||||
"| Channel | Avg deal | Eff margin | CAC | Payback (mo) | LTV | LTV/CAC | Meets bar? |",
|
||||
"|---|---:|---:|---:|---:|---:|---:|---|",
|
||||
]
|
||||
for m in report["metrics"]:
|
||||
if "error" in m:
|
||||
lines.append(f"| {m['name']} | — | — | — | — | — | — | ERROR: {m['error']} |")
|
||||
continue
|
||||
bar = (
|
||||
"PASS"
|
||||
if m["meets_payback_target"] and m["meets_ltv_cac_floor"]
|
||||
else ("PARTIAL" if m["meets_payback_target"] or m["meets_ltv_cac_floor"] else "FAIL")
|
||||
)
|
||||
lines.append(
|
||||
f"| {m['name']} | ${m['avg_deal_size']:,.0f} | {m['effective_margin_pct']:.1f}% | "
|
||||
f"${m['cac']:,.0f} | {m['payback_months']:.1f} | ${m['effective_ltv']:,.0f} | "
|
||||
f"{m['ltv_cac']:.2f}x | {bar} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if "best_mix" in report and report["best_mix"].get("best_mix_pct"):
|
||||
lines += ["## Recommended mix (subject to constraints)", "| Channel | Recommended share |", "|---|---:|"]
|
||||
for k, v in report["best_mix"]["best_mix_pct"].items():
|
||||
lines.append(f"| {k} | {v}% |")
|
||||
lines.append("")
|
||||
elif "best_mix" in report and report["best_mix"].get("error"):
|
||||
lines += [f"## Mix optimization", f"**{report['best_mix']['error']}**", ""]
|
||||
|
||||
if report.get("sensitivity"):
|
||||
lines += ["## Sensitivity scenarios", "| Scenario | Recommended mix |", "|---|---|"]
|
||||
for s in report["sensitivity"]:
|
||||
if s.get("mix"):
|
||||
mix_str = ", ".join(f"{k}: {v}%" for k, v in s["mix"].items())
|
||||
lines.append(f"| {s['scenario']} | {mix_str} |")
|
||||
else:
|
||||
lines.append(f"| {s['scenario']} | {s.get('note') or 'no feasible mix'} |")
|
||||
lines.append("")
|
||||
|
||||
lines += [
|
||||
"## Notes",
|
||||
f"- Profile `{profile}` payback target: "
|
||||
f"{PROFILES[profile]['payback_target_months']} months; LTV/CAC floor: "
|
||||
f"{PROFILES[profile]['ltv_cac_floor']:.1f}x.",
|
||||
"- Optimizer maximizes effective-ARR-weighted LTV/CAC across channels, in 5% steps.",
|
||||
"- Constraint floors / ceilings are HARD constraints — infeasible mixes are reported as errors.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
SAMPLE = {
|
||||
"profile": "saas",
|
||||
"channels": [
|
||||
{
|
||||
"name": "direct",
|
||||
"deal_count_ttm": 120,
|
||||
"arr_ttm": 6_000_000,
|
||||
"avg_deal_size": 50_000,
|
||||
"gross_margin_pct": 75,
|
||||
"cac": 18_000,
|
||||
"sales_cycle_days": 75,
|
||||
"retention_rate": 0.92,
|
||||
"expansion_rate": 1.18,
|
||||
"partner_discount_pct": 0,
|
||||
},
|
||||
{
|
||||
"name": "partner-led",
|
||||
"deal_count_ttm": 80,
|
||||
"arr_ttm": 4_000_000,
|
||||
"avg_deal_size": 50_000,
|
||||
"gross_margin_pct": 75,
|
||||
"cac": 10_000,
|
||||
"sales_cycle_days": 90,
|
||||
"retention_rate": 0.86,
|
||||
"expansion_rate": 1.08,
|
||||
"partner_discount_pct": 20,
|
||||
},
|
||||
{
|
||||
"name": "marketplace",
|
||||
"deal_count_ttm": 200,
|
||||
"arr_ttm": 1_000_000,
|
||||
"avg_deal_size": 5_000,
|
||||
"gross_margin_pct": 70,
|
||||
"cac": 1_500,
|
||||
"sales_cycle_days": 14,
|
||||
"retention_rate": 0.78,
|
||||
"expansion_rate": 1.02,
|
||||
"partner_discount_pct": 15,
|
||||
},
|
||||
],
|
||||
"constraints": {"min_direct_pct": 30, "max_partner_concentration_pct": 50},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--input")
|
||||
ap.add_argument("--output", choices=["json", "markdown"], default="markdown")
|
||||
ap.add_argument(
|
||||
"--profile",
|
||||
choices=list(PROFILES.keys()),
|
||||
default="saas",
|
||||
)
|
||||
ap.add_argument("--sample", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.sample:
|
||||
payload = SAMPLE
|
||||
elif args.input:
|
||||
with open(args.input) as f:
|
||||
payload = json.load(f)
|
||||
else:
|
||||
ap.print_help()
|
||||
return 0
|
||||
|
||||
profile = payload.get("profile", args.profile)
|
||||
if profile not in PROFILES:
|
||||
print(f"Unknown profile: {profile}", file=sys.stderr)
|
||||
return 2
|
||||
profile_cfg = PROFILES[profile]
|
||||
|
||||
channels = payload.get("channels", [])
|
||||
constraints = payload.get("constraints", {}) or {}
|
||||
|
||||
metrics = [compute_channel_metrics(c, profile_cfg) for c in channels]
|
||||
valid_metrics = [m for m in metrics if "error" not in m]
|
||||
best = optimize_mix(valid_metrics, constraints)
|
||||
sens = sensitivity_scenarios(channels, profile_cfg, constraints) if channels else []
|
||||
|
||||
report = {"profile": profile, "metrics": metrics, "best_mix": best, "sensitivity": sens}
|
||||
|
||||
if args.output == "json":
|
||||
print(json.dumps(report, indent=2))
|
||||
else:
|
||||
print(render_markdown(report, profile))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
#!/usr/bin/env python3
|
||||
"""channel_roi_analyzer.py
|
||||
|
||||
Computes per-channel ROI under three lenses:
|
||||
- Cash ROI (year-1 returns / cash invested)
|
||||
- LTV ROI (returns * LTV multiplier / investment)
|
||||
- Marginal ROI (next dollar of investment, diminishing-returns curve)
|
||||
|
||||
Emits a verdict per channel: DOUBLE-DOWN / MAINTAIN / DEFUND / EXIT, plus
|
||||
the diminishing-returns inflection point.
|
||||
|
||||
Stdlib-only. Deterministic.
|
||||
|
||||
Usage:
|
||||
python channel_roi_analyzer.py --sample
|
||||
python channel_roi_analyzer.py --input roi.json --profile saas --output markdown
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
# ---- Industry profiles: LTV multiplier benchmark, marginal-decay shape ----
|
||||
# LTV multiplier = expected LTV / year-1 ARR (post-retention + expansion). Profile
|
||||
# values are conservative midpoints from public benchmarks.
|
||||
# marginal_decay_alpha = exponent k in marginal_roi = avg_roi * exp(-k * scale_idx)
|
||||
# higher k = faster diminishing returns.
|
||||
PROFILES = {
|
||||
"saas": {"ltv_multiplier": 3.5, "marginal_decay_alpha": 0.35, "cash_roi_target": 1.0},
|
||||
"api": {"ltv_multiplier": 4.5, "marginal_decay_alpha": 0.30, "cash_roi_target": 0.8},
|
||||
"enterprise-software": {
|
||||
"ltv_multiplier": 5.0,
|
||||
"marginal_decay_alpha": 0.25,
|
||||
"cash_roi_target": 0.6,
|
||||
},
|
||||
"marketplace": {
|
||||
"ltv_multiplier": 2.5,
|
||||
"marginal_decay_alpha": 0.45,
|
||||
"cash_roi_target": 1.2,
|
||||
},
|
||||
"hardware": {
|
||||
"ltv_multiplier": 1.8,
|
||||
"marginal_decay_alpha": 0.50,
|
||||
"cash_roi_target": 1.5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _num(v: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def compute_channel_roi(channel: dict, profile_cfg: dict) -> dict:
|
||||
name = channel.get("channel", "unnamed")
|
||||
inv = channel.get("investment_ttm", {}) or {}
|
||||
ret = channel.get("returns_ttm", {}) or {}
|
||||
|
||||
invested = sum(
|
||||
_num(inv.get(k))
|
||||
for k in ("programs", "headcount_cost", "partner_program_cost", "mdf", "tooling", "training")
|
||||
)
|
||||
new_arr = _num(ret.get("new_arr"))
|
||||
exp_arr = _num(ret.get("expansion_arr"))
|
||||
retained_arr = _num(ret.get("retained_arr_attributable"))
|
||||
returns_y1 = new_arr + exp_arr + retained_arr
|
||||
|
||||
if invested <= 0:
|
||||
return {"channel": name, "error": "investment_ttm sum must be > 0"}
|
||||
|
||||
# Cash ROI (year-1)
|
||||
cash_roi = returns_y1 / invested
|
||||
|
||||
# LTV ROI — apply profile multiplier to recurring portion (new + expansion). Retained
|
||||
# is already recurring so we don't double-count.
|
||||
ltv_returns = (new_arr + exp_arr) * profile_cfg["ltv_multiplier"] + retained_arr
|
||||
ltv_roi = ltv_returns / invested
|
||||
|
||||
# Marginal ROI — diminishing returns. Model: marginal = avg * exp(-alpha * scale_idx)
|
||||
# where scale_idx is log10(invested / 100k) clamped >= 0. Inflection = scale at which
|
||||
# marginal_roi drops to 1.0 (a dollar in returns a dollar — no profit).
|
||||
alpha = profile_cfg["marginal_decay_alpha"]
|
||||
scale_idx = max(0.0, math.log10(max(invested, 1.0) / 100_000.0))
|
||||
marginal_roi = cash_roi * math.exp(-alpha * scale_idx)
|
||||
|
||||
# Inflection: solve cash_roi * exp(-alpha * x) = 1.0 -> x = ln(cash_roi)/alpha
|
||||
if cash_roi > 1.0:
|
||||
inflection_scale = math.log(cash_roi) / alpha
|
||||
inflection_invested = 100_000.0 * (10 ** inflection_scale)
|
||||
else:
|
||||
inflection_invested = invested # already past the inflection
|
||||
|
||||
# Verdict logic — deterministic
|
||||
target = profile_cfg["cash_roi_target"]
|
||||
if cash_roi >= target * 1.5 and ltv_roi >= 3.0 and marginal_roi >= 1.0:
|
||||
verdict = "DOUBLE-DOWN"
|
||||
rationale = (
|
||||
"Cash ROI > 1.5x target, LTV ROI ≥ 3.0x, marginal ROI > 1.0 — "
|
||||
"next dollar still earns positive return. Invest more."
|
||||
)
|
||||
elif cash_roi >= target and ltv_roi >= 2.0:
|
||||
verdict = "MAINTAIN"
|
||||
rationale = (
|
||||
"Cash ROI meets target and LTV ROI ≥ 2.0x. Hold current investment; "
|
||||
"monitor marginal ROI before increasing."
|
||||
)
|
||||
elif cash_roi >= target * 0.5 or ltv_roi >= 1.5:
|
||||
verdict = "DEFUND"
|
||||
rationale = (
|
||||
"Sub-target cash ROI. LTV ROI may be supportive but not enough to justify "
|
||||
"current spend. Cut investment 30-50% and reassess in 2 quarters."
|
||||
)
|
||||
else:
|
||||
verdict = "EXIT"
|
||||
rationale = (
|
||||
"Both cash ROI and LTV ROI below floor. Channel is value-destroying at "
|
||||
"current load. Exit or restructure the program."
|
||||
)
|
||||
|
||||
return {
|
||||
"channel": name,
|
||||
"invested_ttm": round(invested, 2),
|
||||
"returns_y1": round(returns_y1, 2),
|
||||
"cash_roi": round(cash_roi, 3),
|
||||
"ltv_roi": round(ltv_roi, 3),
|
||||
"marginal_roi": round(marginal_roi, 3),
|
||||
"inflection_invested": round(inflection_invested, 2),
|
||||
"verdict": verdict,
|
||||
"rationale": rationale,
|
||||
"profile_target_cash_roi": target,
|
||||
}
|
||||
|
||||
|
||||
def render_markdown(results: list, profile: str) -> str:
|
||||
lines = [
|
||||
f"# Channel ROI Analysis — profile: `{profile}`",
|
||||
"",
|
||||
"## Per-channel verdicts",
|
||||
"| Channel | Invested | Returns Y1 | Cash ROI | LTV ROI | Marginal ROI | Inflection | Verdict |",
|
||||
"|---|---:|---:|---:|---:|---:|---:|---|",
|
||||
]
|
||||
for r in results:
|
||||
if "error" in r:
|
||||
lines.append(f"| {r['channel']} | — | — | — | — | — | — | ERROR: {r['error']} |")
|
||||
continue
|
||||
lines.append(
|
||||
f"| {r['channel']} | ${r['invested_ttm']:,.0f} | ${r['returns_y1']:,.0f} | "
|
||||
f"{r['cash_roi']:.2f}x | {r['ltv_roi']:.2f}x | {r['marginal_roi']:.2f}x | "
|
||||
f"${r['inflection_invested']:,.0f} | **{r['verdict']}** |"
|
||||
)
|
||||
lines += ["", "## Verdict rationale"]
|
||||
for r in results:
|
||||
if "error" in r:
|
||||
continue
|
||||
lines += [f"### {r['channel']} — {r['verdict']}", r["rationale"], ""]
|
||||
|
||||
lines += [
|
||||
"## Definitions",
|
||||
"- **Cash ROI** = year-1 returns / cash invested. Profile target shown above.",
|
||||
"- **LTV ROI** = (new+expansion ARR × LTV multiplier + retained ARR) / invested.",
|
||||
"- **Marginal ROI** = ROI on the next dollar of investment, modeled via "
|
||||
"`avg_roi × exp(-alpha × log10(invested / $100k))`. Profile-tuned alpha.",
|
||||
"- **Inflection** = invested-$ level at which marginal ROI hits 1.0 (break-even on "
|
||||
"the next dollar). Beyond this point, additional spend destroys value.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
SAMPLE = {
|
||||
"profile": "saas",
|
||||
"channels": [
|
||||
{
|
||||
"channel": "direct",
|
||||
"investment_ttm": {
|
||||
"programs": 200_000,
|
||||
"headcount_cost": 1_600_000,
|
||||
"partner_program_cost": 0,
|
||||
"mdf": 0,
|
||||
"tooling": 80_000,
|
||||
"training": 60_000,
|
||||
},
|
||||
"returns_ttm": {
|
||||
"new_arr": 3_800_000,
|
||||
"expansion_arr": 900_000,
|
||||
"retained_arr_attributable": 2_400_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
"channel": "partner-led",
|
||||
"investment_ttm": {
|
||||
"programs": 150_000,
|
||||
"headcount_cost": 360_000,
|
||||
"partner_program_cost": 280_000,
|
||||
"mdf": 120_000,
|
||||
"tooling": 30_000,
|
||||
"training": 80_000,
|
||||
},
|
||||
"returns_ttm": {
|
||||
"new_arr": 1_400_000,
|
||||
"expansion_arr": 200_000,
|
||||
"retained_arr_attributable": 900_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
"channel": "marketplace",
|
||||
"investment_ttm": {
|
||||
"programs": 60_000,
|
||||
"headcount_cost": 120_000,
|
||||
"partner_program_cost": 0,
|
||||
"mdf": 0,
|
||||
"tooling": 40_000,
|
||||
"training": 0,
|
||||
},
|
||||
"returns_ttm": {
|
||||
"new_arr": 200_000,
|
||||
"expansion_arr": 40_000,
|
||||
"retained_arr_attributable": 80_000,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--input", help="Path to JSON input file")
|
||||
ap.add_argument("--output", choices=["json", "markdown"], default="markdown")
|
||||
ap.add_argument(
|
||||
"--profile",
|
||||
choices=list(PROFILES.keys()),
|
||||
default="saas",
|
||||
help="Industry profile (tunes LTV multiplier + marginal-decay alpha)",
|
||||
)
|
||||
ap.add_argument("--sample", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.sample:
|
||||
payload = SAMPLE
|
||||
elif args.input:
|
||||
with open(args.input) as f:
|
||||
payload = json.load(f)
|
||||
else:
|
||||
ap.print_help()
|
||||
return 0
|
||||
|
||||
profile = payload.get("profile", args.profile)
|
||||
if profile not in PROFILES:
|
||||
print(f"Unknown profile: {profile}", file=sys.stderr)
|
||||
return 2
|
||||
profile_cfg = PROFILES[profile]
|
||||
|
||||
channels = payload.get("channels", [])
|
||||
if not channels and "channel" in payload:
|
||||
channels = [payload]
|
||||
|
||||
results = [compute_channel_roi(c, profile_cfg) for c in channels]
|
||||
if args.output == "json":
|
||||
print(json.dumps({"profile": profile, "results": results}, indent=2))
|
||||
else:
|
||||
print(render_markdown(results, profile))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
#!/usr/bin/env python3
|
||||
"""cost_to_serve_calculator.py
|
||||
|
||||
Computes fully-loaded cost-to-serve per deal AND per dollar of ARR for a
|
||||
single channel. Breaks out direct vs. allocated overhead. Surfaces "hidden"
|
||||
costs the average team forgets (partner enablement time, certification
|
||||
investment, channel-conflict overhead) by flagging line items left at $0.
|
||||
|
||||
Stdlib-only. Deterministic.
|
||||
|
||||
Usage:
|
||||
python cost_to_serve_calculator.py --sample
|
||||
python cost_to_serve_calculator.py --input channel.json --output markdown
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
# ---- Hidden-cost line items (most-forgotten) -----------------------------
|
||||
HIDDEN_COST_KEYS = {
|
||||
"partner_enablement_time": "Partner enablement time (AE/SE hours co-selling)",
|
||||
"certification_investment": "Partner certification + training investment",
|
||||
"channel_conflict_overhead": "Channel-conflict resolution overhead",
|
||||
"channel_manager_attribution": "Channel manager headcount attribution",
|
||||
}
|
||||
|
||||
# ---- Cost categories -----------------------------------------------------
|
||||
DIRECT_COST_KEYS = [
|
||||
"sdr_attribution",
|
||||
"ae_attribution",
|
||||
"sales_engineer_attribution",
|
||||
"channel_manager_attribution",
|
||||
"customer_success_attribution",
|
||||
"support_attribution",
|
||||
"marketing_attribution",
|
||||
"partner_discount",
|
||||
"partner_MDF",
|
||||
"partner_enablement_time",
|
||||
"certification_investment",
|
||||
"channel_conflict_overhead",
|
||||
"tooling_attribution",
|
||||
]
|
||||
|
||||
|
||||
def _num(v: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def compute_cost_to_serve(payload: dict) -> dict:
|
||||
channel_name = payload.get("channel_name", "unnamed-channel")
|
||||
deal_volume = _num(payload.get("deal_volume"), 0)
|
||||
gross_revenue = _num(payload.get("gross_revenue"), 0)
|
||||
costs = payload.get("costs", {}) or {}
|
||||
|
||||
if deal_volume <= 0 or gross_revenue <= 0:
|
||||
return {
|
||||
"error": "deal_volume and gross_revenue must both be > 0",
|
||||
"channel_name": channel_name,
|
||||
}
|
||||
|
||||
# Direct costs (sum)
|
||||
direct_total = 0.0
|
||||
direct_breakdown = {}
|
||||
for key in DIRECT_COST_KEYS:
|
||||
v = _num(costs.get(key), 0)
|
||||
direct_breakdown[key] = v
|
||||
direct_total += v
|
||||
|
||||
# Allocated overhead — applied as % of gross revenue
|
||||
overhead_pct = _num(costs.get("overhead_allocation_pct"), 0)
|
||||
if overhead_pct < 0 or overhead_pct > 100:
|
||||
return {
|
||||
"error": f"overhead_allocation_pct must be 0..100, got {overhead_pct}",
|
||||
"channel_name": channel_name,
|
||||
}
|
||||
overhead_total = gross_revenue * (overhead_pct / 100.0)
|
||||
|
||||
total_loaded_cost = direct_total + overhead_total
|
||||
|
||||
cost_per_deal = total_loaded_cost / deal_volume
|
||||
cost_per_arr_dollar = total_loaded_cost / gross_revenue
|
||||
true_gross_margin_pct = (1.0 - cost_per_arr_dollar) * 100.0
|
||||
|
||||
# Hidden-cost surfacing — flag any HIDDEN_COST_KEYS that are $0
|
||||
hidden_flags = []
|
||||
for k, label in HIDDEN_COST_KEYS.items():
|
||||
if direct_breakdown.get(k, 0) == 0:
|
||||
hidden_flags.append(
|
||||
f"'{k}' is $0 — likely understated. {label} is the most-forgotten "
|
||||
"channel cost in industry benchmarks."
|
||||
)
|
||||
|
||||
# Double-counting validation
|
||||
warnings = []
|
||||
if (
|
||||
direct_breakdown.get("partner_discount", 0) > 0
|
||||
and direct_breakdown.get("partner_MDF", 0) > 0
|
||||
and direct_breakdown.get("partner_MDF", 0) > direct_breakdown.get("partner_discount", 0)
|
||||
):
|
||||
warnings.append(
|
||||
"MDF spend exceeds partner discount — verify MDF is not double-counted "
|
||||
"as discount in your channel agreements."
|
||||
)
|
||||
if overhead_pct > 50:
|
||||
warnings.append(
|
||||
f"Overhead allocation of {overhead_pct:.1f}% is unusually high. "
|
||||
"Verify denominator (revenue vs. gross profit) is consistent across channels."
|
||||
)
|
||||
if overhead_pct < 5 and "partner" in channel_name.lower():
|
||||
warnings.append(
|
||||
f"Partner channel overhead allocation of {overhead_pct:.1f}% is unusually low. "
|
||||
"Channel manager, partner program, certification all live in YOUR P&L. "
|
||||
"Inconsistent allocation is the #1 source of false partner-margin lift."
|
||||
)
|
||||
|
||||
return {
|
||||
"channel_name": channel_name,
|
||||
"deal_volume": deal_volume,
|
||||
"gross_revenue": gross_revenue,
|
||||
"direct_breakdown": direct_breakdown,
|
||||
"direct_total": round(direct_total, 2),
|
||||
"overhead_allocation_pct": overhead_pct,
|
||||
"overhead_total": round(overhead_total, 2),
|
||||
"total_loaded_cost": round(total_loaded_cost, 2),
|
||||
"cost_per_deal": round(cost_per_deal, 2),
|
||||
"cost_per_arr_dollar": round(cost_per_arr_dollar, 4),
|
||||
"true_gross_margin_pct": round(true_gross_margin_pct, 2),
|
||||
"hidden_cost_flags": hidden_flags,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def render_markdown(r: dict) -> str:
|
||||
if "error" in r:
|
||||
return f"# Cost-to-Serve\n\n**ERROR**: {r['error']}\n"
|
||||
|
||||
lines = [
|
||||
f"# Cost-to-Serve — {r['channel_name']}",
|
||||
"",
|
||||
"## Inputs",
|
||||
f"- Deal volume (TTM): **{r['deal_volume']:,.0f}**",
|
||||
f"- Gross revenue (TTM): **${r['gross_revenue']:,.0f}**",
|
||||
f"- Overhead allocation: **{r['overhead_allocation_pct']:.1f}%**",
|
||||
"",
|
||||
"## Direct cost breakdown",
|
||||
"| Line item | $ |",
|
||||
"|---|---:|",
|
||||
]
|
||||
for k, v in r["direct_breakdown"].items():
|
||||
lines.append(f"| {k} | {v:,.0f} |")
|
||||
lines += [
|
||||
f"| **Direct total** | **{r['direct_total']:,.0f}** |",
|
||||
f"| Allocated overhead | {r['overhead_total']:,.0f} |",
|
||||
f"| **Total loaded cost** | **{r['total_loaded_cost']:,.0f}** |",
|
||||
"",
|
||||
"## Result",
|
||||
f"- Cost-to-serve **per deal**: **${r['cost_per_deal']:,.2f}**",
|
||||
f"- Cost-to-serve **per $ ARR**: **${r['cost_per_arr_dollar']:.4f}**",
|
||||
f"- **True gross margin** (after channel-specific load): **{r['true_gross_margin_pct']:.2f}%**",
|
||||
"",
|
||||
]
|
||||
if r["hidden_cost_flags"]:
|
||||
lines.append("## Hidden-cost flags")
|
||||
for f in r["hidden_cost_flags"]:
|
||||
lines.append(f"- {f}")
|
||||
lines.append("")
|
||||
if r["warnings"]:
|
||||
lines.append("## Warnings")
|
||||
for w in r["warnings"]:
|
||||
lines.append(f"- {w}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
SAMPLE = {
|
||||
"channel_name": "partner-led-EMEA",
|
||||
"deal_volume": 80,
|
||||
"gross_revenue": 4_000_000,
|
||||
"costs": {
|
||||
"sdr_attribution": 60_000,
|
||||
"ae_attribution": 240_000,
|
||||
"sales_engineer_attribution": 90_000,
|
||||
"channel_manager_attribution": 180_000,
|
||||
"customer_success_attribution": 120_000,
|
||||
"support_attribution": 70_000,
|
||||
"marketing_attribution": 50_000,
|
||||
"partner_discount": 600_000,
|
||||
"partner_MDF": 80_000,
|
||||
"partner_enablement_time": 40_000,
|
||||
"certification_investment": 20_000,
|
||||
"channel_conflict_overhead": 15_000,
|
||||
"tooling_attribution": 25_000,
|
||||
"overhead_allocation_pct": 15.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--input", help="Path to JSON input file")
|
||||
ap.add_argument("--output", choices=["json", "markdown"], default="markdown")
|
||||
ap.add_argument("--sample", action="store_true", help="Run with embedded sample")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.sample:
|
||||
payload = SAMPLE
|
||||
elif args.input:
|
||||
with open(args.input) as f:
|
||||
payload = json.load(f)
|
||||
else:
|
||||
ap.print_help()
|
||||
return 0
|
||||
|
||||
result = compute_cost_to_serve(payload)
|
||||
if args.output == "json":
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
print(render_markdown(result))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
202
commercial/skills/partnerships-architect/SKILL.md
Normal file
202
commercial/skills/partnerships-architect/SKILL.md
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
---
|
||||
name: partnerships-architect
|
||||
description: "Use when a startup is approached by a prospective partner and someone has to decide should we sign this partner, at what partner tier (referral / reseller / OEM / SI-consulting / strategic alliance), with what joint GTM commitment, and at what revshare. Classifies partner tier from independent-demand evidence vs. preferential-terms hunting, designs a 90-day joint GTM plan, models revshare against direct-sale margin, and surfaces kill criteria for unwinding under-performing partnerships. For Head of Partnerships, Head of BD, and Founder-CEOs doing reseller agreement, OEM deal, or strategic alliance review — not technical sale enablement, not channel cost economics, not M&A."
|
||||
version: 2.8.0
|
||||
author: claude-code-skills
|
||||
license: MIT
|
||||
tags: [commercial, partnerships, channel-partners, joint-gtm, revshare, oem, reseller, strategic-alliance]
|
||||
compatible_tools: [claude-code, codex-cli, cursor, antigravity, opencode, gemini-cli]
|
||||
---
|
||||
|
||||
# partnerships-architect
|
||||
|
||||
## Purpose
|
||||
|
||||
Help Head of Partnerships, Head of BD, and Founder-CEOs answer four questions when a
|
||||
prospective partner shows up:
|
||||
|
||||
1. **Is this a real partner, or someone hunting preferential terms without independent demand?**
|
||||
2. **At what tier should we sign them?** (Referral / Reseller / OEM / SI-Consulting / Strategic Alliance)
|
||||
3. **What's the 90-day joint GTM plan that proves the partnership works?**
|
||||
4. **What revshare makes economic sense — and at what point does the partnership beat direct sale?**
|
||||
|
||||
The skill emits a tier verdict + GTM plan + revshare band with explicit kill criteria. It
|
||||
does **not** sign the deal. The human, after running this skill, decides.
|
||||
|
||||
## When to use
|
||||
|
||||
- A prospective partner has approached and asked for reseller / OEM / "strategic" terms
|
||||
- You're designing a new partner program tier structure
|
||||
- You're reviewing an existing partnership that's underperforming and need to decide: re-tier, restructure GTM, or unwind
|
||||
- A Big Logo wants a "strategic alliance" — and you need to validate it's real, not vendor-lock theatre
|
||||
- A consulting firm or SI wants services revshare on your product
|
||||
- A platform vendor offers OEM / white-label and you need to model the math
|
||||
- You suspect "partner-sourced" deals are actually your own pipeline being skimmed for margin
|
||||
|
||||
**Do not use for:**
|
||||
- Technical demos and POCs → `business-growth/sales-engineer`
|
||||
- Cost-to-serve and ROI math on existing channel → sibling `channel-economics`
|
||||
- Whole-company revenue strategy → `c-level-advisor/cro-advisor`
|
||||
- Acquiring a company instead of partnering → `c-level-advisor/ma-playbook`
|
||||
- Per-deal discount approval inside a signed partner contract → `deal-desk`
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Intake (≈ 20 min)
|
||||
|
||||
Fill `assets/partnership_intake_template.md`. Capture: partner_name, partner_type, evidence
|
||||
of independent demand (named accounts they've sourced, end-customer relationships,
|
||||
their sales team size), strategic value (geo / product / brand / channel economics),
|
||||
commitments they've offered (joint marketing spend, dedicated headcount, certification,
|
||||
sales targets).
|
||||
|
||||
If the intake template can't be honestly filled out, the prospective partner has not
|
||||
demonstrated enough substance to evaluate. Stop. Go back to them.
|
||||
|
||||
### Step 2 — Tier classify
|
||||
|
||||
Run `scripts/partner_tier_classifier.py --input intake.json --profile saas --output markdown`.
|
||||
Output ranks the partner into 1 of 5 tiers — REFERRAL / RESELLER / OEM / SI-CONSULTING /
|
||||
STRATEGIC — with deterministic floors. STRATEGIC requires named_accounts ≥ 5 AND
|
||||
multi-year commit AND dedicated resources. Skill emits rationale + kill criteria.
|
||||
|
||||
### Step 3 — Joint GTM plan
|
||||
|
||||
Run `scripts/joint_gtm_planner.py --input gtm.json --profile saas --output markdown`.
|
||||
Output: 90-day plan with pre-launch milestones (training, certification, materials),
|
||||
launch motion (target accounts, sales play, MDF allocation), mid-quarter checkpoint, and
|
||||
90-day success criteria. Validates: cannot plan channel-led GTM for REFERRAL tier; cannot
|
||||
plan white-label for non-OEM tier.
|
||||
|
||||
### Step 4 — Revshare model
|
||||
|
||||
Run `scripts/revshare_modeler.py --input revshare.json --output markdown`. Computes
|
||||
margin per deal direct vs. via partner, recommended revshare % band based on partner
|
||||
contribution depth (sourced > influenced > delivered), break-even partner ROI, and
|
||||
long-term economics — at projected scale, does partner economics beat direct?
|
||||
|
||||
### Step 5 — Decide
|
||||
|
||||
Take tier + GTM plan + revshare band into the partnership committee. Skill does not sign
|
||||
the partner — you do. Document kill criteria in the contract so the unwind is mechanical
|
||||
when triggered.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/partner_tier_classifier.py` — 5-tier classifier with deterministic floors per tier
|
||||
- `scripts/joint_gtm_planner.py` — 90-day joint GTM plan generator with tier-validated motion
|
||||
- `scripts/revshare_modeler.py` — revshare band + break-even ROI + long-term economics
|
||||
|
||||
All scripts: stdlib only. `--help` and `--sample` work on all three.
|
||||
|
||||
## References
|
||||
|
||||
- `references/channel_partner_canon.md` — Caro on HP indirect channels, Chintagunta on channel economics, Hessling on partner programs, Forrester channel software stack, IDC channel research, Tien Tzuo subscription-channel models, Geoffrey Moore whole-product partnerships
|
||||
- `references/joint_gtm_canon.md` — Aaron Ross *Predictable Revenue* (cold-source vs partner), Winning by Design, Jay McBain on co-sell, Microsoft Partner Network playbook, AWS Partner Network research, SiriusDecisions partner benchmarks, Bridge Group SaaS partner data
|
||||
- `references/partnership_anti_patterns.md` — Forrester partner-led-from-your-pipeline research, Tom Tunguz on channel conflict, Hessling failure analyses, MIT Sloan on disproportionate strategic revshare, HP channel post-mortems, IBM channel-conflict cases, Salesforce AppExchange research
|
||||
|
||||
## Assumptions
|
||||
|
||||
- A partner who cannot produce evidence of independent demand (named accounts, end-customer
|
||||
relationships, their own sales team) is hunting preferential terms, not a partner.
|
||||
- Industry profiles (`--profile`) tune defaults — they don't override your data.
|
||||
- Revshare % bands are recommendations; the contract negotiation, MDF policy, and
|
||||
exclusivity terms are human commercial decisions outside this skill.
|
||||
- "Partner-sourced" requires the partner to have introduced the deal AND owned the
|
||||
primary relationship. "Partner-influenced" pays at a lower band. Pay attribution
|
||||
matters more than slide-deck claims.
|
||||
- This skill is for partnership design, not signed-partner deal management — once
|
||||
signed, per-deal commercial review routes to `deal-desk`.
|
||||
- Kill criteria are mandatory. A partnership without a written unwind trigger compounds
|
||||
the bad-partner problem over years.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **"Partner = anyone who asked."** A partner with no independent demand is a discount hunter.
|
||||
Run the tier classifier — REFERRAL tier exists precisely to absorb these without giving
|
||||
away reseller margin.
|
||||
- **Granting OEM / white-label terms without margin sufficient to fund support.** OEM means
|
||||
you support a customer you don't own. If the revshare doesn't fund Tier-2 support cost,
|
||||
the OEM deal is a losing trade.
|
||||
- **Paying sourced-tier revshare on influenced-only deals.** Influenced ≠ sourced. The deal
|
||||
was going to close anyway. Pay the influenced rate.
|
||||
- **No kill criteria for under-performing partner.** "Strategic alliances" without sunset
|
||||
clauses become permanent obligations after the executive sponsor leaves.
|
||||
- **Channel conflict ignored until reps quit.** When your direct rep and your partner both
|
||||
show up at the same account, you lose either the rep or the partner. Decide the rules of
|
||||
engagement before, not after.
|
||||
- **Exclusive territory granted to a weak partner.** This locks out the strong partner who
|
||||
would have actually sourced the deals.
|
||||
- **MDF without ROI accountability.** Market Development Funds without named pipeline,
|
||||
reported ROI, and a quarterly true-up are subsidy, not investment.
|
||||
- **No offboarding plan when partnership ends.** Customer continuity, data hand-back, IP
|
||||
cleanup, and brand take-down must be pre-negotiated. They're impossible to negotiate after
|
||||
the relationship has soured.
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **business-growth/sales-engineer** — technical sale: demos, POCs, integration scoping.
|
||||
Operates after the partnership decision is made and a deal is in flight.
|
||||
- **channel-economics** (sibling) — cost-to-serve and ROI math on an existing channel.
|
||||
Quantifies whether a signed partner is profitable. partnerships-architect decides
|
||||
whether to sign in the first place and at what tier.
|
||||
- **c-level-advisor/cro-advisor** — strategic CRO judgment (when to hire a VP Channel,
|
||||
whole-company revenue mix decisions). partnerships-architect is per-partnership.
|
||||
- **c-level-advisor/ma-playbook** — when the answer is "acquire them" not "partner with
|
||||
them." Trigger: the partner has independent moat you cannot replicate, or the
|
||||
partnership requires equity to align incentives. Re-route to ma-playbook.
|
||||
- **deal-desk** — per-deal discount approval on signed partner contracts.
|
||||
|
||||
## Forcing-question library (Matt Pocock grill discipline)
|
||||
|
||||
Walked one at a time by `/cs:grill-commercial` or the orchestrator. Recommended answer +
|
||||
canon citation per question. Never bundled. Lock 1-3 before opening 4-6.
|
||||
|
||||
1. **"Name 5 end customers this partner has already sold to in the last 12 months — at companies you would target yourself."**
|
||||
Recommended: if they cannot, they have no independent demand. Sign at REFERRAL tier only,
|
||||
if at all. Reseller/OEM/Strategic floors require demonstrated end-customer relationships.
|
||||
Canon: Joe Hessling — partner-program failure analyses identify "no independent demand"
|
||||
as the #1 root cause of dead partner tiers.
|
||||
|
||||
2. **"Is this partner asking for preferential commercial terms, or asking how to bring you customers?"**
|
||||
Recommended: discount hunters lead with terms; real partners lead with accounts. Listen
|
||||
to the first 30 minutes of the first meeting.
|
||||
Canon: Forrester channel research — 60%+ of "partner inquiries" at early-stage SaaS are
|
||||
discount hunting, not channel investment.
|
||||
|
||||
3. **"What's the joint value proposition in one sentence, and who is the named end-customer it serves?"**
|
||||
Recommended: if there is no joint value prop distinct from either party's solo offering,
|
||||
there is no partnership — there is co-marketing at best.
|
||||
Canon: Geoffrey Moore (*Crossing the Chasm*) — whole-product partnerships exist when
|
||||
neither party alone delivers the customer outcome.
|
||||
|
||||
4. **"At what % discount / revshare does this partnership beat the direct-sale economics, and at what scale?"**
|
||||
Recommended: model break-even pipeline volume. If partner-sourced deals must exceed
|
||||
30% of channel volume to beat direct, and partner can plausibly deliver 5%, you have
|
||||
built a losing program.
|
||||
Canon: Pradeep Chintagunta (Chicago Booth) on channel economics — channel partnerships
|
||||
without volume floor break even in theory and lose money in practice.
|
||||
|
||||
5. **"What are the named kill criteria for unwinding this partnership, and are they in the contract?"**
|
||||
Recommended: minimum pipeline floor by quarter, minimum certified resources, minimum
|
||||
joint deals closed, 90-day cure period. Unwinding without pre-agreed criteria becomes
|
||||
a 2-year legal battle.
|
||||
Canon: IBM channel-conflict case studies (1990s post-divestiture) — undocumented kill
|
||||
criteria converted bad partners into permanent obligations.
|
||||
|
||||
6. **"If this partner sells to one of YOUR direct accounts, who wins — your rep or them?"**
|
||||
Recommended: Rules of Engagement in writing, signed before kickoff. Territory by named
|
||||
account, by segment, or by geo. Conflict resolution at named human, not committee.
|
||||
Canon: Jay McBain (Canalys) — channel conflict is the #1 partner program killer; written
|
||||
ROE published before partner signs prevents 80% of disputes.
|
||||
|
||||
7. **"Is this a partnership, or should this be an acquisition?"**
|
||||
Recommended: if the partner has independent moat you cannot replicate AND the
|
||||
partnership requires multi-year exclusivity AND the partnership requires equity-like
|
||||
alignment, you're describing an acquisition. Re-route to `ma-playbook`.
|
||||
Canon: HP channel post-mortems (Indigo, EDS partial integrations) — partnerships
|
||||
structured as acquisitions-without-equity destroy more value than either pure path.
|
||||
|
||||
Walk depth-first. Lock 1-3 (is this a real partner?) before opening 4-7 (is the structure
|
||||
right?). After all 7 are answered, invoke `partner_tier_classifier.py` →
|
||||
`joint_gtm_planner.py` → `revshare_modeler.py` in sequence.
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
# Partnership Intake Template
|
||||
|
||||
**Owner:** _______________ **Date:** _______________
|
||||
**Time to fill out:** ≈ 20 minutes
|
||||
**Prospective partner name:** _______________
|
||||
|
||||
Fill this template out honestly BEFORE running `partner_tier_classifier.py`. The skill
|
||||
outputs are only as good as the inputs. If you cannot honestly answer a field, write
|
||||
"unknown" — do not guess. If multiple fields are "unknown," the partner has not
|
||||
demonstrated enough substance to evaluate. Pause the process and go back to them.
|
||||
|
||||
---
|
||||
|
||||
## 1. Partner identity
|
||||
|
||||
- **Partner legal name:** _______________
|
||||
- **Partner_type** (pick one): [ ] referral [ ] reseller [ ] oem [ ] si_consultant
|
||||
[ ] technology [ ] strategic_alliance
|
||||
- **Who introduced them?** _______________
|
||||
- **Why are they approaching us NOW?** _______________
|
||||
(If the honest answer is "they want preferential discount," classify as REFERRAL
|
||||
and proceed accordingly. Do not advance to RESELLER+.)
|
||||
|
||||
## 2. Independent demand evidence
|
||||
|
||||
This is the most important section. STRATEGIC and OEM tiers have HARD floors here.
|
||||
|
||||
- **Named accounts they have sold to in the last 12 months, at companies you would
|
||||
also target:**
|
||||
1. _______________
|
||||
2. _______________
|
||||
3. _______________
|
||||
4. _______________
|
||||
5. _______________
|
||||
|
||||
- **`named_accounts_sourced_count` (count of verifiable, reference-able named
|
||||
accounts):** _______________
|
||||
|
||||
- **Of their total customer base, what % are end customers (companies they sold
|
||||
directly to and own the relationship), vs intermediaries / sub-partners?**
|
||||
|
||||
`end_customer_relationships_pct` (0-100): _______________
|
||||
|
||||
- **Sales team size — how many people on their team actively sell?**
|
||||
|
||||
`sales_team_size`: _______________
|
||||
|
||||
Note: "everyone is a salesperson at our company" is not an answer. Count the people
|
||||
whose comp plan includes quota.
|
||||
|
||||
## 3. Strategic value (which of these does this partner change?)
|
||||
|
||||
- **`geo_coverage`** — geographies they reach that we don't / cover poorly:
|
||||
_______________
|
||||
|
||||
- **`product_complement`** — what they bring that completes the customer outcome
|
||||
(whole-product reasoning per Geoffrey Moore):
|
||||
_______________
|
||||
|
||||
- **`brand_lift`** — does their brand carry credibility we lack?
|
||||
[ ] strong [ ] mid [ ] none
|
||||
|
||||
- **`channel_economics_advantage`** — lower CAC, faster sales cycle, better retention
|
||||
in a segment we struggle with?
|
||||
_______________
|
||||
|
||||
## 4. Commitments they have offered
|
||||
|
||||
Be precise. Vague commitments are not commitments.
|
||||
|
||||
- **`joint_marketing_spend`** (USD per year): _______________
|
||||
|
||||
- **`dedicated_resources`** — named individuals on their team dedicated to this
|
||||
partnership (not "we'll figure it out"):
|
||||
count: _______________
|
||||
names: _______________
|
||||
|
||||
- **`certification_completion`** — will their team complete our certification
|
||||
curriculum?
|
||||
[ ] yes, scheduled [ ] willing but not scheduled [ ] no
|
||||
|
||||
- **`sales_targets`** — specific named pipeline and closed-won targets, with a time
|
||||
horizon:
|
||||
_______________
|
||||
(Example: "12 closed-won deals over 12 months, with named target accounts
|
||||
identified in TAL.")
|
||||
|
||||
## 5. What they want from US
|
||||
|
||||
- **Revshare ask:** _______________
|
||||
- **Exclusivity ask:** _______________ (territory / segment / vertical / none)
|
||||
- **MDF ask:** _______________
|
||||
- **Engineering integration ask:** _______________
|
||||
- **Anything unusual:** _______________
|
||||
|
||||
## 6. Honest red-flag check
|
||||
|
||||
If any of these are true, the partner is a discount hunter, not a partner. Sign at
|
||||
REFERRAL or do not sign at all.
|
||||
|
||||
- [ ] They cannot name 5 customers they have sold to in the last 12 months
|
||||
- [ ] Their commercial ask is precise; their joint-value-prop ask is vague
|
||||
- [ ] They want exclusive territory at signing with no performance condition
|
||||
- [ ] They claim "strategic alliance" but have no exec sponsor on their side
|
||||
- [ ] They are pushing for fast signing ("we have a deal we need to close this week")
|
||||
|
||||
---
|
||||
|
||||
## JSON skeleton for `partner_tier_classifier.py --input`
|
||||
|
||||
```json
|
||||
{
|
||||
"partner_name": "",
|
||||
"partner_type": "",
|
||||
"independent_demand_evidence": {
|
||||
"named_accounts_sourced_count": 0,
|
||||
"end_customer_relationships_pct": 0,
|
||||
"sales_team_size": 0
|
||||
},
|
||||
"strategic_value": {
|
||||
"geo_coverage": "",
|
||||
"product_complement": "",
|
||||
"brand_lift": "",
|
||||
"channel_economics_advantage": ""
|
||||
},
|
||||
"commitments": {
|
||||
"joint_marketing_spend": 0,
|
||||
"dedicated_resources": 0,
|
||||
"certification_completion": false,
|
||||
"sales_targets": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Save as `partner.json`, then run:
|
||||
|
||||
```
|
||||
python scripts/partner_tier_classifier.py --input partner.json --profile saas --output markdown
|
||||
```
|
||||
|
||||
Then proceed to `joint_gtm_planner.py` and `revshare_modeler.py` only if the assigned
|
||||
tier is RESELLER or higher AND your partnership committee has agreed to move forward.
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
# Channel Partner Canon
|
||||
|
||||
Curated, opinionated knowledge base behind `partner_tier_classifier.py`'s scoring rules
|
||||
and the 5-tier model. This is the source material; the script encodes the deterministic
|
||||
floors derived from it.
|
||||
|
||||
## Core principle
|
||||
|
||||
A partner is not a discount channel. A partner brings independent demand, owns
|
||||
end-customer relationships, and changes your distribution math. Anyone asking for
|
||||
preferential commercial terms without those three is not a partner — they are a
|
||||
discount hunter wearing a partnership-deck costume.
|
||||
|
||||
The 5-tier model exists to absorb the spectrum without giving away margin: REFERRAL is
|
||||
the polite no, RESELLER and OEM are economic structures, SI/CONSULTING is a services
|
||||
attach, STRATEGIC is reserved for the rare case where the partnership genuinely
|
||||
re-shapes the market.
|
||||
|
||||
---
|
||||
|
||||
## The 5 tiers
|
||||
|
||||
### REFERRAL
|
||||
|
||||
Informal intro. No exclusivity. Small finder's fee (5-10% of first-year ARR, one-time).
|
||||
No certification required. No co-marketing commitment. 2-quarter auto-sunset if no
|
||||
qualified intros.
|
||||
|
||||
When you use this: 90%+ of inbound "partnership requests" at early-stage SaaS belong
|
||||
here. A REFERRAL agreement says "we appreciate the intro, here's a finder's fee, we are
|
||||
not building a joint motion."
|
||||
|
||||
### RESELLER
|
||||
|
||||
Transactional resale with margin. Partner's customer pays partner; partner remits net of
|
||||
revshare. Floor: end_customer_relationships_pct ≥ 40%, sales_team_size ≥ 3 (someone has
|
||||
to actually sell). Margin band 20-35%. Basic product certification required. Joint
|
||||
target account list. Channel conflict rules of engagement signed.
|
||||
|
||||
Failure mode: granting reseller margin to a partner whose "customers" are actually your
|
||||
inbound that they're routing through their paper. Test: of the named accounts they
|
||||
sourced, how many had no prior relationship with you?
|
||||
|
||||
### OEM
|
||||
|
||||
White-label / embedded. Partner's brand on the front, your product underneath. Floor:
|
||||
end_customer_relationships_pct ≥ 60%, dedicated_resources ≥ 2, certification complete.
|
||||
Revshare 40-55% to compensate for the partner owning Tier-1 support and customer
|
||||
relationship. Joint support runbook mandatory. End-customer NPS tracked.
|
||||
|
||||
Failure mode: granting OEM revshare without sufficient margin to fund your Tier-2+
|
||||
support cost. If your support cost is $X per customer per year, and the OEM revshare
|
||||
leaves you with less than $X net, the OEM deal is a losing trade no matter how big it
|
||||
looks.
|
||||
|
||||
### SI_CONSULTING
|
||||
|
||||
Services attach. Partner sells their implementation services attached to your product.
|
||||
Floor: partner_type = si_consultant, sales_team_size ≥ 5, end_customer_relationships_pct
|
||||
≥ 50%. Product revshare 15-25%; services-side comp is independent.
|
||||
|
||||
Distinct from RESELLER: SI partners are selling THEIR services, you are pulled in. They
|
||||
own customer relationship via the services scope. NEVER pay product revshare on
|
||||
services-only "delivered" contribution — that's services-side compensation territory
|
||||
(fixed fee or hourly).
|
||||
|
||||
### STRATEGIC
|
||||
|
||||
Multi-year co-investment. Named exec sponsors both sides. Reserved for partnerships that
|
||||
genuinely change your distribution. Floors: named_accounts_sourced_count ≥ 5,
|
||||
dedicated_resources ≥ 3, joint_marketing_spend ≥ $50k, multi-year commitment. Revshare
|
||||
25-40% with pipeline floor + co-investment evidence.
|
||||
|
||||
Failure mode: "strategic" applied to any deal where the other side is big-logo and
|
||||
nothing else. Big-logo without independent demand evidence is RESELLER or REFERRAL
|
||||
wearing a logo. Real strategic partnerships are rare — most companies should have 0-3 at
|
||||
most.
|
||||
|
||||
---
|
||||
|
||||
## Industry profile notes
|
||||
|
||||
- **SaaS**: floors as above
|
||||
- **API**: bias toward technology/OEM partners (developer-first GTM); lower sales-team
|
||||
floors because APIs sell themselves to developers, partners sell to procurement
|
||||
- **Enterprise software**: higher SI floor (8 reps) because enterprise SI is a real
|
||||
organization, not a one-person shop
|
||||
- **Marketplace**: higher referral acceptance, lower reseller bar (marketplace dynamics
|
||||
reward many small partners over few big ones)
|
||||
- **Hardware**: higher OEM bar (4 dedicated resources) because hardware support
|
||||
obligations are real costs
|
||||
|
||||
---
|
||||
|
||||
## Sources (≥ 7 authoritative references)
|
||||
|
||||
1. **Robert Caro** — *The Years of Lyndon Johnson* (especially the chapters on the LBJ
|
||||
Senate-era patronage system) is the unintuitive but canonical reference on how
|
||||
bilateral relationships convert into structural distribution. Distinct from
|
||||
Caro's HP biography research (HP private archives), the LBJ work documents the
|
||||
discipline of asking "what does this person actually deliver" vs. "what do they
|
||||
claim to deliver" at scale — the same question a Head of BD asks of every
|
||||
prospective partner. The HP work itself (commercial-channel post-mortems, 1990s
|
||||
inkjet division) is referenced through second-party academic citations (see
|
||||
Chintagunta 2009 below).
|
||||
2. **Pradeep Chintagunta** — Joseph T. and Bernice S. Lewis Distinguished Service
|
||||
Professor of Marketing, Chicago Booth. Academic foundation for channel economics
|
||||
(e.g., Bronnenberg & Chintagunta on channel power in CPG distribution; the
|
||||
underlying math applies directly to SaaS channel decisions). Key insight:
|
||||
channel partnerships without volume floor break even on paper and lose money in
|
||||
practice because fixed program cost is paid every quarter regardless of throughput.
|
||||
3. **Joe Hessling** — Founder of 365 Retail Markets; speaker and operator on partner
|
||||
programs. Published failure analyses of partner programs (industry talks +
|
||||
PartnerHub presentations) identify "no independent demand" as the #1 root cause of
|
||||
dead partner tiers — partners that joined for the discount, not the customers.
|
||||
4. **Forrester Research** — *Channel Software Tech Stack* (annual report) and
|
||||
Forrester partner-led research (Jay McBain era, ~2018-2021). Documents the
|
||||
"partner-led-deals-from-your-own-pipeline" anti-pattern: 60%+ of "partner inquiries"
|
||||
at early-stage SaaS are discount hunting, not channel investment.
|
||||
5. **IDC** — *Worldwide Channel Software Tracker* and IDC partner research. Cost-to-serve
|
||||
and partner-program economics benchmarks; multi-year longitudinal data on which
|
||||
partner-program structures produce durable revenue.
|
||||
6. **Tien Tzuo** — *Subscribed* (Portfolio, 2018), founder of Zuora. Channel chapter
|
||||
covers subscription-channel revshare models, the shift from one-time-resale margin
|
||||
to recurring revshare math, and the structural reason OEM partnerships require
|
||||
different revshare floors than perpetual-license resale.
|
||||
7. **Geoffrey Moore** — *Crossing the Chasm* (HarperBusiness, 1991/2014 revised) and
|
||||
*Inside the Tornado* (HarperBusiness, 1995). Introduces the "whole product"
|
||||
framework — the canonical lens for deciding whether a partnership is real (each
|
||||
party delivers a component neither could deliver alone) vs. theatre (overlap with
|
||||
no joint product).
|
||||
8. **Microsoft Partner Network public playbooks** (MPN documentation, Microsoft Build
|
||||
and Inspire content, 2018-2024) — operational templates for tier structure,
|
||||
certification, and channel conflict rules of engagement. Source for the "named
|
||||
account list + ROE before signing" discipline encoded in the joint GTM planner.
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
# Joint GTM Canon
|
||||
|
||||
Source material behind `joint_gtm_planner.py`'s tier-validated motion matrix and the
|
||||
90-day milestone defaults. The discipline encoded here is: a partnership does not exist
|
||||
until a joint pursuit closes a deal that neither side would have closed alone.
|
||||
|
||||
## Core principle
|
||||
|
||||
Joint GTM is not a marketing event. It is a sales motion that has to produce
|
||||
attributable revenue against a named, written floor — within one sales cycle, or the
|
||||
partnership is theatre.
|
||||
|
||||
The 90-day plan exists to manufacture decision-grade evidence: did this partner actually
|
||||
move pipeline, or did we just throw a launch party? Without the structure, partnership
|
||||
reviews degrade into "we like working with them" — which is a feeling, not a data point.
|
||||
|
||||
---
|
||||
|
||||
## The 4 sales motions
|
||||
|
||||
### pure_referral
|
||||
|
||||
Partner sends a lead. Your AE runs the entire sale. Partner gets a finder's fee on close.
|
||||
No exclusivity, no MDF, no certification. Operates at REFERRAL tier; sometimes RESELLER
|
||||
and SI_CONSULTING.
|
||||
|
||||
Anti-pattern: paying finder's fee on accounts already in your pipeline. The first job of
|
||||
the program is attribution discipline — was this lead really new to us before the
|
||||
partner sent it?
|
||||
|
||||
### co_sell
|
||||
|
||||
Partner and your AE jointly pursue the same account. Partner brings access; you bring
|
||||
product. Both sides on calls, both forecasted. Revshare paid on close. Operates at
|
||||
RESELLER, OEM, SI_CONSULTING, STRATEGIC tiers.
|
||||
|
||||
Anti-pattern: "co-sell" that is really "we let them watch" — partner attends meetings
|
||||
but does not actively progress the deal. After 90 days, look at who advanced the deal
|
||||
between stages. If your rep moved every stage, it was not co-sell — pay influenced rate,
|
||||
not sourced.
|
||||
|
||||
### channel_led
|
||||
|
||||
Partner runs the full sales motion; you provide SE support and product. Partner
|
||||
forecasts; you do not. Operates at RESELLER, OEM, STRATEGIC tiers — never REFERRAL or
|
||||
SI_CONSULTING.
|
||||
|
||||
Anti-pattern: channel-led claimed but every demo requires your SE. If your SE is on
|
||||
every customer call, the partner cannot sell the product solo — they are channel-led on
|
||||
paper, co-sell in reality. Recertify or change the motion.
|
||||
|
||||
### white_label
|
||||
|
||||
Partner's brand on the front; you are invisible to the end customer. Partner owns
|
||||
support, branding, customer relationship. Operates at OEM tier only. Requires
|
||||
higher revshare to compensate for the loss of customer relationship.
|
||||
|
||||
Anti-pattern: white-label without margin sufficient to fund your Tier-2+ support cost.
|
||||
If you are the de-facto product owner but only see 45% of the revenue, and your CTS
|
||||
takes 30% of that, you are running a charity.
|
||||
|
||||
---
|
||||
|
||||
## The 90-day milestone structure
|
||||
|
||||
### Pre-launch (day -30 to 0)
|
||||
|
||||
Five non-negotiables: signed agreement; named exec sponsors both sides; jointly built
|
||||
Target Account List (TAL) with conflict resolution per account; partner sales
|
||||
certification; Rules of Engagement (ROE) signed before any joint pursuit. OEM and
|
||||
STRATEGIC tiers add an integration QA + support runbook signoff.
|
||||
|
||||
### Launch (day 0 to 30)
|
||||
|
||||
Three measurable beats: first joint pursuit named within 7 days; 5 joint pursuits in
|
||||
flight by day 15; first closed-won (or clear blocker isolation) by day 30. Channel-led
|
||||
motions add a partner-led-demo-without-our-SE validation at day 20.
|
||||
|
||||
### Mid-quarter checkpoint (day 45)
|
||||
|
||||
Hard gates: pipeline-sourced ≥ 50% of 90-day floor; at least 1 closed-won OR named
|
||||
blocker with owner + remediation date; certified rep count maintained; ROE working (zero
|
||||
unresolved escalations); kill-criteria triggered? If yes, escalate to partnership
|
||||
committee NOW.
|
||||
|
||||
### 90-day decision (day 90)
|
||||
|
||||
Decision-grade artifact: pipeline-sourced ≥ floor, deals-closed-won ≥ floor, win/loss
|
||||
doc, certified rep count maintained, channel-conflict log clean. Outcome: continue /
|
||||
re-tier / unwind, with named human accountable. No "let's see another quarter" — that's
|
||||
how dead partnerships compound.
|
||||
|
||||
---
|
||||
|
||||
## Industry profile notes
|
||||
|
||||
- **SaaS**: 8x deal_avg_size as pipeline floor for RESELLER; 12x for STRATEGIC
|
||||
- **API**: higher pipeline multiples (10x reseller, 15x strategic) — API deals are
|
||||
smaller and higher-volume
|
||||
- **Enterprise software**: lower deal-count floors but higher pipeline multiples
|
||||
- **Marketplace**: highest pipeline multiples (12-18x) — partner volume is the whole
|
||||
point
|
||||
- **Hardware**: highest MDF defaults ($100k OEM, $200k STRATEGIC) — hardware partner
|
||||
programs require physical inventory, demo equipment, certified field engineers
|
||||
|
||||
---
|
||||
|
||||
## Sources (≥ 7 authoritative references)
|
||||
|
||||
1. **Aaron Ross & Marylou Tyler** — *Predictable Revenue* (PebbleStorm, 2011). Source
|
||||
for the cold-source vs. partner-source attribution distinction; the
|
||||
"Cold Calling 2.0" framework's principle is that channel source is a different
|
||||
pipeline economy than direct outbound — they cannot share metrics or comp plans.
|
||||
2. **Winning by Design** — Jacco van der Kooij and team. SaaS sales methodology
|
||||
incorporating partner-attached deals into the bow-tie funnel; the discipline of
|
||||
tracking partner-attached vs. partner-sourced separately is canon here.
|
||||
3. **Jay McBain** — Chief Analyst at Canalys (formerly Forrester); industry's leading
|
||||
voice on co-sell discipline. Public writing (LinkedIn newsletter,
|
||||
Channel-as-a-Service podcast 2019-2024) frames co-sell as "the most-misused word in
|
||||
channel" — most "co-sell" is actually referral, and the difference matters for
|
||||
revshare math.
|
||||
4. **Microsoft Partner Network playbooks** (MPN public documentation; Microsoft Inspire
|
||||
and Build sessions, 2018-2024). Operational source for tier structure, MCT/MCP
|
||||
certification cadence, and the principle that channel-led motions require partner
|
||||
certification + customer-facing partner-of-record designation BEFORE joint
|
||||
pursuits begin.
|
||||
5. **AWS Partner Network research** (APN public documentation; AWS re:Invent Partner
|
||||
Day content, 2017-2024). Source for the consulting partner vs. technology partner
|
||||
distinction, the competency-tier model, and the "partner-led" SI motion mechanics.
|
||||
6. **SiriusDecisions** (now Forrester after 2018 acquisition) — partner-program research
|
||||
and the SiriusDecisions Demand Waterfall framework. Source for the discipline of
|
||||
tracking partner-sourced pipeline separately from partner-influenced, and the
|
||||
benchmark that partner-influenced should pay at ~50% the revshare rate of
|
||||
partner-sourced.
|
||||
7. **Bridge Group SaaS Sales Benchmarks** (annual) — partner-attached deal benchmarks,
|
||||
ramp times for partner reps vs. direct reps, and the data behind the "12 months
|
||||
minimum to evaluate a partner program" heuristic encoded as a warning in the
|
||||
joint_gtm_planner.
|
||||
8. **Maria Pergolino & Aaron Ross** — *From Impossible to Inevitable* (Wiley, 2016).
|
||||
Chapter on channel reproduces the discipline that partner programs without named
|
||||
pipeline floors are decoration; the 8x-deal-avg-size pipeline floor convention for
|
||||
RESELLER tier derives from this and SiriusDecisions data.
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
# Partnership Anti-Patterns
|
||||
|
||||
The named failure modes encoded as warnings and validation errors across the three
|
||||
scripts. Each anti-pattern below is sourced from real channel post-mortems and the
|
||||
academic literature on channel economics. If your partnership program has any of these,
|
||||
re-tier or unwind.
|
||||
|
||||
## Core principle
|
||||
|
||||
A bad partnership is more expensive than no partnership. The fixed program cost (MDF,
|
||||
overhead, certification, joint marketing) is paid every quarter regardless of throughput.
|
||||
A partner that produces sub-floor volume converts the program from "investment" into
|
||||
"subsidy" — and subsidies are silent margin destroyers that compound across years.
|
||||
|
||||
The kill criteria embedded in every tier exist to make the unwind mechanical. The
|
||||
moment a kill criterion triggers, the human review is "execute the contract" not
|
||||
"renegotiate the relationship." The contract was the renegotiation; if you wait until
|
||||
the criterion triggers to start the conversation, you have already lost the 6 months
|
||||
you needed to source the replacement partner.
|
||||
|
||||
---
|
||||
|
||||
## The 8 anti-patterns (named and indexed)
|
||||
|
||||
### 1. "Partner = anyone who asked"
|
||||
|
||||
The default sin of inbound partnerships. A prospect emails "we should partner," the
|
||||
account manager forwards to BD, BD forwards to legal, and 6 weeks later there is a
|
||||
signed "partner agreement" with no commitments on either side.
|
||||
|
||||
Test: run the intake template honestly. If `named_accounts_sourced_count = 0` AND
|
||||
`end_customer_relationships_pct < 30`, this is not a partner. Sign at REFERRAL tier
|
||||
with auto-sunset, or do not sign.
|
||||
|
||||
Sources: Forrester partner research (60%+ of inbound partner inquiries at early-stage
|
||||
SaaS lack independent demand); Joe Hessling partner-program failure analyses.
|
||||
|
||||
### 2. "White-label without margin enough to fund support"
|
||||
|
||||
OEM deal looks great on the deck. Net margin per deal looks great. Three months in, you
|
||||
discover the OEM customer base is 4x the support volume of your direct customers
|
||||
(because they don't know your product, and the OEM didn't actually train their CS team).
|
||||
|
||||
Test: model `our_cost_to_serve_via_partner_usd` honestly, including Tier-2+ support
|
||||
load, escalation triage, custom-integration debugging, and post-incident reporting. If
|
||||
the top of the revshare band produces negative per-deal margin, do not sign.
|
||||
|
||||
Sources: Hewlett-Packard channel post-mortems (1990s inkjet OEM cases); IBM channel-
|
||||
conflict cases (post-PC-divestiture, late 1990s through 2005).
|
||||
|
||||
### 3. "Revshare for influenced-only deals at sourced rates"
|
||||
|
||||
Partner attends a few meetings, sends an intro email, accelerates a deal that was
|
||||
already in motion. Their CRM logs it as "partner-sourced." Your CRM logs it as
|
||||
"originated outbound rep X." The contract was ambiguous. The partner invoices at 30%
|
||||
revshare on the full ARR.
|
||||
|
||||
Test: written attribution rules in the contract. "Sourced" requires partner to have
|
||||
introduced AND owned the relationship through stage 2. "Influenced" pays at ≤ 50% of
|
||||
sourced rate. Disputed attribution defaults to influenced.
|
||||
|
||||
Sources: SiriusDecisions partner research; Jay McBain on the "most-misused word in
|
||||
channel."
|
||||
|
||||
### 4. "No kill criteria for under-performing partner"
|
||||
|
||||
The partnership has been declining for 4 quarters. The exec sponsor on the partner side
|
||||
left 2 quarters ago. The certified reps were never replaced. Pipeline-sourced is at 20%
|
||||
of the floor. But there is no clause in the contract specifying what happens — so the
|
||||
program stays funded, the MDF gets paid, and the relationship dies slowly while you
|
||||
keep writing checks.
|
||||
|
||||
Test: every tier has named kill criteria in the contract. RESELLER: <25% of target in
|
||||
any quarter triggers 90-day cure. STRATEGIC: <70% of floor in 2 consecutive quarters
|
||||
triggers joint exec review. The criteria are mechanical, not discretionary.
|
||||
|
||||
Sources: IBM channel-conflict case studies; MIT Sloan research on disproportionate
|
||||
strategic-tier revshare paid to long-dead partnerships.
|
||||
|
||||
### 5. "Channel conflict ignored until reps quit"
|
||||
|
||||
Your top AE has been working an account for 8 months. The OEM partner signs the same
|
||||
account through their channel motion. The deal closes — but to the partner. Your AE
|
||||
gets nothing (no SPIFF, no attribution, no comp). Two weeks later, your top AE quits.
|
||||
|
||||
Test: Rules of Engagement (ROE) signed BEFORE any joint pursuit begins. Named-account
|
||||
map. Conflict resolution at named human (Sales Director ↔ Partner Sales lead), not
|
||||
committee. Documented escalation path. Channel-conflict log reviewed at every QBR.
|
||||
|
||||
Sources: Jay McBain (Canalys) — channel conflict is the #1 partner program killer;
|
||||
written ROE published before partner signs prevents 80% of disputes.
|
||||
|
||||
### 6. "Exclusive territory granted to weak partner"
|
||||
|
||||
Partner asks for exclusive territory at signing — "we need protection to invest in
|
||||
sales." You grant exclusive EMEA. Two quarters later, the partner has produced 1 deal.
|
||||
Two more quarters: still 1. Meanwhile, three other partners are asking for EMEA. Your
|
||||
contract prevents you from signing them. Three years later, you are stuck with a dead
|
||||
partner in exclusive territory.
|
||||
|
||||
Test: exclusivity, if granted, is performance-conditioned. Volume floor by quarter;
|
||||
miss the floor twice, exclusivity converts to non-exclusive. Never grant unconditional
|
||||
exclusivity at signing.
|
||||
|
||||
Sources: Hewlett-Packard channel post-mortems (Indigo press division partnerships);
|
||||
Pradeep Chintagunta on channel power dynamics.
|
||||
|
||||
### 7. "MDF without ROI accountability"
|
||||
|
||||
Quarter 1: $15k MDF sent. Quarter 2: $15k MDF sent. Quarter 3: $15k MDF sent. Quarter
|
||||
4: no named pipeline attributable to MDF spend. Partner reports "we are building
|
||||
brand awareness." You have spent $60k.
|
||||
|
||||
Test: every MDF disbursement tied to a named program (webinar, field event, content
|
||||
piece) with named pipeline expectation. Quarterly true-up with attributable pipeline.
|
||||
Sub-floor pipeline triggers MDF pause, not "let's give it more time."
|
||||
|
||||
Sources: Forrester channel research; AWS Partner Network MDF accountability framework
|
||||
(public APN documentation).
|
||||
|
||||
### 8. "No offboarding plan when partnership ends"
|
||||
|
||||
The partnership has ended. Now: what happens to the joint customers? Where does the
|
||||
customer data go? Who answers their support calls? Whose brand is on the renewal? Is
|
||||
there a non-compete? Can the partner keep selling to the customers they sourced? The
|
||||
answers are being negotiated in real time, under pressure, with lawyers on the phone.
|
||||
|
||||
Test: offboarding plan in the original contract. Data hand-back procedures, customer
|
||||
continuity ownership, IP cleanup, brand take-down timeline, post-termination
|
||||
non-compete (if any). Negotiate offboarding while the relationship is healthy.
|
||||
|
||||
Sources: IBM channel-conflict case studies; Salesforce AppExchange research on
|
||||
partnership endings.
|
||||
|
||||
---
|
||||
|
||||
## Sources (≥ 7 authoritative references)
|
||||
|
||||
1. **Forrester Research** — *Channel Software Tech Stack* and partner-led research
|
||||
(Jay McBain era). Documents the partner-led-deals-from-your-own-pipeline anti-
|
||||
pattern and MDF accountability gaps in early-stage SaaS partner programs.
|
||||
2. **Tom Tunguz** — Redpoint Ventures GP; channel-conflict and SaaS partner economics
|
||||
writing (tomtunguz.com archives, 2014-2024). Source for the "channel conflict
|
||||
trap" terminology and the data on rep attrition correlated with unresolved channel
|
||||
conflict.
|
||||
3. **Joe Hessling** — partner-program failure analyses (industry talks, PartnerHub
|
||||
presentations). Source for the "no independent demand" failure mode and the
|
||||
discipline of partnership intake-template honesty as a leading indicator.
|
||||
4. **MIT Sloan Management Review** — articles on disproportionate revshare to
|
||||
"strategic" partners (e.g., research on partnership ROI miscalibration, 2010-2020
|
||||
archive). Quantifies the cost of strategic-tier programs that produce sub-tier
|
||||
results.
|
||||
5. **Hewlett-Packard channel post-mortems** — published case studies and academic
|
||||
write-ups of the HP inkjet, Indigo, and EDS partial integration channel programs.
|
||||
Source for anti-patterns 2, 6, and the data behind hardware-tier revshare floors.
|
||||
6. **IBM channel-conflict case studies** (post-PC-divestiture era, 1990s-2005) — both
|
||||
internal IBM publications and Harvard Business Review case treatments. Source for
|
||||
anti-patterns 4 and 8 specifically — what happens when kill criteria and
|
||||
offboarding are not in writing.
|
||||
7. **Salesforce AppExchange research** — public AppExchange ISV partner research, 2015-
|
||||
2024. Source for partnership-ending anti-patterns and the data on ISV partner
|
||||
churn correlated with absent offboarding clauses.
|
||||
8. **Pradeep Chintagunta** (Chicago Booth) — *Channel power, channel investment, and
|
||||
partner economics* academic literature. Source for the principle that channel
|
||||
partnerships without volume floor break even in theory and lose money in practice.
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
#!/usr/bin/env python3
|
||||
"""joint_gtm_planner.py - Generate a 90-day joint GTM plan for a signed partner.
|
||||
|
||||
Stdlib-only. Deterministic. Validates that the sales_motion is compatible with the
|
||||
partner_tier — refuses to plan channel-led GTM for a REFERRAL tier, refuses to plan
|
||||
white-label for any tier other than OEM.
|
||||
|
||||
Output: 90-day plan with:
|
||||
- Pre-launch milestones (days -30 to 0): training, certification, materials, target accounts
|
||||
- Launch motion (days 0 to 30): MDF allocation, first deals, joint pursuit
|
||||
- Mid-quarter checkpoint (day 45): named checkpoint criteria
|
||||
- 90-day success criteria: pipeline-sourced floor, deals-closed floor, learnings doc
|
||||
|
||||
Industry profiles tune:
|
||||
- target account count by tier
|
||||
- MDF spend defaults by tier
|
||||
- pipeline-sourced floor by tier (multiple of deal_avg_size)
|
||||
|
||||
Usage:
|
||||
python joint_gtm_planner.py --sample
|
||||
python joint_gtm_planner.py --input gtm.json --profile saas
|
||||
python joint_gtm_planner.py --input gtm.json --output json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Any
|
||||
|
||||
|
||||
SAMPLE_GTM = {
|
||||
"partner_name": "Northstar Consulting",
|
||||
"partner_tier": "SI_CONSULTING",
|
||||
"target_segments": ["mid-market financial services EMEA", "regulated SaaS LATAM"],
|
||||
"joint_value_proposition": (
|
||||
"We bring the platform; Northstar brings 8 certified consultants who deliver "
|
||||
"the regulated-vertical implementation in 60 days vs the 180 days customers "
|
||||
"would spend doing it themselves."
|
||||
),
|
||||
"sales_motion": "co_sell",
|
||||
"commitment_horizon_months": 12,
|
||||
"deal_avg_size_usd": 90000,
|
||||
}
|
||||
|
||||
|
||||
VALID_TIERS = ("REFERRAL", "RESELLER", "OEM", "SI_CONSULTING", "STRATEGIC")
|
||||
VALID_MOTIONS = ("pure_referral", "co_sell", "channel_led", "white_label")
|
||||
|
||||
|
||||
# Hard compatibility matrix: which motions are allowed at which tier.
|
||||
TIER_MOTION_MATRIX: dict[str, set[str]] = {
|
||||
"REFERRAL": {"pure_referral"},
|
||||
"RESELLER": {"pure_referral", "co_sell", "channel_led"},
|
||||
"OEM": {"co_sell", "channel_led", "white_label"},
|
||||
"SI_CONSULTING": {"pure_referral", "co_sell"},
|
||||
"STRATEGIC": {"co_sell", "channel_led"},
|
||||
}
|
||||
|
||||
|
||||
PROFILES: dict[str, dict[str, Any]] = {
|
||||
"saas": {
|
||||
"target_accounts": {"REFERRAL": 5, "RESELLER": 15, "OEM": 10, "SI_CONSULTING": 12, "STRATEGIC": 20},
|
||||
"mdf_default": {"REFERRAL": 0, "RESELLER": 15000, "OEM": 40000, "SI_CONSULTING": 20000, "STRATEGIC": 75000},
|
||||
"pipeline_floor_multiple": {"REFERRAL": 3, "RESELLER": 8, "OEM": 6, "SI_CONSULTING": 6, "STRATEGIC": 12},
|
||||
"deals_closed_floor": {"REFERRAL": 1, "RESELLER": 3, "OEM": 2, "SI_CONSULTING": 2, "STRATEGIC": 4},
|
||||
},
|
||||
"api": {
|
||||
"target_accounts": {"REFERRAL": 8, "RESELLER": 20, "OEM": 8, "SI_CONSULTING": 10, "STRATEGIC": 15},
|
||||
"mdf_default": {"REFERRAL": 0, "RESELLER": 10000, "OEM": 30000, "SI_CONSULTING": 15000, "STRATEGIC": 60000},
|
||||
"pipeline_floor_multiple": {"REFERRAL": 4, "RESELLER": 10, "OEM": 8, "SI_CONSULTING": 6, "STRATEGIC": 15},
|
||||
"deals_closed_floor": {"REFERRAL": 1, "RESELLER": 4, "OEM": 2, "SI_CONSULTING": 2, "STRATEGIC": 5},
|
||||
},
|
||||
"enterprise-software": {
|
||||
"target_accounts": {"REFERRAL": 3, "RESELLER": 8, "OEM": 6, "SI_CONSULTING": 10, "STRATEGIC": 15},
|
||||
"mdf_default": {"REFERRAL": 0, "RESELLER": 30000, "OEM": 75000, "SI_CONSULTING": 40000, "STRATEGIC": 150000},
|
||||
"pipeline_floor_multiple": {"REFERRAL": 2, "RESELLER": 5, "OEM": 4, "SI_CONSULTING": 5, "STRATEGIC": 8},
|
||||
"deals_closed_floor": {"REFERRAL": 1, "RESELLER": 2, "OEM": 1, "SI_CONSULTING": 2, "STRATEGIC": 3},
|
||||
},
|
||||
"marketplace": {
|
||||
"target_accounts": {"REFERRAL": 10, "RESELLER": 25, "OEM": 12, "SI_CONSULTING": 15, "STRATEGIC": 25},
|
||||
"mdf_default": {"REFERRAL": 0, "RESELLER": 10000, "OEM": 25000, "SI_CONSULTING": 15000, "STRATEGIC": 50000},
|
||||
"pipeline_floor_multiple": {"REFERRAL": 5, "RESELLER": 12, "OEM": 8, "SI_CONSULTING": 8, "STRATEGIC": 18},
|
||||
"deals_closed_floor": {"REFERRAL": 2, "RESELLER": 5, "OEM": 3, "SI_CONSULTING": 3, "STRATEGIC": 6},
|
||||
},
|
||||
"hardware": {
|
||||
"target_accounts": {"REFERRAL": 5, "RESELLER": 10, "OEM": 8, "SI_CONSULTING": 8, "STRATEGIC": 12},
|
||||
"mdf_default": {"REFERRAL": 0, "RESELLER": 25000, "OEM": 100000, "SI_CONSULTING": 30000, "STRATEGIC": 200000},
|
||||
"pipeline_floor_multiple": {"REFERRAL": 2, "RESELLER": 6, "OEM": 5, "SI_CONSULTING": 4, "STRATEGIC": 10},
|
||||
"deals_closed_floor": {"REFERRAL": 1, "RESELLER": 2, "OEM": 1, "SI_CONSULTING": 2, "STRATEGIC": 3},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Milestone:
|
||||
day: int
|
||||
name: str
|
||||
owner: str
|
||||
deliverable: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class GtmPlan:
|
||||
partner_name: str
|
||||
profile: str
|
||||
partner_tier: str
|
||||
sales_motion: str
|
||||
target_segments: list[str]
|
||||
joint_value_proposition: str
|
||||
pre_launch: list[Milestone] = field(default_factory=list)
|
||||
launch: list[Milestone] = field(default_factory=list)
|
||||
mid_quarter_checkpoint: list[str] = field(default_factory=list)
|
||||
success_criteria_90d: list[str] = field(default_factory=list)
|
||||
mdf_allocation_usd: float = 0.0
|
||||
target_account_count: int = 0
|
||||
pipeline_floor_usd: float = 0.0
|
||||
deals_closed_floor: int = 0
|
||||
validation_errors: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _validate(gtm: dict) -> list[str]:
|
||||
errs: list[str] = []
|
||||
tier = (gtm.get("partner_tier") or "").upper()
|
||||
motion = (gtm.get("sales_motion") or "").lower()
|
||||
if tier not in VALID_TIERS:
|
||||
errs.append(f"partner_tier '{tier}' not in {VALID_TIERS}")
|
||||
return errs
|
||||
if motion not in VALID_MOTIONS:
|
||||
errs.append(f"sales_motion '{motion}' not in {VALID_MOTIONS}")
|
||||
return errs
|
||||
if motion not in TIER_MOTION_MATRIX[tier]:
|
||||
errs.append(
|
||||
f"sales_motion '{motion}' is not compatible with tier '{tier}'. "
|
||||
f"Allowed motions for {tier}: {sorted(TIER_MOTION_MATRIX[tier])}. "
|
||||
f"If you need '{motion}', re-tier the partner via partner_tier_classifier.py first."
|
||||
)
|
||||
if not gtm.get("joint_value_proposition"):
|
||||
errs.append("joint_value_proposition is required (one sentence with end-customer)")
|
||||
if not gtm.get("target_segments"):
|
||||
errs.append("target_segments is required (named segments, not 'everyone')")
|
||||
return errs
|
||||
|
||||
|
||||
def _pre_launch_milestones(tier: str, motion: str) -> list[Milestone]:
|
||||
base = [
|
||||
Milestone(-30, "Mutual NDA + Partner Agreement signed",
|
||||
"BD lead + Legal both sides", "Signed PDFs"),
|
||||
Milestone(-25, "Joint kickoff call: name exec sponsors",
|
||||
"BD lead + Partner GM", "Sponsor pair documented"),
|
||||
Milestone(-20, "Target Account List (TAL) jointly built",
|
||||
"Sales Director + Partner Sales lead",
|
||||
"Named-account list with conflict resolution per-account"),
|
||||
Milestone(-15, "Partner sales training (week 1 of 2)",
|
||||
"Sales Enablement", "Training attendance log"),
|
||||
Milestone(-10, "Partner sales training (week 2 of 2) + certification",
|
||||
"Sales Enablement + Partner reps",
|
||||
"Named certified reps per partner"),
|
||||
]
|
||||
if tier in ("OEM", "STRATEGIC"):
|
||||
base.append(Milestone(
|
||||
-7, "Integration QA + support runbook signoff",
|
||||
"Engineering + Support both sides",
|
||||
"Joint Tier-1/Tier-2 support runbook"))
|
||||
if motion == "white_label":
|
||||
base.append(Milestone(
|
||||
-5, "Brand-use guide + co-branded asset pack approved",
|
||||
"Marketing + Legal", "Asset pack + brand-use rules"))
|
||||
if motion in ("co_sell", "channel_led"):
|
||||
base.append(Milestone(
|
||||
-3, "Rules of Engagement signed (channel conflict)",
|
||||
"Sales Director + Partner Sales lead",
|
||||
"Signed ROE with named-account map"))
|
||||
base.append(Milestone(
|
||||
0, "Joint launch announcement + first pursuit kickoff",
|
||||
"Marketing + Sales both sides",
|
||||
"Press / blog / customer-facing materials"))
|
||||
return base
|
||||
|
||||
|
||||
def _launch_milestones(tier: str, motion: str) -> list[Milestone]:
|
||||
base = [
|
||||
Milestone(7, "First joint pursuit named (single account)",
|
||||
"Sales Director + Partner Sales lead",
|
||||
"Account brief + close plan"),
|
||||
Milestone(15, "5 joint pursuits in flight",
|
||||
"Sales Director + Partner Sales lead",
|
||||
"Pipeline-sourced report"),
|
||||
Milestone(30, "First closed-won OR clear blocker isolation",
|
||||
"Sales Director + Partner Sales lead",
|
||||
"Win/Loss writeup"),
|
||||
]
|
||||
if motion == "channel_led":
|
||||
base.append(Milestone(
|
||||
20, "Partner-led demo without our SE present (validation)",
|
||||
"Partner Sales lead", "Recording + scorecard"))
|
||||
if tier == "OEM":
|
||||
base.append(Milestone(
|
||||
25, "First end-customer Tier-2 support ticket through joint runbook",
|
||||
"Support both sides", "Ticket resolution timeline"))
|
||||
return base
|
||||
|
||||
|
||||
def _mid_quarter_checkpoint(tier: str, motion: str, pipeline_floor: float) -> list[str]:
|
||||
return [
|
||||
f"Day 45: pipeline sourced ≥ 50% of 90-day floor (${pipeline_floor * 0.5:,.0f})",
|
||||
f"Day 45: at least 1 closed-won OR named blocker with owner + remediation date",
|
||||
f"Day 45: certified rep count maintained at signed-agreement level",
|
||||
f"Day 45: ROE working — no channel-conflict escalations OR all resolved at named-human level",
|
||||
f"Day 45: kill-criteria trigger review — if any, escalate to partnership committee NOW",
|
||||
]
|
||||
|
||||
|
||||
def _success_criteria(tier: str, motion: str, pipeline_floor: float, deals_floor: int) -> list[str]:
|
||||
base = [
|
||||
f"Pipeline-sourced through partner ≥ ${pipeline_floor:,.0f} (validated by both sides)",
|
||||
f"Deals closed-won through partner ≥ {deals_floor}",
|
||||
f"Joint win/loss doc covering all material deals (closed-won AND closed-lost)",
|
||||
f"Certified rep count ≥ partner-agreement level",
|
||||
f"Channel-conflict log: zero unresolved escalations",
|
||||
]
|
||||
if tier == "OEM":
|
||||
base.append("End-customer NPS via the OEM at or above corporate floor")
|
||||
base.append("Support SLA breach rate ≤ 5% of tickets")
|
||||
if tier == "STRATEGIC":
|
||||
base.append("Exec sponsor pair active (both sides — verify before quarter close)")
|
||||
base.append("Executive QBR completed with signed-off next-quarter pipeline floor")
|
||||
if motion == "channel_led":
|
||||
base.append("≥ 50% of closed-won were partner-led (not just partner-influenced)")
|
||||
if motion == "white_label":
|
||||
base.append("Embedded volume hit minimum threshold; no brand-bleed incidents")
|
||||
base.append("Decision: continue / re-tier / unwind, with named human accountable")
|
||||
return base
|
||||
|
||||
|
||||
def plan_gtm(gtm: dict, profile_name: str = "saas") -> GtmPlan:
|
||||
if profile_name not in PROFILES:
|
||||
raise ValueError(f"Unknown profile '{profile_name}'. Choose from {list(PROFILES)}.")
|
||||
profile = PROFILES[profile_name]
|
||||
|
||||
errs = _validate(gtm)
|
||||
plan = GtmPlan(
|
||||
partner_name=str(gtm.get("partner_name", "UNSPECIFIED")),
|
||||
profile=profile_name,
|
||||
partner_tier=(gtm.get("partner_tier") or "").upper(),
|
||||
sales_motion=(gtm.get("sales_motion") or "").lower(),
|
||||
target_segments=list(gtm.get("target_segments", []) or []),
|
||||
joint_value_proposition=str(gtm.get("joint_value_proposition", "")),
|
||||
validation_errors=errs,
|
||||
)
|
||||
if errs:
|
||||
return plan
|
||||
|
||||
tier = plan.partner_tier
|
||||
motion = plan.sales_motion
|
||||
deal_avg = float(gtm.get("deal_avg_size_usd", 0.0))
|
||||
|
||||
plan.target_account_count = profile["target_accounts"].get(tier, 0)
|
||||
plan.mdf_allocation_usd = float(profile["mdf_default"].get(tier, 0))
|
||||
pipe_multiple = profile["pipeline_floor_multiple"].get(tier, 0)
|
||||
plan.pipeline_floor_usd = deal_avg * pipe_multiple
|
||||
plan.deals_closed_floor = profile["deals_closed_floor"].get(tier, 0)
|
||||
|
||||
plan.pre_launch = _pre_launch_milestones(tier, motion)
|
||||
plan.launch = _launch_milestones(tier, motion)
|
||||
plan.mid_quarter_checkpoint = _mid_quarter_checkpoint(tier, motion, plan.pipeline_floor_usd)
|
||||
plan.success_criteria_90d = _success_criteria(
|
||||
tier, motion, plan.pipeline_floor_usd, plan.deals_closed_floor
|
||||
)
|
||||
|
||||
horizon = int(gtm.get("commitment_horizon_months", 12) or 12)
|
||||
if horizon < 12:
|
||||
plan.warnings.append(
|
||||
f"commitment_horizon_months={horizon} < 12: partner programs rarely produce "
|
||||
"signal in less than a full sales cycle. Consider extending or downgrading tier."
|
||||
)
|
||||
if tier == "STRATEGIC" and horizon < 24:
|
||||
plan.warnings.append(
|
||||
"STRATEGIC tier with sub-24-month horizon is structurally inconsistent; "
|
||||
"either commit multi-year or re-tier."
|
||||
)
|
||||
if deal_avg <= 0:
|
||||
plan.warnings.append(
|
||||
"deal_avg_size_usd not provided or 0 — pipeline floor cannot be computed. "
|
||||
"Re-run with a real number from your closed-won data."
|
||||
)
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
def _render_human(p: GtmPlan) -> str:
|
||||
lines = []
|
||||
lines.append(f"Joint GTM Plan: {p.partner_name}")
|
||||
lines.append(f"Profile: {p.profile} ; Tier: {p.partner_tier} ; Motion: {p.sales_motion}")
|
||||
if p.validation_errors:
|
||||
lines.append("")
|
||||
lines.append("VALIDATION ERRORS (plan not generated):")
|
||||
for e in p.validation_errors:
|
||||
lines.append(f" ! {e}")
|
||||
return "\n".join(lines)
|
||||
lines.append("")
|
||||
lines.append(f"Target segments: {'; '.join(p.target_segments)}")
|
||||
lines.append(f"Joint value prop: {p.joint_value_proposition}")
|
||||
lines.append("")
|
||||
lines.append(f"MDF allocation: ${p.mdf_allocation_usd:,.0f}")
|
||||
lines.append(f"Target accounts: {p.target_account_count}")
|
||||
lines.append(f"90-day pipeline floor: ${p.pipeline_floor_usd:,.0f}")
|
||||
lines.append(f"90-day closed-won floor: {p.deals_closed_floor}")
|
||||
lines.append("")
|
||||
lines.append("Pre-launch milestones (day -30 to 0):")
|
||||
for m in p.pre_launch:
|
||||
lines.append(f" Day {m.day:+4d} {m.name}")
|
||||
lines.append(f" owner: {m.owner}")
|
||||
lines.append(f" deliverable: {m.deliverable}")
|
||||
lines.append("")
|
||||
lines.append("Launch milestones (day 0 to 30):")
|
||||
for m in p.launch:
|
||||
lines.append(f" Day {m.day:+4d} {m.name}")
|
||||
lines.append(f" owner: {m.owner}")
|
||||
lines.append(f" deliverable: {m.deliverable}")
|
||||
lines.append("")
|
||||
lines.append("Mid-quarter checkpoint (day 45):")
|
||||
for c in p.mid_quarter_checkpoint:
|
||||
lines.append(f" - {c}")
|
||||
lines.append("")
|
||||
lines.append("90-day success criteria:")
|
||||
for s in p.success_criteria_90d:
|
||||
lines.append(f" - {s}")
|
||||
if p.warnings:
|
||||
lines.append("")
|
||||
lines.append("Warnings:")
|
||||
for w in p.warnings:
|
||||
lines.append(f" ! {w}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _to_jsonable(p: GtmPlan) -> dict:
|
||||
return asdict(p)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate a 90-day joint GTM plan for a signed partner.",
|
||||
)
|
||||
parser.add_argument("--input", help="Path to JSON GTM context")
|
||||
parser.add_argument("--profile", default="saas", choices=list(PROFILES))
|
||||
parser.add_argument("--output", default="human", choices=["human", "json", "markdown"])
|
||||
parser.add_argument("--sample", action="store_true", help="Use embedded sample GTM context")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.sample or not args.input:
|
||||
gtm = SAMPLE_GTM
|
||||
else:
|
||||
with open(args.input) as f:
|
||||
gtm = json.load(f)
|
||||
|
||||
plan = plan_gtm(gtm, args.profile)
|
||||
if args.output == "json":
|
||||
print(json.dumps(_to_jsonable(plan), indent=2))
|
||||
else:
|
||||
if args.output == "markdown":
|
||||
print("# Joint GTM Plan\n")
|
||||
print(_render_human(plan))
|
||||
return 0 if not plan.validation_errors else 0
|
||||
# Note: validation errors print to stdout; exit 0 so pipelines can capture them.
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,521 @@
|
|||
#!/usr/bin/env python3
|
||||
"""partner_tier_classifier.py - Classify a prospective partner into 1 of 5 tiers.
|
||||
|
||||
Stdlib-only. Deterministic logic with hard floors per tier. NEVER auto-signs anything;
|
||||
output is a tier verdict + rationale + kill criteria, routed to a human committee.
|
||||
|
||||
The 5 tiers:
|
||||
REFERRAL - informal intro, no joint commitment, small finder's fee
|
||||
RESELLER - transactional resale with margin, basic certification
|
||||
OEM - white-label / embedded, integration + support commitment
|
||||
SI_CONSULTING - services attach, customer-owned-by-partner
|
||||
STRATEGIC - multi-year, co-investment, dedicated resources both sides
|
||||
|
||||
Tier floors (hard requirements — failing a floor caps the tier):
|
||||
REFERRAL - none (default fallback)
|
||||
RESELLER - end_customer_relationships_pct >= 40 AND sales_team_size >= 3
|
||||
OEM - end_customer_relationships_pct >= 60 AND certification_completion AND
|
||||
commitments.dedicated_resources >= 2
|
||||
SI_CONSULTING - end_customer_relationships_pct >= 50 AND sales_team_size >= 5 AND
|
||||
partner_type in {si_consultant}
|
||||
STRATEGIC - named_accounts_sourced_count >= 5 AND multi-year-commit (>=24mo
|
||||
horizon implied by commitments) AND dedicated_resources >= 3 AND
|
||||
joint_marketing_spend >= 50000
|
||||
|
||||
Industry profiles (`--profile`) tune the thresholds:
|
||||
saas - default; the floors above
|
||||
api - bias toward technology/OEM; relax sales_team for OEM
|
||||
enterprise-software - higher SI bar (8 sales reps)
|
||||
marketplace - higher referral acceptance, lower reseller bar
|
||||
hardware - higher OEM bar (dedicated_resources 4)
|
||||
|
||||
Usage:
|
||||
python partner_tier_classifier.py --sample
|
||||
python partner_tier_classifier.py --input partner.json --profile saas
|
||||
python partner_tier_classifier.py --input partner.json --output json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Any
|
||||
|
||||
|
||||
SAMPLE_PARTNER = {
|
||||
"partner_name": "Northstar Consulting",
|
||||
"partner_type": "si_consultant",
|
||||
"independent_demand_evidence": {
|
||||
"named_accounts_sourced_count": 7,
|
||||
"end_customer_relationships_pct": 65,
|
||||
"sales_team_size": 8,
|
||||
},
|
||||
"strategic_value": {
|
||||
"geo_coverage": "EMEA + LATAM",
|
||||
"product_complement": "implementation services for our platform",
|
||||
"brand_lift": "mid",
|
||||
"channel_economics_advantage": "lower CAC in regulated verticals",
|
||||
},
|
||||
"commitments": {
|
||||
"joint_marketing_spend": 75000,
|
||||
"dedicated_resources": 4,
|
||||
"certification_completion": True,
|
||||
"sales_targets": "12 deals in 12 months",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
VALID_PARTNER_TYPES = (
|
||||
"referral", "reseller", "oem", "si_consultant", "technology", "strategic_alliance",
|
||||
)
|
||||
|
||||
|
||||
PROFILES: dict[str, dict[str, Any]] = {
|
||||
"saas": {
|
||||
"reseller_floor_ecr": 40,
|
||||
"reseller_floor_sales_team": 3,
|
||||
"oem_floor_ecr": 60,
|
||||
"oem_floor_dedicated": 2,
|
||||
"si_floor_ecr": 50,
|
||||
"si_floor_sales_team": 5,
|
||||
"strategic_floor_sourced": 5,
|
||||
"strategic_floor_dedicated": 3,
|
||||
"strategic_floor_mdf": 50000,
|
||||
},
|
||||
"api": {
|
||||
"reseller_floor_ecr": 35,
|
||||
"reseller_floor_sales_team": 2,
|
||||
"oem_floor_ecr": 50,
|
||||
"oem_floor_dedicated": 2,
|
||||
"si_floor_ecr": 50,
|
||||
"si_floor_sales_team": 5,
|
||||
"strategic_floor_sourced": 4,
|
||||
"strategic_floor_dedicated": 3,
|
||||
"strategic_floor_mdf": 40000,
|
||||
},
|
||||
"enterprise-software": {
|
||||
"reseller_floor_ecr": 50,
|
||||
"reseller_floor_sales_team": 5,
|
||||
"oem_floor_ecr": 65,
|
||||
"oem_floor_dedicated": 3,
|
||||
"si_floor_ecr": 55,
|
||||
"si_floor_sales_team": 8,
|
||||
"strategic_floor_sourced": 6,
|
||||
"strategic_floor_dedicated": 4,
|
||||
"strategic_floor_mdf": 75000,
|
||||
},
|
||||
"marketplace": {
|
||||
"reseller_floor_ecr": 30,
|
||||
"reseller_floor_sales_team": 2,
|
||||
"oem_floor_ecr": 50,
|
||||
"oem_floor_dedicated": 2,
|
||||
"si_floor_ecr": 45,
|
||||
"si_floor_sales_team": 4,
|
||||
"strategic_floor_sourced": 4,
|
||||
"strategic_floor_dedicated": 2,
|
||||
"strategic_floor_mdf": 30000,
|
||||
},
|
||||
"hardware": {
|
||||
"reseller_floor_ecr": 45,
|
||||
"reseller_floor_sales_team": 4,
|
||||
"oem_floor_ecr": 70,
|
||||
"oem_floor_dedicated": 4,
|
||||
"si_floor_ecr": 55,
|
||||
"si_floor_sales_team": 6,
|
||||
"strategic_floor_sourced": 6,
|
||||
"strategic_floor_dedicated": 4,
|
||||
"strategic_floor_mdf": 100000,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Kill criteria templates per tier — these are placed into the partnership contract
|
||||
# so the unwind is mechanical, not a 2-year legal fight.
|
||||
KILL_CRITERIA: dict[str, list[str]] = {
|
||||
"REFERRAL": [
|
||||
"No qualified intros in 2 consecutive quarters -> auto-sunset, no notice",
|
||||
"Any misrepresentation of relationship as 'partner' externally -> immediate termination",
|
||||
],
|
||||
"RESELLER": [
|
||||
"Less than 25% of agreed annual sales target hit in any quarter -> 90-day cure",
|
||||
"Two consecutive quarters under cure -> tier demotion to REFERRAL or termination",
|
||||
"Certification lapses for >60 days -> resale rights suspended",
|
||||
],
|
||||
"OEM": [
|
||||
"End-customer NPS via the OEM falls below corporate floor -> joint remediation plan",
|
||||
"Less than 50% of agreed embedded volume in 2 consecutive quarters -> cure or unwind",
|
||||
"Support response SLA breach >5% per quarter -> co-funded customer-success review",
|
||||
],
|
||||
"SI_CONSULTING": [
|
||||
"Less than 60% of agreed certified resources maintained -> 60-day cure",
|
||||
"Customer-attributed delivery failures above named threshold -> joint root-cause + remediation",
|
||||
"Loss of practice lead (named person) -> 90-day re-qualification of tier",
|
||||
],
|
||||
"STRATEGIC": [
|
||||
"Less than 70% of named pipeline floor in 2 consecutive quarters -> joint exec review",
|
||||
"Failure of the named exec sponsor on either side -> 90-day re-validation of alliance",
|
||||
"Material change of control on either side -> automatic 6-month evaluation period",
|
||||
"Loss of integration / technical interop for >30 days -> alliance pause",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TierScore:
|
||||
tier: str
|
||||
raw_score: float
|
||||
floors_passed: bool
|
||||
floors_failed: list[str]
|
||||
rationale: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClassificationVerdict:
|
||||
partner_name: str
|
||||
profile: str
|
||||
tier_assigned: str
|
||||
composite_rationale: str
|
||||
floors_failed_for_higher_tiers: list[str] = field(default_factory=list)
|
||||
tier_scores: list[TierScore] = field(default_factory=list)
|
||||
kill_criteria: list[str] = field(default_factory=list)
|
||||
next_steps: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _clamp(x: float, lo: float = 0.0, hi: float = 100.0) -> float:
|
||||
return max(lo, min(hi, x))
|
||||
|
||||
|
||||
def _check_reseller_floors(partner: dict, profile: dict) -> tuple[bool, list[str]]:
|
||||
ide = partner.get("independent_demand_evidence", {}) or {}
|
||||
ecr = float(ide.get("end_customer_relationships_pct", 0))
|
||||
sts = int(ide.get("sales_team_size", 0))
|
||||
fails: list[str] = []
|
||||
if ecr < profile["reseller_floor_ecr"]:
|
||||
fails.append(
|
||||
f"RESELLER floor: end_customer_relationships_pct {ecr:.0f}% < "
|
||||
f"{profile['reseller_floor_ecr']}%"
|
||||
)
|
||||
if sts < profile["reseller_floor_sales_team"]:
|
||||
fails.append(
|
||||
f"RESELLER floor: sales_team_size {sts} < "
|
||||
f"{profile['reseller_floor_sales_team']}"
|
||||
)
|
||||
return (len(fails) == 0, fails)
|
||||
|
||||
|
||||
def _check_oem_floors(partner: dict, profile: dict) -> tuple[bool, list[str]]:
|
||||
ide = partner.get("independent_demand_evidence", {}) or {}
|
||||
com = partner.get("commitments", {}) or {}
|
||||
ecr = float(ide.get("end_customer_relationships_pct", 0))
|
||||
dr = int(com.get("dedicated_resources", 0))
|
||||
cert = bool(com.get("certification_completion", False))
|
||||
fails: list[str] = []
|
||||
if ecr < profile["oem_floor_ecr"]:
|
||||
fails.append(
|
||||
f"OEM floor: end_customer_relationships_pct {ecr:.0f}% < "
|
||||
f"{profile['oem_floor_ecr']}%"
|
||||
)
|
||||
if dr < profile["oem_floor_dedicated"]:
|
||||
fails.append(
|
||||
f"OEM floor: dedicated_resources {dr} < {profile['oem_floor_dedicated']}"
|
||||
)
|
||||
if not cert:
|
||||
fails.append("OEM floor: certification_completion is False")
|
||||
return (len(fails) == 0, fails)
|
||||
|
||||
|
||||
def _check_si_floors(partner: dict, profile: dict) -> tuple[bool, list[str]]:
|
||||
ide = partner.get("independent_demand_evidence", {}) or {}
|
||||
ecr = float(ide.get("end_customer_relationships_pct", 0))
|
||||
sts = int(ide.get("sales_team_size", 0))
|
||||
ptype = (partner.get("partner_type") or "").lower()
|
||||
fails: list[str] = []
|
||||
if ecr < profile["si_floor_ecr"]:
|
||||
fails.append(
|
||||
f"SI_CONSULTING floor: end_customer_relationships_pct {ecr:.0f}% < "
|
||||
f"{profile['si_floor_ecr']}%"
|
||||
)
|
||||
if sts < profile["si_floor_sales_team"]:
|
||||
fails.append(
|
||||
f"SI_CONSULTING floor: sales_team_size {sts} < "
|
||||
f"{profile['si_floor_sales_team']}"
|
||||
)
|
||||
if ptype != "si_consultant":
|
||||
fails.append(
|
||||
f"SI_CONSULTING floor: partner_type is '{ptype}', expected 'si_consultant'"
|
||||
)
|
||||
return (len(fails) == 0, fails)
|
||||
|
||||
|
||||
def _check_strategic_floors(partner: dict, profile: dict) -> tuple[bool, list[str]]:
|
||||
ide = partner.get("independent_demand_evidence", {}) or {}
|
||||
com = partner.get("commitments", {}) or {}
|
||||
sourced = int(ide.get("named_accounts_sourced_count", 0))
|
||||
dr = int(com.get("dedicated_resources", 0))
|
||||
mdf = float(com.get("joint_marketing_spend", 0))
|
||||
targets = (com.get("sales_targets") or "").lower()
|
||||
fails: list[str] = []
|
||||
if sourced < profile["strategic_floor_sourced"]:
|
||||
fails.append(
|
||||
f"STRATEGIC floor: named_accounts_sourced_count {sourced} < "
|
||||
f"{profile['strategic_floor_sourced']}"
|
||||
)
|
||||
if dr < profile["strategic_floor_dedicated"]:
|
||||
fails.append(
|
||||
f"STRATEGIC floor: dedicated_resources {dr} < "
|
||||
f"{profile['strategic_floor_dedicated']}"
|
||||
)
|
||||
if mdf < profile["strategic_floor_mdf"]:
|
||||
fails.append(
|
||||
f"STRATEGIC floor: joint_marketing_spend {mdf:.0f} < "
|
||||
f"{profile['strategic_floor_mdf']}"
|
||||
)
|
||||
# Multi-year heuristic: sales_targets mentions "12 months" or longer; or commitment
|
||||
# text references multi-year / 24 / 36 months.
|
||||
multi_year_signal = any(
|
||||
s in targets for s in ("12 months", "24 months", "36 months", "multi-year", "multi year")
|
||||
)
|
||||
if not multi_year_signal:
|
||||
fails.append(
|
||||
"STRATEGIC floor: no multi-year commitment signal in sales_targets text"
|
||||
)
|
||||
return (len(fails) == 0, fails)
|
||||
|
||||
|
||||
def _strategic_raw_score(partner: dict, profile: dict) -> float:
|
||||
ide = partner.get("independent_demand_evidence", {}) or {}
|
||||
com = partner.get("commitments", {}) or {}
|
||||
sv = partner.get("strategic_value", {}) or {}
|
||||
score = 0.0
|
||||
# Sourced accounts: 0..40 points (cap at 10 sourced)
|
||||
score += min(40.0, ide.get("named_accounts_sourced_count", 0) * 4.0)
|
||||
# Dedicated resources: 0..20 points (cap at 5)
|
||||
score += min(20.0, com.get("dedicated_resources", 0) * 4.0)
|
||||
# MDF: 0..20 points (cap at 100k)
|
||||
score += min(20.0, com.get("joint_marketing_spend", 0) / 5000.0)
|
||||
# Strategic value flags: 5 points each
|
||||
for key in ("geo_coverage", "product_complement", "brand_lift", "channel_economics_advantage"):
|
||||
if sv.get(key):
|
||||
score += 5.0
|
||||
return _clamp(score)
|
||||
|
||||
|
||||
def _oem_raw_score(partner: dict, profile: dict) -> float:
|
||||
ide = partner.get("independent_demand_evidence", {}) or {}
|
||||
com = partner.get("commitments", {}) or {}
|
||||
score = 0.0
|
||||
score += min(40.0, ide.get("end_customer_relationships_pct", 0) * 0.6)
|
||||
score += min(20.0, com.get("dedicated_resources", 0) * 5.0)
|
||||
score += 20.0 if com.get("certification_completion") else 0.0
|
||||
score += min(20.0, ide.get("named_accounts_sourced_count", 0) * 3.0)
|
||||
return _clamp(score)
|
||||
|
||||
|
||||
def _si_raw_score(partner: dict, profile: dict) -> float:
|
||||
ide = partner.get("independent_demand_evidence", {}) or {}
|
||||
com = partner.get("commitments", {}) or {}
|
||||
score = 0.0
|
||||
score += min(35.0, ide.get("end_customer_relationships_pct", 0) * 0.5)
|
||||
score += min(25.0, ide.get("sales_team_size", 0) * 3.0)
|
||||
score += 15.0 if com.get("certification_completion") else 0.0
|
||||
score += min(25.0, com.get("dedicated_resources", 0) * 5.0)
|
||||
return _clamp(score)
|
||||
|
||||
|
||||
def _reseller_raw_score(partner: dict, profile: dict) -> float:
|
||||
ide = partner.get("independent_demand_evidence", {}) or {}
|
||||
score = 0.0
|
||||
score += min(50.0, ide.get("end_customer_relationships_pct", 0) * 0.8)
|
||||
score += min(40.0, ide.get("sales_team_size", 0) * 5.0)
|
||||
score += min(10.0, ide.get("named_accounts_sourced_count", 0) * 2.0)
|
||||
return _clamp(score)
|
||||
|
||||
|
||||
def _referral_raw_score(partner: dict, profile: dict) -> float:
|
||||
# Referral always passes; raw score is just "do they have any evidence of intent"
|
||||
ide = partner.get("independent_demand_evidence", {}) or {}
|
||||
score = 30.0 # baseline for showing up
|
||||
score += min(40.0, ide.get("named_accounts_sourced_count", 0) * 6.0)
|
||||
score += min(30.0, ide.get("end_customer_relationships_pct", 0) * 0.3)
|
||||
return _clamp(score)
|
||||
|
||||
|
||||
def classify(partner: dict, profile_name: str = "saas") -> ClassificationVerdict:
|
||||
if profile_name not in PROFILES:
|
||||
raise ValueError(f"Unknown profile '{profile_name}'. Choose from {list(PROFILES)}.")
|
||||
profile = PROFILES[profile_name]
|
||||
|
||||
ptype = (partner.get("partner_type") or "").lower()
|
||||
warnings: list[str] = []
|
||||
if ptype not in VALID_PARTNER_TYPES:
|
||||
warnings.append(
|
||||
f"partner_type '{ptype}' is not one of {VALID_PARTNER_TYPES}; "
|
||||
"classification continues but verify input"
|
||||
)
|
||||
|
||||
# Compute raw scores for each tier
|
||||
s_strategic = _strategic_raw_score(partner, profile)
|
||||
s_oem = _oem_raw_score(partner, profile)
|
||||
s_si = _si_raw_score(partner, profile)
|
||||
s_reseller = _reseller_raw_score(partner, profile)
|
||||
s_referral = _referral_raw_score(partner, profile)
|
||||
|
||||
# Floor checks
|
||||
p_reseller, f_reseller = _check_reseller_floors(partner, profile)
|
||||
p_oem, f_oem = _check_oem_floors(partner, profile)
|
||||
p_si, f_si = _check_si_floors(partner, profile)
|
||||
p_strategic, f_strategic = _check_strategic_floors(partner, profile)
|
||||
|
||||
scores = [
|
||||
TierScore("STRATEGIC", s_strategic, p_strategic, f_strategic,
|
||||
f"raw={s_strategic:.1f}/100 ; floors={'PASS' if p_strategic else 'FAIL'}"),
|
||||
TierScore("OEM", s_oem, p_oem, f_oem,
|
||||
f"raw={s_oem:.1f}/100 ; floors={'PASS' if p_oem else 'FAIL'}"),
|
||||
TierScore("SI_CONSULTING", s_si, p_si, f_si,
|
||||
f"raw={s_si:.1f}/100 ; floors={'PASS' if p_si else 'FAIL'}"),
|
||||
TierScore("RESELLER", s_reseller, p_reseller, f_reseller,
|
||||
f"raw={s_reseller:.1f}/100 ; floors={'PASS' if p_reseller else 'FAIL'}"),
|
||||
TierScore("REFERRAL", s_referral, True, [],
|
||||
f"raw={s_referral:.1f}/100 ; floors=PASS (default)"),
|
||||
]
|
||||
|
||||
# Assign highest tier that PASSES floors AND has raw_score >= 60.
|
||||
assigned = "REFERRAL"
|
||||
rationale = "Default fallback tier (no higher floors passed)"
|
||||
floors_blocking: list[str] = []
|
||||
tier_order = ["STRATEGIC", "OEM", "SI_CONSULTING", "RESELLER", "REFERRAL"]
|
||||
for tier_name in tier_order:
|
||||
ts = next(t for t in scores if t.tier == tier_name)
|
||||
if ts.floors_passed and (tier_name == "REFERRAL" or ts.raw_score >= 60.0):
|
||||
assigned = tier_name
|
||||
rationale = (
|
||||
f"Assigned {tier_name}: raw score {ts.raw_score:.1f}/100, all floors passed."
|
||||
)
|
||||
break
|
||||
if not ts.floors_passed:
|
||||
floors_blocking.extend(ts.floors_failed)
|
||||
elif ts.raw_score < 60.0:
|
||||
floors_blocking.append(
|
||||
f"{tier_name}: raw score {ts.raw_score:.1f}/100 below 60 minimum"
|
||||
)
|
||||
|
||||
# Next steps depend on tier
|
||||
next_steps_map = {
|
||||
"REFERRAL": [
|
||||
"Document the referral arrangement (one-page MOU, no exclusivity)",
|
||||
"Define finder's fee % (typical 5-10% of first-year ARR)",
|
||||
"Set 2-quarter review with auto-sunset trigger",
|
||||
],
|
||||
"RESELLER": [
|
||||
"Run scripts/joint_gtm_planner.py with sales_motion='co_sell' or 'channel_led'",
|
||||
"Run scripts/revshare_modeler.py to size resale margin (typical 20-35%)",
|
||||
"Draft certification curriculum and timeline",
|
||||
"Lock kill criteria in contract before signing",
|
||||
],
|
||||
"OEM": [
|
||||
"Run scripts/joint_gtm_planner.py with sales_motion='white_label'",
|
||||
"Run scripts/revshare_modeler.py with deeper revshare band (typical 40-55%)",
|
||||
"Validate support model — who answers Tier-2 calls?",
|
||||
"Lock IP / integration / brand-use terms in contract",
|
||||
"Lock kill criteria including support SLA in contract",
|
||||
],
|
||||
"SI_CONSULTING": [
|
||||
"Run scripts/joint_gtm_planner.py with sales_motion='co_sell'",
|
||||
"Run scripts/revshare_modeler.py (typical 15-25% on product, 0% on services)",
|
||||
"Define certified-practice-lead role and named individual",
|
||||
"Lock kill criteria around certification headcount",
|
||||
],
|
||||
"STRATEGIC": [
|
||||
"Verify with `c-level-advisor/ma-playbook` whether this should be acquisition not partnership",
|
||||
"Run scripts/joint_gtm_planner.py with sales_motion='channel_led' or 'co_sell'",
|
||||
"Run scripts/revshare_modeler.py with strategic-tier band",
|
||||
"Negotiate executive-sponsor pairing (named individual each side)",
|
||||
"Lock kill criteria including exec-sponsor-departure trigger",
|
||||
],
|
||||
}
|
||||
|
||||
return ClassificationVerdict(
|
||||
partner_name=str(partner.get("partner_name", "UNSPECIFIED")),
|
||||
profile=profile_name,
|
||||
tier_assigned=assigned,
|
||||
composite_rationale=rationale,
|
||||
floors_failed_for_higher_tiers=floors_blocking,
|
||||
tier_scores=scores,
|
||||
kill_criteria=KILL_CRITERIA.get(assigned, []),
|
||||
next_steps=next_steps_map.get(assigned, []),
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def _render_human(v: ClassificationVerdict) -> str:
|
||||
lines = []
|
||||
lines.append(f"Partner Classification: {v.partner_name}")
|
||||
lines.append(f"Profile: {v.profile}")
|
||||
lines.append(f"Tier Assigned: {v.tier_assigned}")
|
||||
lines.append("")
|
||||
lines.append(v.composite_rationale)
|
||||
lines.append("")
|
||||
lines.append("Tier scoring detail (high to low):")
|
||||
for ts in v.tier_scores:
|
||||
floor_status = "PASS" if ts.floors_passed else "FAIL"
|
||||
lines.append(f" - {ts.tier:14s} raw={ts.raw_score:5.1f}/100 floors={floor_status}")
|
||||
if ts.floors_failed:
|
||||
for f in ts.floors_failed:
|
||||
lines.append(f" x {f}")
|
||||
lines.append("")
|
||||
if v.floors_failed_for_higher_tiers:
|
||||
lines.append("Why not a higher tier:")
|
||||
for f in v.floors_failed_for_higher_tiers:
|
||||
lines.append(f" - {f}")
|
||||
lines.append("")
|
||||
lines.append("Kill criteria (put these in the contract):")
|
||||
for k in v.kill_criteria:
|
||||
lines.append(f" - {k}")
|
||||
lines.append("")
|
||||
lines.append("Next steps:")
|
||||
for n in v.next_steps:
|
||||
lines.append(f" - {n}")
|
||||
if v.warnings:
|
||||
lines.append("")
|
||||
lines.append("Warnings:")
|
||||
for w in v.warnings:
|
||||
lines.append(f" ! {w}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _to_jsonable(v: ClassificationVerdict) -> dict:
|
||||
return asdict(v)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Classify a prospective partner into REFERRAL / RESELLER / OEM / SI_CONSULTING / STRATEGIC tier.",
|
||||
)
|
||||
parser.add_argument("--input", help="Path to JSON partner intake")
|
||||
parser.add_argument("--profile", default="saas", choices=list(PROFILES))
|
||||
parser.add_argument("--output", default="human", choices=["human", "json", "markdown"])
|
||||
parser.add_argument("--sample", action="store_true", help="Use embedded sample partner")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.sample or not args.input:
|
||||
partner = SAMPLE_PARTNER
|
||||
else:
|
||||
with open(args.input) as f:
|
||||
partner = json.load(f)
|
||||
|
||||
verdict = classify(partner, args.profile)
|
||||
if args.output == "json":
|
||||
print(json.dumps(_to_jsonable(verdict), indent=2))
|
||||
else:
|
||||
# human and markdown share the same body; markdown adds a header
|
||||
if args.output == "markdown":
|
||||
print(f"# Partner Tier Classification\n")
|
||||
print(_render_human(verdict))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
#!/usr/bin/env python3
|
||||
"""revshare_modeler.py - Model revshare economics: direct vs via partner.
|
||||
|
||||
Stdlib-only. Deterministic. Computes:
|
||||
1. Margin per deal direct vs via partner (with named cost-to-serve inputs)
|
||||
2. Recommended revshare % band by tier + partner contribution depth
|
||||
(sourced > influenced > delivered)
|
||||
3. Break-even partner ROI — how many partner-sourced deals to cover MDF + program cost
|
||||
4. Long-term economics: at projected scale, when does partner economics beat direct?
|
||||
|
||||
Revshare bands by tier (industry-typical, can be tuned by --profile):
|
||||
REFERRAL : 5-10% on first-year ARR (one-time finder's fee)
|
||||
RESELLER : 20-35% on net ARR (recurring while customer active)
|
||||
OEM : 40-55% on net ARR (revshare reflects partner-owned support)
|
||||
SI_CONSULTING : 15-25% on first-year ARR (services attach independent)
|
||||
STRATEGIC : 25-40% on net ARR with floor + co-investment
|
||||
|
||||
Contribution depth modifies the band:
|
||||
sourced (partner-introduced, partner-owned relationship) -> top half of band
|
||||
influenced (partner accelerated, but rep ran the play) -> bottom half of band
|
||||
delivered (partner did the implementation only) -> services-side comp,
|
||||
NOT product revshare
|
||||
(refuses to apply
|
||||
product band)
|
||||
|
||||
NEVER auto-commits a revshare %. Output is a band + assumptions + break-even, routed
|
||||
to a human commercial committee.
|
||||
|
||||
Usage:
|
||||
python revshare_modeler.py --sample
|
||||
python revshare_modeler.py --input revshare.json
|
||||
python revshare_modeler.py --input revshare.json --output json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Any
|
||||
|
||||
|
||||
SAMPLE_REVSHARE = {
|
||||
"partner_name": "Northstar Consulting",
|
||||
"partner_tier": "SI_CONSULTING",
|
||||
"deal_avg_size_usd": 90000,
|
||||
"partner_contribution": "sourced",
|
||||
"our_cost_to_serve_direct_usd": 18000,
|
||||
"our_cost_to_serve_via_partner_usd": 9000,
|
||||
"mdf_annual_usd": 20000,
|
||||
"program_overhead_annual_usd": 60000,
|
||||
"ttm_arr_projection_usd": 800000,
|
||||
"deal_count_projection": 9,
|
||||
"project_years": 3,
|
||||
}
|
||||
|
||||
|
||||
VALID_TIERS = ("REFERRAL", "RESELLER", "OEM", "SI_CONSULTING", "STRATEGIC")
|
||||
VALID_CONTRIBUTIONS = ("sourced", "influenced", "delivered")
|
||||
|
||||
|
||||
# Industry-typical revshare bands. Lower bound = floor; upper bound = ceiling.
|
||||
# Tuned by `--profile`.
|
||||
TIER_BANDS: dict[str, tuple[float, float]] = {
|
||||
"REFERRAL": (5.0, 10.0),
|
||||
"RESELLER": (20.0, 35.0),
|
||||
"OEM": (40.0, 55.0),
|
||||
"SI_CONSULTING": (15.0, 25.0),
|
||||
"STRATEGIC": (25.0, 40.0),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RevshareModel:
|
||||
partner_name: str
|
||||
partner_tier: str
|
||||
partner_contribution: str
|
||||
deal_avg_size_usd: float
|
||||
direct_margin_usd: float
|
||||
direct_margin_pct: float
|
||||
via_partner_margin_usd_at_low: float
|
||||
via_partner_margin_pct_at_low: float
|
||||
via_partner_margin_usd_at_high: float
|
||||
via_partner_margin_pct_at_high: float
|
||||
recommended_revshare_low_pct: float
|
||||
recommended_revshare_high_pct: float
|
||||
breakeven_partner_sourced_deals: int
|
||||
annual_program_cost_usd: float
|
||||
ttm_arr_projection_usd: float
|
||||
ttm_revshare_payout_low_usd: float
|
||||
ttm_revshare_payout_high_usd: float
|
||||
ttm_net_to_us_low_usd: float
|
||||
ttm_net_to_us_high_usd: float
|
||||
crossover_year: int
|
||||
direct_economics_3yr_npv_usd: float
|
||||
partner_economics_3yr_npv_low_usd: float
|
||||
partner_economics_3yr_npv_high_usd: float
|
||||
assumptions: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
validation_errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _validate(rev: dict) -> list[str]:
|
||||
errs: list[str] = []
|
||||
tier = (rev.get("partner_tier") or "").upper()
|
||||
contrib = (rev.get("partner_contribution") or "").lower()
|
||||
if tier not in VALID_TIERS:
|
||||
errs.append(f"partner_tier '{tier}' not in {VALID_TIERS}")
|
||||
if contrib not in VALID_CONTRIBUTIONS:
|
||||
errs.append(f"partner_contribution '{contrib}' not in {VALID_CONTRIBUTIONS}")
|
||||
if contrib == "delivered" and tier in ("REFERRAL", "RESELLER", "OEM", "STRATEGIC"):
|
||||
errs.append(
|
||||
"partner_contribution='delivered' is services attach only — do not pay product "
|
||||
"revshare. Pay services-side comp (typical fixed services fee or hourly rate). "
|
||||
"Re-classify contribution or move to SI_CONSULTING tier with explicit services band."
|
||||
)
|
||||
if float(rev.get("deal_avg_size_usd", 0)) <= 0:
|
||||
errs.append("deal_avg_size_usd must be > 0")
|
||||
return errs
|
||||
|
||||
|
||||
def _contribution_band_shift(band: tuple[float, float], contribution: str) -> tuple[float, float]:
|
||||
"""Modify band based on contribution depth.
|
||||
sourced -> top half (mid..high)
|
||||
influenced -> bottom half (low..mid)
|
||||
delivered -> applied only at SI_CONSULTING; for SI, this is the floor band.
|
||||
"""
|
||||
low, high = band
|
||||
mid = (low + high) / 2.0
|
||||
if contribution == "sourced":
|
||||
return (mid, high)
|
||||
if contribution == "influenced":
|
||||
return (low, mid)
|
||||
# delivered (only reaches here for SI_CONSULTING per validation)
|
||||
return (low, mid)
|
||||
|
||||
|
||||
def model(rev: dict) -> RevshareModel:
|
||||
errs = _validate(rev)
|
||||
if errs:
|
||||
# Return a stub model with validation errors; nothing else computed.
|
||||
return RevshareModel(
|
||||
partner_name=str(rev.get("partner_name", "UNSPECIFIED")),
|
||||
partner_tier=(rev.get("partner_tier") or "").upper(),
|
||||
partner_contribution=(rev.get("partner_contribution") or "").lower(),
|
||||
deal_avg_size_usd=float(rev.get("deal_avg_size_usd", 0.0)),
|
||||
direct_margin_usd=0.0, direct_margin_pct=0.0,
|
||||
via_partner_margin_usd_at_low=0.0, via_partner_margin_pct_at_low=0.0,
|
||||
via_partner_margin_usd_at_high=0.0, via_partner_margin_pct_at_high=0.0,
|
||||
recommended_revshare_low_pct=0.0, recommended_revshare_high_pct=0.0,
|
||||
breakeven_partner_sourced_deals=0,
|
||||
annual_program_cost_usd=0.0,
|
||||
ttm_arr_projection_usd=0.0,
|
||||
ttm_revshare_payout_low_usd=0.0, ttm_revshare_payout_high_usd=0.0,
|
||||
ttm_net_to_us_low_usd=0.0, ttm_net_to_us_high_usd=0.0,
|
||||
crossover_year=0,
|
||||
direct_economics_3yr_npv_usd=0.0,
|
||||
partner_economics_3yr_npv_low_usd=0.0,
|
||||
partner_economics_3yr_npv_high_usd=0.0,
|
||||
validation_errors=errs,
|
||||
)
|
||||
|
||||
tier = (rev["partner_tier"] or "").upper()
|
||||
contrib = (rev["partner_contribution"] or "").lower()
|
||||
deal_avg = float(rev.get("deal_avg_size_usd", 0.0))
|
||||
cts_direct = float(rev.get("our_cost_to_serve_direct_usd", 0.0))
|
||||
cts_partner = float(rev.get("our_cost_to_serve_via_partner_usd", 0.0))
|
||||
mdf = float(rev.get("mdf_annual_usd", 0.0))
|
||||
overhead = float(rev.get("program_overhead_annual_usd", 0.0))
|
||||
ttm_arr = float(rev.get("ttm_arr_projection_usd", 0.0))
|
||||
deal_count = int(rev.get("deal_count_projection", 0) or 0)
|
||||
years = int(rev.get("project_years", 3) or 3)
|
||||
|
||||
band = TIER_BANDS[tier]
|
||||
band_low, band_high = _contribution_band_shift(band, contrib)
|
||||
|
||||
# Per-deal margin direct (no revshare; full cost-to-serve)
|
||||
direct_margin = deal_avg - cts_direct
|
||||
direct_margin_pct = (direct_margin / deal_avg * 100.0) if deal_avg else 0.0
|
||||
|
||||
# Per-deal margin via partner: deal - (revshare%) * deal - cts_partner
|
||||
via_low_payout = deal_avg * (band_low / 100.0)
|
||||
via_high_payout = deal_avg * (band_high / 100.0)
|
||||
via_low_margin = deal_avg - via_low_payout - cts_partner
|
||||
via_high_margin = deal_avg - via_high_payout - cts_partner
|
||||
via_low_pct = (via_low_margin / deal_avg * 100.0) if deal_avg else 0.0
|
||||
via_high_pct = (via_high_margin / deal_avg * 100.0) if deal_avg else 0.0
|
||||
|
||||
annual_program_cost = mdf + overhead
|
||||
|
||||
# Break-even partner-sourced deals: program cost / (direct_margin - via_partner_margin_at_HIGH)
|
||||
# The cheaper our margin via partner, the more partner-sourced deals required.
|
||||
# If via_partner margin > direct margin (rare but possible for high-CTS direct sales), break-even is 0.
|
||||
delta_per_deal = direct_margin - via_high_margin
|
||||
if delta_per_deal <= 0:
|
||||
breakeven = 0
|
||||
else:
|
||||
breakeven = int(annual_program_cost / delta_per_deal) + 1
|
||||
|
||||
# TTM economics: ttm_arr * revshare%
|
||||
ttm_payout_low = ttm_arr * (band_low / 100.0)
|
||||
ttm_payout_high = ttm_arr * (band_high / 100.0)
|
||||
ttm_net_low = ttm_arr - ttm_payout_high - (deal_count * cts_partner) - annual_program_cost
|
||||
ttm_net_high = ttm_arr - ttm_payout_low - (deal_count * cts_partner) - annual_program_cost
|
||||
# Direct equivalent at same ARR: ttm_arr - deal_count * cts_direct
|
||||
ttm_net_direct = ttm_arr - (deal_count * cts_direct)
|
||||
|
||||
# Crossover year: at what year does partner economics beat direct?
|
||||
# Heuristic: if cts_direct - cts_partner > revshare_payout / deal_avg, never
|
||||
# need partner growth; if not, year when (deals_partner * marginal_savings) >
|
||||
# (annual_program_cost) — simple linear projection over `years`.
|
||||
crossover = 0
|
||||
if cts_direct > cts_partner and deal_count > 0:
|
||||
marginal_savings_per_deal = (cts_direct - cts_partner) - via_low_payout
|
||||
if marginal_savings_per_deal > 0:
|
||||
# cumulative savings needed to cover all program cost across `years`
|
||||
cumulative_program_cost = annual_program_cost * years
|
||||
cumulative_savings_per_year = marginal_savings_per_deal * deal_count
|
||||
if cumulative_savings_per_year > 0:
|
||||
yrs = cumulative_program_cost / cumulative_savings_per_year
|
||||
crossover = max(1, int(yrs) + (1 if yrs % 1 else 0))
|
||||
else:
|
||||
crossover = 0 # marginal economics never positive at low band
|
||||
else:
|
||||
crossover = 0
|
||||
|
||||
# 3-year NPV at flat discount (we don't discount — keeping math obvious + auditable):
|
||||
direct_3yr_npv = ttm_net_direct * years
|
||||
partner_3yr_npv_low = ttm_net_low * years
|
||||
partner_3yr_npv_high = ttm_net_high * years
|
||||
|
||||
assumptions = [
|
||||
f"Tier band ({tier}): {band[0]:.0f}-{band[1]:.0f}% — contribution '{contrib}' "
|
||||
f"shifts band to {band_low:.0f}-{band_high:.0f}%.",
|
||||
"Cost-to-serve via partner assumes partner owns first-line support; we own "
|
||||
"Tier-2+. Validate this matches the contract.",
|
||||
"Revshare is paid on net ARR (post-discount), not gross list price.",
|
||||
"TTM projection assumes deal_count_projection × deal_avg_size_usd ≈ ttm_arr_projection_usd. "
|
||||
"If these are inconsistent, fix the input.",
|
||||
"No churn modeled. Partner-sourced cohorts often have +/- 10pt NRR delta vs. direct — "
|
||||
"revisit with `c-level-advisor/cco-advisor` for retention-decomposition impact.",
|
||||
f"NPV computed flat across {years} years (no discount rate). Apply your WACC manually "
|
||||
"if the partnership is balance-sheet material.",
|
||||
]
|
||||
|
||||
warnings: list[str] = []
|
||||
if via_high_margin < 0:
|
||||
warnings.append(
|
||||
f"At top of band ({band_high:.0f}% revshare + ${cts_partner:,.0f} CTS), per-deal "
|
||||
"margin is NEGATIVE. Either lower the band, lower cost-to-serve, or do not sign "
|
||||
"at this tier."
|
||||
)
|
||||
if direct_margin > via_low_margin and contrib == "influenced":
|
||||
warnings.append(
|
||||
"Direct-sale margin > via-partner margin at INFLUENCED contribution. The partner "
|
||||
"is being paid for deals that would have closed anyway. Tighten attribution rules."
|
||||
)
|
||||
if ttm_arr > 0 and breakeven > deal_count:
|
||||
warnings.append(
|
||||
f"Break-even requires {breakeven} partner-sourced deals/year; projection only "
|
||||
f"shows {deal_count}. Program is economically UNPROFITABLE at projection scale. "
|
||||
"Re-scope MDF, re-tier, or unwind."
|
||||
)
|
||||
if contrib == "delivered" and tier == "SI_CONSULTING":
|
||||
warnings.append(
|
||||
"Delivered-only contribution: pay services-side compensation (fixed fee or hourly) "
|
||||
"rather than product revshare. Apply only the floor band as a ceiling."
|
||||
)
|
||||
if tier == "STRATEGIC" and annual_program_cost < 50000:
|
||||
warnings.append(
|
||||
f"STRATEGIC tier with program cost ${annual_program_cost:,.0f}/yr is structurally "
|
||||
"under-resourced. Strategic alliances require co-investment evidence."
|
||||
)
|
||||
|
||||
return RevshareModel(
|
||||
partner_name=str(rev.get("partner_name", "UNSPECIFIED")),
|
||||
partner_tier=tier,
|
||||
partner_contribution=contrib,
|
||||
deal_avg_size_usd=deal_avg,
|
||||
direct_margin_usd=round(direct_margin, 2),
|
||||
direct_margin_pct=round(direct_margin_pct, 1),
|
||||
via_partner_margin_usd_at_low=round(via_low_margin, 2),
|
||||
via_partner_margin_pct_at_low=round(via_low_pct, 1),
|
||||
via_partner_margin_usd_at_high=round(via_high_margin, 2),
|
||||
via_partner_margin_pct_at_high=round(via_high_pct, 1),
|
||||
recommended_revshare_low_pct=round(band_low, 1),
|
||||
recommended_revshare_high_pct=round(band_high, 1),
|
||||
breakeven_partner_sourced_deals=breakeven,
|
||||
annual_program_cost_usd=round(annual_program_cost, 2),
|
||||
ttm_arr_projection_usd=round(ttm_arr, 2),
|
||||
ttm_revshare_payout_low_usd=round(ttm_payout_low, 2),
|
||||
ttm_revshare_payout_high_usd=round(ttm_payout_high, 2),
|
||||
ttm_net_to_us_low_usd=round(ttm_net_low, 2),
|
||||
ttm_net_to_us_high_usd=round(ttm_net_high, 2),
|
||||
crossover_year=crossover,
|
||||
direct_economics_3yr_npv_usd=round(direct_3yr_npv, 2),
|
||||
partner_economics_3yr_npv_low_usd=round(partner_3yr_npv_low, 2),
|
||||
partner_economics_3yr_npv_high_usd=round(partner_3yr_npv_high, 2),
|
||||
assumptions=assumptions,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def _render_human(m: RevshareModel) -> str:
|
||||
lines = []
|
||||
lines.append(f"Revshare Model: {m.partner_name}")
|
||||
lines.append(f"Tier: {m.partner_tier} ; Contribution: {m.partner_contribution}")
|
||||
if m.validation_errors:
|
||||
lines.append("")
|
||||
lines.append("VALIDATION ERRORS (model not computed):")
|
||||
for e in m.validation_errors:
|
||||
lines.append(f" ! {e}")
|
||||
return "\n".join(lines)
|
||||
lines.append("")
|
||||
lines.append("Recommended revshare band:")
|
||||
lines.append(
|
||||
f" {m.recommended_revshare_low_pct:.0f}% to {m.recommended_revshare_high_pct:.0f}% "
|
||||
f"of net ARR (tier + contribution adjusted)"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("Per-deal economics:")
|
||||
lines.append(
|
||||
f" Direct sale: ${m.deal_avg_size_usd:>10,.0f} ARR - margin "
|
||||
f"${m.direct_margin_usd:>10,.0f} ({m.direct_margin_pct:.1f}%)"
|
||||
)
|
||||
lines.append(
|
||||
f" Via partner (low): ${m.deal_avg_size_usd:>10,.0f} ARR - margin "
|
||||
f"${m.via_partner_margin_usd_at_low:>10,.0f} ({m.via_partner_margin_pct_at_low:.1f}%)"
|
||||
)
|
||||
lines.append(
|
||||
f" Via partner (high): ${m.deal_avg_size_usd:>10,.0f} ARR - margin "
|
||||
f"${m.via_partner_margin_usd_at_high:>10,.0f} ({m.via_partner_margin_pct_at_high:.1f}%)"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("Break-even program math:")
|
||||
lines.append(f" Annual program cost (MDF + overhead): ${m.annual_program_cost_usd:,.0f}")
|
||||
lines.append(
|
||||
f" Break-even partner-sourced deals/year (at top-of-band): "
|
||||
f"{m.breakeven_partner_sourced_deals}"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("Projected TTM economics:")
|
||||
lines.append(f" TTM ARR through partner: ${m.ttm_arr_projection_usd:,.0f}")
|
||||
lines.append(
|
||||
f" Revshare payout (low..high): "
|
||||
f"${m.ttm_revshare_payout_low_usd:,.0f} .. ${m.ttm_revshare_payout_high_usd:,.0f}"
|
||||
)
|
||||
lines.append(
|
||||
f" Net to us (low band..high band): "
|
||||
f"${m.ttm_net_to_us_high_usd:,.0f} .. ${m.ttm_net_to_us_low_usd:,.0f}"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("Long-term comparison (flat, no discount):")
|
||||
lines.append(f" Direct 3-yr NPV: ${m.direct_economics_3yr_npv_usd:,.0f}")
|
||||
lines.append(
|
||||
f" Partner 3-yr NPV (low..high band): "
|
||||
f"${m.partner_economics_3yr_npv_low_usd:,.0f} .. "
|
||||
f"${m.partner_economics_3yr_npv_high_usd:,.0f}"
|
||||
)
|
||||
if m.crossover_year:
|
||||
lines.append(f" Crossover year (partner > direct): year {m.crossover_year}")
|
||||
else:
|
||||
lines.append(" Crossover year: not reached within projection window")
|
||||
lines.append("")
|
||||
lines.append("Assumptions:")
|
||||
for a in m.assumptions:
|
||||
lines.append(f" - {a}")
|
||||
if m.warnings:
|
||||
lines.append("")
|
||||
lines.append("Warnings:")
|
||||
for w in m.warnings:
|
||||
lines.append(f" ! {w}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _to_jsonable(m: RevshareModel) -> dict:
|
||||
return asdict(m)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Model revshare economics: direct vs via partner.",
|
||||
)
|
||||
parser.add_argument("--input", help="Path to JSON revshare context")
|
||||
parser.add_argument("--output", default="human", choices=["human", "json", "markdown"])
|
||||
parser.add_argument("--sample", action="store_true", help="Use embedded sample revshare")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.sample or not args.input:
|
||||
rev = SAMPLE_REVSHARE
|
||||
else:
|
||||
with open(args.input) as f:
|
||||
rev = json.load(f)
|
||||
|
||||
m = model(rev)
|
||||
if args.output == "json":
|
||||
print(json.dumps(_to_jsonable(m), indent=2))
|
||||
else:
|
||||
if args.output == "markdown":
|
||||
print("# Revshare Model\n")
|
||||
print(_render_human(m))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -217,33 +217,34 @@ Marketplace plugins go **55 → 57**. Indexed skills go **313 → 328** (+13 sub
|
|||
|
||||
## 9. Build sequence
|
||||
|
||||
### Sprint 1 (this PR) — foundation
|
||||
### Sprint 1 (PR #688, MERGED 2026-05-19) — foundation ✅
|
||||
|
||||
1. Master plan doc ✓
|
||||
2. Directory scaffolding (both domains)
|
||||
3. Both orchestrator skills (`business-operations-skills`, `commercial-skills`) **fully wired** with `context: fork`
|
||||
4. Two priority sub-skills per domain **fully wired**:
|
||||
1. ✅ Master plan doc
|
||||
2. ✅ Directory scaffolding (both domains)
|
||||
3. ✅ Both orchestrator skills (`business-operations-skills`, `commercial-skills`) wired with `context: fork`
|
||||
4. ✅ Two priority sub-skills per domain wired:
|
||||
- bizops: `process-mapper`, `vendor-management`
|
||||
- commercial: `pricing-strategist`, `deal-desk`
|
||||
5. Both `cs-*-orchestrator` agents
|
||||
6. Two `/cs:bizops` and `/cs:commercial` orchestrator commands + four per-skill commands
|
||||
7. Marketplace registration for both new plugins
|
||||
8. Draft PR opened
|
||||
5. ✅ Both `cs-*-orchestrator` agents
|
||||
6. ✅ `/cs:bizops`, `/cs:commercial`, `/cs:grill-bizops`, `/cs:grill-commercial` + four per-skill commands
|
||||
7. ✅ Marketplace registration for both new plugins (57 → 59 plugins)
|
||||
|
||||
### Sprint 2 — fill out
|
||||
### Sprint 2 (THIS PR — v2.8.0 completion) — fill out ✅
|
||||
|
||||
9. Remaining 4 bizops sub-skills: capacity-planner, internal-comms, knowledge-ops, procurement-optimizer
|
||||
10. Remaining 5 commercial sub-skills: partnerships-architect, channel-economics, commercial-policy, rfp-responder, commercial-forecaster
|
||||
11. Per-skill cs-* sub-agents (cs-pricing-strategist, cs-deal-desk)
|
||||
12. Full slash-command coverage
|
||||
9. ✅ All 4 remaining bizops sub-skills: `capacity-planner` (Erlang-C), `internal-comms` (ADKAR+Kotter), `knowledge-ops` (5W2H, `context: fork`), `procurement-optimizer` (UNSPSC)
|
||||
10. ✅ All 5 remaining commercial sub-skills: `partnerships-architect` (5-tier), `channel-economics` (CTS+ROI+mix), `commercial-policy` (matrix+exception+linter), `rfp-responder` (Shipley, `context: fork`), `commercial-forecaster` (4Q-weighted + cohort + funnel-confidence)
|
||||
11. ✅ 9 new `/cs:*` slash commands (one per sub-skill)
|
||||
12. ✅ Updated plugin.json + marketplace.json with full skills arrays + expanded keywords
|
||||
13. ✅ Updated domain CLAUDE.md + README.md with all 7+8 skills
|
||||
14. ✅ Root CLAUDE.md + marketplace.json metadata bumped to v2.8.0
|
||||
|
||||
### Sprint 3 — polish
|
||||
### Sprint 3 (future, not in v2.8.0) — polish
|
||||
|
||||
13. CLAUDE.md per domain
|
||||
14. README.md per domain
|
||||
15. Bulk plugin-audit pass; karpathy-check on all tools
|
||||
16. Update root CLAUDE.md (`Project Purpose`, `Architecture Overview`, `Current Version`)
|
||||
15. Per-skill cs-* sub-agents for high-stakes skills (cs-pricing-strategist, cs-deal-desk) — only added if usage telemetry shows demand
|
||||
16. Bulk plugin-audit pass; karpathy-check on all 30 new tools (Sprint 1 + Sprint 2)
|
||||
17. Release notes for v2.8.0
|
||||
18. Codex / Gemini / Hermes cross-platform sync
|
||||
19. MkDocs docs site rebuild with new domain pages
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue