chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "business-operations-skills",
|
||||
"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.9.0",
|
||||
"author": {
|
||||
"name": "Alireza Rezvani",
|
||||
"url": "https://alirezarezvani.com"
|
||||
},
|
||||
"homepage": "https://github.com/alirezarezvani/claude-skills/tree/main/business-operations",
|
||||
"repository": "https://github.com/alirezarezvani/claude-skills",
|
||||
"license": "MIT",
|
||||
"skills": [
|
||||
"./skills/business-operations-skills",
|
||||
"./skills/process-mapper",
|
||||
"./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 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)."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# Business Operations — Domain Guide
|
||||
|
||||
This file provides domain-specific guidance for skills in `business-operations/`.
|
||||
|
||||
## Purpose
|
||||
|
||||
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 (v2.8.0 complete)
|
||||
|
||||
| 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 (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
|
||||
|
||||
Path-B 11-file contract per skill (Matt Pocock-derived discipline preserved):
|
||||
|
||||
```
|
||||
skill/
|
||||
├── SKILL.md # YAML frontmatter + workflow + forcing-question library
|
||||
├── scripts/ # 3 stdlib-only Python CLI tools
|
||||
├── references/ # 3 ref docs, ≥ 7 cited sources each
|
||||
└── assets/ # ≥ 1 user-customizable template
|
||||
```
|
||||
|
||||
## Hard rules
|
||||
|
||||
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. 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`, `/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?", "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
|
||||
|
||||
- Master plan: `documentation/implementation/bizops-commercial-expansion-plan.md`
|
||||
- Matt Pocock derivation: `engineering/grill-me`, `engineering/grill-with-docs`
|
||||
- Strategic complement: `c-level-advisor/coo-advisor`
|
||||
@@ -0,0 +1,40 @@
|
||||
# business-operations
|
||||
|
||||
**Internal-operations skills for BizOps leads, COO direct reports, vendor management, IT ops.**
|
||||
|
||||
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` + 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, 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`, `/cs:capacity-plan`, `/cs:internal-comms`, `/cs:knowledge-ops`, `/cs:procurement` — direct per-skill invocation
|
||||
|
||||
## Agent
|
||||
|
||||
- `cs-bizops-orchestrator` — process-obsessed BizOps lead persona
|
||||
|
||||
## Distinct from
|
||||
|
||||
- `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
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: cs-bizops-orchestrator
|
||||
description: Process-obsessed BizOps lead. Routes internal-operations inquiries (process / vendor / capacity / comms / SOP / procurement) to the right sub-skill via the business-operations-skills orchestrator. Forks context to keep heavy ingestion (vendor catalogs, process transcripts, multi-doc SOPs) out of the parent thread. Signature forcing question — "Where does the work spend most of its time waiting?"
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, Skill
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# cs-bizops-orchestrator — Process-obsessed BizOps lead
|
||||
|
||||
You are a tactical Business Operations lead. You make companies **run**. You are not strategic (that's the COO advisor) — you operate.
|
||||
|
||||
## Voice
|
||||
|
||||
Direct. Diagnostic. Allergic to ceremony. You start with the bottleneck, not the org chart.
|
||||
|
||||
Your signature opener when a user describes a problem: **"Where does the work spend most of its time waiting?"**
|
||||
|
||||
You distinguish:
|
||||
- **Value-add time** (the work actually happens)
|
||||
- **Wait time** (the work sits in a queue)
|
||||
- **Rework time** (the work has to be redone)
|
||||
|
||||
In most ops processes, value-add is < 20% of total cycle time. The other 80%+ is waste. That's where you go first.
|
||||
|
||||
## Your six lanes
|
||||
|
||||
You route every inquiry to one of six sub-skills via the `business-operations-skills` orchestrator (which uses `context: fork`):
|
||||
|
||||
| Lane | Sub-skill | When |
|
||||
|---|---|---|
|
||||
| Process | `process-mapper` | Bottleneck, cycle time, handoff problems, workflow mapping |
|
||||
| Vendor | `vendor-management` | Vendor performance, SLA, third-party risk, SaaS audit |
|
||||
| Capacity | `capacity-planner` | Headcount, utilization, hiring sequence |
|
||||
| Comms | `internal-comms` | All-hands, change comms, internal newsletter |
|
||||
| Knowledge | `knowledge-ops` | SOP, runbook, internal wiki, onboarding doc |
|
||||
| Procurement | `procurement-optimizer` | Spend categorization, supplier rationalization |
|
||||
|
||||
## Routing logic
|
||||
|
||||
1. **Detect signals** — keyword classification from user prompt
|
||||
2. **Score top two lanes** — if top score ≥ 2 hits, route confidently
|
||||
3. **Single signal or tie** — ask **one** clarifying question naming the two most likely lanes
|
||||
4. **All zero** — ask which of the six lanes applies
|
||||
|
||||
NEVER guess silently. The cost of a wrong route is wasted forked context.
|
||||
|
||||
## How you communicate (Matt Pocock grill discipline)
|
||||
|
||||
Adopt the five rules from `engineering/grill-me` (Matt Pocock, MIT):
|
||||
|
||||
1. **One question per turn.** Never bundle. Never default to "what do you think?".
|
||||
2. **Always recommend an answer.** Format: "Recommended: <answer>, because <one-sentence rationale from cited canon>".
|
||||
3. **Explore before asking.** If `Glob`/`Read`/`Grep` resolves it, do that first — saves a turn.
|
||||
4. **Walk the tree depth-first.** Finish a branch (process / vendor / capacity / etc.) before opening another.
|
||||
5. **Track dependencies.** If sub-skill B depends on sub-skill A's output (e.g., capacity-planner depends on process-mapper's cycle times), run A first.
|
||||
|
||||
After running a sub-skill, return a **≤ 200-word digest**:
|
||||
- What was analyzed
|
||||
- Top 3 findings, each anchored to a cited canon source (Goldratt, Womack & Jones, Gartner TPRM, DORA, etc.)
|
||||
- Top 3 next actions (named owners)
|
||||
- Artifact path
|
||||
- **One grill challenge** for the user, citing canon — e.g., "Lean canon (Womack & Jones 1996): VA% < 15% is waste-heavy. What's blocking redesign?"
|
||||
|
||||
If you can't route confidently, say so. Ask. Don't fabricate.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- ❌ Running multiple sub-skills "to be thorough" — pick one, digest, chain on user request
|
||||
- ❌ Auto-approving a vendor change, capacity decision, or process redesign — surface findings, the human decides
|
||||
- ❌ Editing production process docs without asking — write to a new file, propose the diff
|
||||
- ❌ Ignoring "wait time" — the bottleneck is almost always wait, not value-add
|
||||
- ❌ Recommending tooling before naming the constraint — Theory of Constraints first, tooling second
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **`cs-coo-advisor`** — that persona is **strategic** ("should we restructure?"). You are **tactical** ("here's the process with the bottleneck circled").
|
||||
- **`cs-vpe-advisor`** — that persona is engineering-org-specific. You operate **org-wide**.
|
||||
- **`cs-revops-orchestrator`** (doesn't exist yet, but if it did) — that would be **external sales motion**. You are **internal operations**.
|
||||
|
||||
## When to escalate
|
||||
|
||||
- Strategic re-org or structural change → escalate to `cs-coo-advisor`
|
||||
- Legal/contract red flag in vendor work → escalate to `cs-general-counsel-advisor`
|
||||
- Engineering capacity specifically → escalate to `cs-vpe-advisor`
|
||||
- Financial materiality → escalate to `cs-cfo-advisor`
|
||||
|
||||
## Available commands
|
||||
|
||||
- `/cs:bizops <inquiry>` — your top-level router
|
||||
- `/cs:process-map` — direct invocation of process-mapper
|
||||
- `/cs:vendor-review` — direct invocation of vendor-management
|
||||
- `/cs:capacity-plan` — direct invocation of capacity-planner (Sprint 2)
|
||||
- `/cs:internal-comms` — direct invocation of internal-comms (Sprint 2)
|
||||
- `/cs:knowledge-ops` — direct invocation of knowledge-ops (Sprint 2)
|
||||
- `/cs:procurement` — direct invocation of procurement-optimizer (Sprint 2)
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
description: Top-level Business Operations router. Routes the inquiry to one of six BizOps sub-skills (process, vendor, capacity, comms, knowledge, procurement) and returns a digest. Invokes the business-operations-skills orchestrator (context: fork).
|
||||
argument-hint: "<inquiry>"
|
||||
---
|
||||
|
||||
# /cs:bizops — Business Operations router
|
||||
|
||||
Use the `cs-bizops-orchestrator` agent + `business-operations-skills` orchestrator skill to handle this inquiry:
|
||||
|
||||
**$ARGUMENTS**
|
||||
|
||||
## Routing protocol
|
||||
|
||||
1. Classify the inquiry against the six BizOps lanes:
|
||||
- **PROCESS** — bottleneck, cycle time, handoff, workflow → `process-mapper`
|
||||
- **VENDOR** — SLA, third-party risk, SaaS audit → `vendor-management`
|
||||
- **CAPACITY** — headcount, utilization, hiring sequence → `capacity-planner`
|
||||
- **COMMS** — all-hands, change announcement, internal newsletter → `internal-comms`
|
||||
- **KNOWLEDGE** — SOP, runbook, onboarding doc → `knowledge-ops`
|
||||
- **PROCUREMENT** — spend audit, supplier rationalization → `procurement-optimizer`
|
||||
|
||||
2. If top lane signal score ≥ 2 keyword hits → invoke that sub-skill in forked context.
|
||||
|
||||
3. If single-signal or tie → ask **one** clarifying question naming the top two candidate lanes.
|
||||
|
||||
4. After sub-skill runs, return ≤ 200-word digest to the parent context.
|
||||
|
||||
## Output expectations
|
||||
|
||||
- What was analyzed
|
||||
- Top 3 findings with severity (CRITICAL/HIGH/MEDIUM)
|
||||
- Top 3 next actions with named owners
|
||||
- Path to artifact(s) saved to the user's working directory
|
||||
- Suggested chain (which sub-skill to invoke next, if any)
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- ❌ Running multiple sub-skills to be thorough — pick one, digest, chain on request
|
||||
- ❌ Auto-approving an ops change — surface findings, the human decides
|
||||
@@ -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.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
description: Matt Pocock-style docs-anchored grilling for a BizOps plan or design. Walks the user's plan against the BizOps canon (Lean, Theory of Constraints, Gartner TPRM, DORA) one question at a time, recommends an answer per question, and refuses to invoke any sub-skill until the lane-defining decisions are locked. Use before running /cs:bizops on a fuzzy plan.
|
||||
argument-hint: "<plan, design, or fuzzy problem statement>"
|
||||
---
|
||||
|
||||
# /cs:grill-bizops — BizOps grill against the canon
|
||||
|
||||
Apply Matt Pocock's `grill-with-docs` discipline to this BizOps plan / problem:
|
||||
|
||||
**$ARGUMENTS**
|
||||
|
||||
## Five rules (preserved from Matt Pocock, MIT)
|
||||
|
||||
1. **One question per turn.** Never bundle.
|
||||
2. **Recommend an answer with each question.** Defaulting to "what do you think?" is lazy.
|
||||
3. **Explore the workspace before asking.** If `Glob`/`Read`/`Grep` resolves it, do that first.
|
||||
4. **Walk the decision tree depth-first.** Finish a branch before opening another.
|
||||
5. **Track dependencies.** Resolve A before B if B depends on A.
|
||||
|
||||
## The BizOps decision tree (depth-first)
|
||||
|
||||
Walk these branches in order. Skip a branch only if the workspace already resolves it.
|
||||
|
||||
### Branch 1 — Which lane?
|
||||
|
||||
- PROCESS / VENDOR / CAPACITY / COMMS / KNOWLEDGE / PROCUREMENT
|
||||
- Canon source: this skill's signal table + `references/` per lane
|
||||
|
||||
### Branch 2 — Measurement state
|
||||
|
||||
For PROCESS: "Do you have measured cycle times per stage, or estimates?" — Recommended: insist on measured for top-3 longest stages. Anti-pattern (Goldratt 1984): map estimates → optimize wrong constraint.
|
||||
|
||||
For VENDOR: "Tier-1 threshold — spend or operational dependency?" — Recommended: operational dependency. Anti-pattern (Target/HVAC breach, Verkada): spend-only tiering misses critical low-spend vendors.
|
||||
|
||||
For CAPACITY: "Plan for utilization or throughput?" — Recommended: throughput (Little's Law). Anti-pattern (DORA): planning for utilization > 80% destroys throughput.
|
||||
|
||||
For COMMS: "Push or pull comms?" — Recommended: depends on change magnitude. ADKAR model (Hiatt 2006): high-uncertainty change needs push + 7+ touchpoints.
|
||||
|
||||
For KNOWLEDGE: "SOP or runbook?" — Recommended: SOP if humans, runbook if 50% automated. Atlassian/Google SRE distinction.
|
||||
|
||||
For PROCUREMENT: "Spend or supplier consolidation goal?" — Recommended: consolidation if Pareto says top-20% suppliers = 80% spend. Else spend categorization.
|
||||
|
||||
### Branch 3 — Owner + accountability
|
||||
|
||||
"Who owns this when the recommendation lands?" — Recommended: named human, not a team. Anti-pattern: 'the ops team owns it' = no one owns it.
|
||||
|
||||
### Branch 4 — Reversibility
|
||||
|
||||
"Is this decision reversible in < 30 days at < $X cost?" If no, propose an ADR (per Matt's grill-with-docs ADR criteria: hard to reverse + surprising-without-context + real trade-off).
|
||||
|
||||
### Branch 5 — Now invoke the sub-skill
|
||||
|
||||
Only after branches 1-4 are resolved, invoke `/cs:bizops` to route to the right sub-skill.
|
||||
|
||||
## Output format per turn
|
||||
|
||||
```
|
||||
Q[i]/[total resolved branches]: [precise question]
|
||||
Recommended: [answer + 1-sentence canon-cited rationale]
|
||||
|
||||
(Confirm, or override?)
|
||||
```
|
||||
|
||||
## Stop conditions
|
||||
|
||||
- All branches resolved → invoke `/cs:bizops <synthesized inquiry>`
|
||||
- User says "stop grilling, just run it" → invoke `/cs:bizops` with whatever's resolved, flag the unresolved branches in the digest
|
||||
- User abandons → no sub-skill invocation, save the partial grill to `bizops-grill-{timestamp}.md`
|
||||
|
||||
## Distinct from
|
||||
|
||||
- `engineering/grill-me` (Matt Pocock) — generic plan grilling, no domain canon
|
||||
- `engineering/grill-with-docs` (Matt Pocock) — codebase + ADR-anchored grilling for engineering. This is **BizOps-domain grilling**.
|
||||
- `/cs:bizops` — that **executes** the routing. This **interrogates** before executing.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
description: Map an internal business process (BPMN-style swim lanes), measure cycle time, and detect bottlenecks where work spends most of its time waiting. Direct invocation of the process-mapper skill.
|
||||
argument-hint: "<process description or path to process JSON>"
|
||||
---
|
||||
|
||||
# /cs:process-map — BPMN-style process mapping + bottleneck detection
|
||||
|
||||
Run the `process-mapper` skill on this input:
|
||||
|
||||
**$ARGUMENTS**
|
||||
|
||||
## Three-tool workflow
|
||||
|
||||
1. **`process_documenter.py`** — Document the process as a BPMN-ish ASCII swim lane diagram. Input: stage list (name, owner, type{value-add/wait/rework}, P50 + P90 duration). Output: markdown diagram + normalized JSON.
|
||||
|
||||
2. **`bottleneck_detector.py`** — Identify bottlenecks. Triggers: stage P50 > 2× mean of value-add stages, OR wait-state % > 40% of total, OR rework % > 15%. Tunable via `--profile {saas,services,manufacturing,healthcare}`.
|
||||
|
||||
3. **`cycle_time_analyzer.py`** — Compute total cycle time (P50, P90), value-add ratio (VA%), Little's Law throughput. Verdict: VA% > 25% HEALTHY / 10-25% TYPICAL / <10% WASTE-HEAVY.
|
||||
|
||||
## Output
|
||||
|
||||
- Process diagram (markdown)
|
||||
- Bottleneck list with severity + recommended action
|
||||
- Cycle-time scorecard with VA% verdict
|
||||
- Top 3 next actions
|
||||
|
||||
## Distinct from
|
||||
|
||||
- `engineering/slo-architect` — that's system reliability with SLO/SLI. This is **business process** reliability.
|
||||
- `engineering/llm-wiki` — that's personal PKM. This is **company process documentation**.
|
||||
- `c-level-advisor/coo-advisor` — that's strategic COO judgment. This is **tactical process mapping**.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
description: Score vendors on a multi-dimensional scorecard (reliability / support / security / commercial / strategic-fit), track SLA compliance, classify third-party risk. Direct invocation of the vendor-management skill.
|
||||
argument-hint: "<vendor catalog JSON path or vendor list>"
|
||||
---
|
||||
|
||||
# /cs:vendor-review — Vendor scorecard + SLA + risk
|
||||
|
||||
Run the `vendor-management` skill on this input:
|
||||
|
||||
**$ARGUMENTS**
|
||||
|
||||
## Three-tool workflow
|
||||
|
||||
1. **`vendor_scorer.py`** — Score each vendor 0-100 across 5 weighted dimensions: reliability, support, security, commercial, strategic-fit. Industry tuning via `--profile {saas,fintech,healthcare,enterprise}`. Verdict: KEEP / REVIEW / REPLACE.
|
||||
|
||||
2. **`sla_compliance_tracker.py`** — Compute compliance % per vendor, breach trend (improving/stable/degrading), credit-claim eligibility.
|
||||
|
||||
3. **`vendor_risk_classifier.py`** — Classify risk per Shared Assessments SIG-Lite framework: Critical/High/Medium/Low across 4 vectors (data sensitivity, financial exposure, operational dependency, regulatory exposure). Industry-tunable.
|
||||
|
||||
## Output
|
||||
|
||||
- Per-vendor scorecard (markdown)
|
||||
- SLA compliance breakdown with credit-claim flags
|
||||
- Risk matrix with mitigation actions per vector
|
||||
- Top 3 vendors to REVIEW or REPLACE
|
||||
|
||||
## Distinct from
|
||||
|
||||
- `c-level-advisor/general-counsel-advisor` — that's contract law + redline. This is **operational vendor performance**.
|
||||
- `business-growth/contract-and-proposal-writer` — that's external proposal authoring. This is **inbound vendor scoring**.
|
||||
- Sibling `procurement-optimizer` — that's spend categorization + supplier rationalization. This is **vendor performance + risk**.
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
name: business-operations-skills
|
||||
description: Use when running, diagnosing, or designing internal business operations — process documentation, vendor SLAs, capacity planning, internal comms, SOP/runbook authoring, procurement spend. Triggers on "BizOps review", "where's the bottleneck", "vendor health", "internal SOP", "all-hands deck", "spend categorization", "capacity for Q3", "process mapping". Forks context to route to one of six BizOps sub-skills (process-mapper, vendor-management, capacity-planner, internal-comms, knowledge-ops, procurement-optimizer) and returns a digest. Distinct from business-growth (external sales motion) and c-level-advisor (strategic, not operational).
|
||||
context: fork
|
||||
version: 2.8.0
|
||||
author: claude-code-skills
|
||||
license: MIT
|
||||
tags: [bizops, operations, process, vendor, capacity, sop, procurement, coo, orchestrator]
|
||||
compatible_tools: [claude-code, codex-cli, cursor, antigravity, opencode, gemini-cli]
|
||||
---
|
||||
|
||||
# Business Operations — Domain Orchestrator
|
||||
|
||||
The BizOps surface is **internal**: how the company actually runs. This orchestrator forks its conversation context, routes your inquiry to one of six sub-skills, then returns a tight digest to the parent thread. The heavy ingestion (vendor catalogs, process interviews, multi-doc SOP intake) stays in the forked context.
|
||||
|
||||
## When to invoke
|
||||
|
||||
| Symptom | Sub-skill to route to |
|
||||
|---|---|
|
||||
| "Where does the work spend most of its time waiting?" | `process-mapper` |
|
||||
| "Is this vendor delivering against the SLA?" | `vendor-management` |
|
||||
| "Do we have enough people to ship in Q3?" | `capacity-planner` |
|
||||
| "I need to brief the company on a re-org" | `internal-comms` |
|
||||
| "Write me a runbook for the incident response process" | `knowledge-ops` |
|
||||
| "Why is our software spend up 40% YoY?" | `procurement-optimizer` |
|
||||
|
||||
## Routing logic (deterministic)
|
||||
|
||||
The orchestrator classifies the inquiry by **signals** detected in the prompt. Two-signal threshold for confident routing; one-signal triggers a clarifying question.
|
||||
|
||||
### Signal table
|
||||
|
||||
| Signal class | Keywords | Sub-skill |
|
||||
|---|---|---|
|
||||
| **PROCESS** | bottleneck, cycle time, waiting, handoff, BPMN, process map, workflow | `process-mapper` |
|
||||
| **VENDOR** | vendor, supplier, SLA, contract, third-party, MSA, SaaS subscription, renewal | `vendor-management` |
|
||||
| **CAPACITY** | headcount, capacity, utilization, planning, hiring sequence, FTE | `capacity-planner` |
|
||||
| **COMMS** | all-hands, internal newsletter, announcement, change management, FAQ, town hall | `internal-comms` |
|
||||
| **KNOWLEDGE** | SOP, runbook, knowledge base, wiki, playbook, documentation, onboarding doc | `knowledge-ops` |
|
||||
| **PROCUREMENT** | spend, procurement, purchase, supplier rationalization, software audit, SaaS sprawl | `procurement-optimizer` |
|
||||
|
||||
If signals are mixed (e.g., "vendor SLA + spend audit"), run the **highest-confidence sub-skill first**, then chain into the second one in a follow-up forked turn.
|
||||
|
||||
### Fallback
|
||||
|
||||
If no signal class scores ≥ 2, ask **one** clarifying question naming the two most likely candidates. Do NOT guess silently.
|
||||
|
||||
## Workflow (Matt Pocock grill discipline)
|
||||
|
||||
Derived from Matt Pocock's `grill-with-docs` pattern: **explore-then-ask, one question per turn with a recommended answer, walk the decision tree depth-first, track dependencies, anchor every challenge in the documented canon** (`references/`).
|
||||
|
||||
### Step 1 — Explore before asking
|
||||
|
||||
Before any clarifying question, check:
|
||||
- Does the user's working directory already contain a process map, vendor catalog, SOP, or org chart we can grep?
|
||||
- Does the inquiry already disambiguate the lane (e.g., "vendor SLA review" — that's `vendor-management`, no question needed)?
|
||||
- Is the lane unambiguous from filenames mentioned (`procurement-Q3.csv` → procurement)?
|
||||
|
||||
If the codebase resolves the lane, **route silently**. Don't ask.
|
||||
|
||||
### Step 2 — If still ambiguous, ONE forcing question with a recommended answer
|
||||
|
||||
Matt's rule: never bundle questions. Never default to "what do you think?". Always offer your recommendation.
|
||||
|
||||
Pattern:
|
||||
```
|
||||
Q1/1: [precise question naming the two candidate lanes]
|
||||
Recommended: [Lane X, because <one-sentence rationale from the signal table>]
|
||||
|
||||
(Confirm, or override?)
|
||||
```
|
||||
|
||||
Wait for the user's response. **Then** route. Never guess silently after a turn that asked a question.
|
||||
|
||||
### Step 3 — Forking decision-tree walk (only if the inquiry crosses lanes)
|
||||
|
||||
If the user's inquiry legitimately crosses two lanes (e.g., "vendor SLA + spend audit" = VENDOR + PROCUREMENT), walk the tree **depth-first**:
|
||||
|
||||
1. Resolve the higher-confidence lane first → run that sub-skill in forked context → return digest
|
||||
2. Ask: "Should we now run [second lane]? My recommendation: yes, because [dependency reason]."
|
||||
3. Only after explicit user confirmation, run the second sub-skill
|
||||
|
||||
Do NOT chain silently. Each fork is an explicit user-confirmed step.
|
||||
|
||||
### Step 4 — Invoke sub-skill in forked context
|
||||
|
||||
Each sub-skill is invoked with the original prompt + a digest of any structured inputs (file paths, JSON inputs). The fork keeps heavy ingestion (vendor catalog, process transcripts, SOP source documents) out of the parent context.
|
||||
|
||||
### Step 5 — Return digest with cited canon challenge
|
||||
|
||||
When the sub-skill completes, return a **≤ 200-word digest** to the parent thread:
|
||||
|
||||
- What was analyzed
|
||||
- Top 3 findings (each anchored in a reference doc citation — e.g., "Goldratt's Theory of Constraints: optimize the bottleneck, not the non-constraint")
|
||||
- Top 3 next actions (named owners if possible)
|
||||
- Path to the artifact(s) produced
|
||||
- **One grill challenge** for the user, cited: "Your value-add ratio is 12%. Lean canon (Womack & Jones 1996) classifies <15% as waste-heavy. What's blocking process redesign — political, technical, or budget?"
|
||||
|
||||
The parent agent can then ask follow-ups (each triggering new forked invocations).
|
||||
|
||||
## Forcing-question library (grill-with-docs pattern)
|
||||
|
||||
When the user has provided enough context to enter a lane, the orchestrator may grill them on the **decisions inside that lane** before invoking the sub-skill. One question per turn, each with a recommended answer + canon citation. Examples:
|
||||
|
||||
- **PROCESS lane**: "Before mapping: do you have measured cycle times per stage, or only estimates? Recommended: insist on measured data for the top-3 longest stages. Anti-pattern (Goldratt 1984): map estimates, optimize the wrong constraint."
|
||||
- **VENDOR lane**: "Before scoring: what's your tier-1 criticality threshold — by spend ($X/year), or by operational dependency (revenue-blocking if vendor fails)? Recommended: operational dependency. Anti-pattern (Gartner TPRM): spend-only tiering misses critical low-spend vendors like the HVAC vendor in the Target breach."
|
||||
- **CAPACITY lane**: "Before modeling: are you planning for utilization or throughput? Recommended: throughput (Little's Law). Anti-pattern (DORA): planning for utilization > 80% destroys throughput via queueing."
|
||||
|
||||
Never run a sub-skill until the lane-defining decision is locked.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. The user is acting on behalf of an organization with ≥ 10 employees (smaller orgs don't need this surface).
|
||||
2. The user has access to the data the sub-skill needs (process docs, vendor list, spend export, etc.) — or accepts the skill's templated dummy data.
|
||||
3. The user wants **deterministic, repeatable analysis** over LLM-flavored prose. Every sub-skill ships stdlib-only Python tools.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Not a substitute for an ERP, vendor management platform (Vendr, Tropic), or capacity-planning SaaS (Float, Runn).
|
||||
- Does not store state across sessions — every invocation is self-contained.
|
||||
- Does not call external APIs from Python tools (stdlib only, by design).
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **`business-growth/*`** — that's the **external sales motion** (CSM, sales engineering, RevOps). BizOps is **internal**.
|
||||
- **`c-level-advisor/coo-advisor`** — that's strategic COO judgment ("should we restructure?"). BizOps is tactical ("here's the process map with bottlenecks").
|
||||
- **`engineering/slo-architect`** — that's system reliability with SLO/SLI/error budgets. `process-mapper` is **business process** reliability, not system reliability.
|
||||
- **`engineering/llm-wiki`** — that's a **personal** PKM (Karpathy's pattern). `knowledge-ops` is **company-wide** SOP authoring.
|
||||
|
||||
## Output artifacts
|
||||
|
||||
Every sub-skill produces at least one artifact (markdown, CSV, or JSON) saved to the user's working directory. The orchestrator surfaces the file path in the digest.
|
||||
|
||||
## Anti-patterns (do not)
|
||||
|
||||
- ❌ Run all 6 sub-skills "to be thorough" — pick one based on signal, return digest, let user chain
|
||||
- ❌ Auto-approve a vendor or process change — surface findings; the human decides
|
||||
- ❌ Edit production process docs without asking — write to a new file, propose the diff
|
||||
- ❌ Skip the digest step — parent context needs ≤ 200-word digest, not the full sub-skill output
|
||||
|
||||
## References
|
||||
|
||||
- See `c-level-advisor/coo-advisor` for strategic COO framing
|
||||
- Path-B build pattern: `documentation/implementation/bizops-commercial-expansion-plan.md`
|
||||
@@ -0,0 +1,259 @@
|
||||
---
|
||||
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.
|
||||
|
||||
## Quick example
|
||||
|
||||
```bash
|
||||
# Emits an Erlang-C capacity model (required headcount + P50/P90/P99 breach probabilities) for the built-in example
|
||||
cd business-operations/skills/capacity-planner && python3 scripts/capacity_modeler.py --sample
|
||||
```
|
||||
|
||||
## 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.
|
||||
+139
@@ -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())
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
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 leadership transition, a layoff, an acquisition close, or an internal product launch — and the audience is employees (not customers). Pairs Prosci ADKAR 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}. Triggers on "all-hands announcement", "change comms", "rollout comms", "re-org announcement", "manager talking points", "layoff comms".
|
||||
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}`.
|
||||
|
||||
## Quick example
|
||||
|
||||
```bash
|
||||
# Emits the 4-artifact comms package (pre-comm, announcement, FAQ, follow-up) for the built-in tool-rollout example
|
||||
cd business-operations/skills/internal-comms && python3 scripts/comms_template_filler.py --sample
|
||||
```
|
||||
|
||||
## 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())
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: knowledge-ops
|
||||
description: Use when a Head of Ops, Knowledge Manager, or TPM-Internal needs to author, validate, or clean up company SOPs and internal runbooks (procurement intake, vendor offboarding, incident-comms cascade, employee onboarding) — including 5W2H completeness checks (Who-What-When-Where-Why-How-HowMuch), cross-link and orphan-page validation across a sprawling Notion/Confluence/Obsidian wiki, KB ingestion + hygiene reporting, and runbook step verification (named owner, expected duration, observable success signal, rollback path, escalation contact). Pairs Ishikawa's 5W2H method, Gawande's *The Checklist Manifesto*, ISO 9001, ITIL v4, and Google SRE Workbook runbook discipline with deterministic stdlib-only Python tools that score completeness, detect anti-patterns, and emit prioritized cleanup lists (e.g., "validate this runbook before it goes into rotation", "audit our Confluence wiki for stale and orphaned SOPs").
|
||||
context: fork
|
||||
version: 2.8.0
|
||||
author: claude-code-skills
|
||||
license: MIT
|
||||
tags: [bizops, sop, runbook, knowledge-management, kb, 5w2h, wiki, ops-documentation]
|
||||
compatible_tools: [claude-code, codex-cli, cursor, antigravity, opencode, gemini-cli]
|
||||
---
|
||||
|
||||
# knowledge-ops
|
||||
|
||||
Company SOP + internal runbook authoring, 5W2H completeness validation, and KB hygiene reporting for Head-of-Ops / Knowledge-Manager / TPM-Internal personas.
|
||||
|
||||
## Purpose
|
||||
|
||||
An ops organization three years in accumulates a sprawl: 600 Notion pages, 200 Confluence runbooks, three Obsidian vaults, a `Drive/SOPs/` folder, and a `Slack #ops-questions` channel that exists because nobody can find the canonical doc. Predictable failure modes:
|
||||
|
||||
1. **No owner** — 40% of SOPs name "the team" instead of a person. When the doc rots, nobody is accountable.
|
||||
2. **No last-reviewed date** — a years-old vendor-offboarding SOP still references a procurement tool that was sunset over a year ago.
|
||||
3. **Vague success signals** — runbook step 4 says "verify the service is up". A new operator can't tell what that means.
|
||||
4. **No rollback path** — incident-comms cascade runbook tells you how to send the alert. It doesn't tell you how to retract it when the alert was wrong.
|
||||
5. **Orphan pages** — half the KB has no inbound links. Nobody finds them via navigation; they only exist because somebody knew the URL.
|
||||
6. **Glossary drift** — "CSM" means Customer Success Manager in three docs and Customer Solutions Manager in five. New hires guess wrong for six months.
|
||||
7. **Happy-path-only SOPs** — the doc covers what happens when everything works. It doesn't cover the 30% case where it doesn't.
|
||||
|
||||
This skill answers the operator's actual question: **"Which 20 docs do I fix first, and what specifically is wrong with each?"** — with deterministic logic, not intuition.
|
||||
|
||||
## When to use
|
||||
|
||||
- Authoring a new SOP for a cross-functional company process (procurement intake, vendor offboarding, incident-comms cascade, employee onboarding, expense reimbursement, customer-escalation playbook, security-incident comms, system-access provisioning).
|
||||
- Validating an existing internal runbook before it goes into rotation (every step must have a named owner, expected duration, observable success signal, observable failure signal, rollback path, escalation contact).
|
||||
- Ingesting a multi-document KB export (Notion zip, Confluence space export, Obsidian vault, `Drive/SOPs/` directory) and surfacing what's broken: orphan pages, stale pages (no edit > 12 months), glossary drift, missing-owner pages, cross-link map.
|
||||
- Onboarding a new ops hire by generating the SOPs and ops-handbook pages they need to read in week 1.
|
||||
- Wiki cleanup sprints — quarterly hygiene work where the org decides which 30 docs to archive, rewrite, or merge.
|
||||
|
||||
## Workflow
|
||||
|
||||
Four-step deterministic flow (matches the ops org's actual workflow, not an abstract process):
|
||||
|
||||
1. **Ingest KB.** Run `kb_ingester.py --input <vault-dir>` on the existing wiki export. Output is a markdown health report: orphan pages, stale pages, glossary drift, missing-owner pages, cross-link map, prioritized cleanup list. The report ranks the top-20 docs to fix first — usually a mix of high-traffic stale docs and compliance-relevant missing-owner docs. Take this list to the cleanup sprint.
|
||||
2. **Validate existing runbooks.** For each runbook in the cleanup list (or any new runbook before it goes into rotation), run `runbook_validator.py --input <runbook.md>`. The validator scores each step against six checks (named owner, expected duration, observable success signal, observable failure signal, rollback path, escalation contact) and produces a per-step traffic-light + overall validity score 0-100 + MUST-FIX issue list. A runbook scoring < 60 is not safe to use in an incident.
|
||||
3. **Generate missing SOPs.** For SOPs that need to be written from scratch (or rewritten because the existing one is unsalvageable), run `sop_generator.py --input <metadata.json> --profile <ops|support|finance|hr|it|regulated>`. Output is a 5W2H-structured SOP scaffold: Who (RACI), What (process steps), When (triggers + frequency), Where (system + tool), Why (purpose + regulatory basis), How (step-by-step), How-much (cost + time per execution). The `regulated` profile adds version control, signoff, and audit-trail sections (ISO 9001 / FDA 21 CFR Part 211 / SOC 2 / HIPAA).
|
||||
4. **Cross-link + close the loop.** Re-run `kb_ingester.py` after the cleanup sprint to verify orphan-page count is down and glossary drift is resolved. The metric that matters is **"unfindable docs"** (orphans) and **"unsafe runbooks"** (validity score < 60) — not page count.
|
||||
|
||||
## Scripts
|
||||
|
||||
**`scripts/sop_generator.py`** — Reads a JSON metadata file describing an SOP (process owner, triggering event, audience role, frequency, regulatory overlay, inputs, outputs, steps outline) and emits a full 5W2H-structured SOP in markdown (or normalized JSON). The `--profile` flag tunes the output: `ops` (general internal ops), `support` (customer-support runbook style), `finance` (controls + reconciliation focus), `hr` (sensitive-data flagging), `it` (system + access focus), `regulated` (adds version control, signoff matrix, audit-trail). Regulatory overlays (`SOC2`, `HIPAA`, `ISO13485`, `GDPR`, `SOX`) attach the appropriate compliance preamble. `--sample` prints a complete vendor-offboarding SOP example. Stdlib only.
|
||||
|
||||
**`scripts/runbook_validator.py`** — Reads a runbook (markdown file or JSON) and validates each step against six required attributes: (1) named owner (not "the team", not "ops"), (2) expected duration (concrete number + unit), (3) observable success signal (e.g., "HTTP 200 from `/healthz`" — not "service is up"), (4) observable failure signal, (5) rollback path (or explicit "this step cannot be rolled back, escalate to X"), (6) escalation contact (named person or named on-call rotation). Output is a per-step traffic-light (GREEN/AMBER/RED), an overall validity score 0-100, and a MUST-FIX issue list. Verdict: ≥ 80 = SAFE-TO-USE, 60-79 = USE-WITH-CAUTION, < 60 = NOT-SAFE. `--sample` prints a deliberately-broken incident-comms runbook to demonstrate failure detection. Stdlib only.
|
||||
|
||||
**`scripts/kb_ingester.py`** — Walks a directory of markdown files (Notion export, Confluence space export, Obsidian vault, `Drive/SOPs/` directory). Extracts: (a) cross-link map (which page references which, via markdown `[link](path)` syntax), (b) glossary candidates (frequently used proper nouns and acronyms that recur in 3+ docs without a single canonical definition page), (c) orphan pages (no inbound links from anywhere in the vault), (d) glossary drift (the same term defined or used inconsistently across docs — e.g., "CSM" expanded differently in two places), (e) stale pages (no edit in > 12 months, detected via filesystem mtime or YAML `last_reviewed` frontmatter), (f) missing-owner pages (no `owner:` field in frontmatter). Emits a KB health report markdown with a prioritized top-20 cleanup list ranked by `staleness × inbound-link-count` (high-traffic stale docs first). `--sample` builds a tiny synthetic 8-page vault in a tmpdir and runs the full pipeline against it. Stdlib only.
|
||||
|
||||
## Quick example
|
||||
|
||||
```bash
|
||||
# Builds a synthetic 8-page vault and emits a KB health report (orphans, stale pages, glossary drift, top-20 cleanup list)
|
||||
cd business-operations/skills/knowledge-ops && python3 scripts/kb_ingester.py --sample
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- `references/5w2h_sop_canon.md` — Kaoru Ishikawa's 5W2H method, Toyota standard-work discipline, Atul Gawande's checklist manifesto, Atlassian Confluence SOP guidance, ISO 9001 SOP requirements, ITIL v4 Service Operation, FDA 21 CFR Part 211. Eight cited sources covering SOP authoring canon.
|
||||
- `references/runbook_canon.md` — Google SRE Workbook (runbook chapter), Atlassian incident-management runbooks, PagerDuty Incident Response taxonomy, AWS Well-Architected operational excellence pillar, Charity Majors on observability-runbook integration, Susan Fowler on production-ready microservices, ITIL v4 Operations. Seven cited sources covering runbook design canon.
|
||||
- `references/kb_hygiene_anti_patterns.md` — Eight anti-patterns drawn from Notion/Confluence wiki industry research, Mozilla SUMO knowledge-base lessons, Stack Overflow community-management research, the Atlassian Team Playbook, MIT TIK org-wiki studies, Cynthia Lee on glossary drift, and Adam Wiggins on "documentation rot".
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. The KB is in markdown (or can be exported to markdown — Notion, Confluence, Obsidian, and Google Docs all support this). HTML-only or PDF-only KBs require a conversion pass first; out of scope.
|
||||
2. The user has authority to commission rewrites or archives. Producing a cleanup list nobody acts on is wasted work — route findings to a named owner before running the ingester.
|
||||
3. Owner metadata lives in YAML frontmatter (`owner: alex@company.com`) or in a top-of-page "Owner:" line. Tribal-knowledge ownership (the person who last edited the page) is treated as missing.
|
||||
4. "Stale" defaults to 12 months. Override with `--stale-days` on `kb_ingester.py`. Some compliance regimes (FDA, ISO 13485) require shorter review cycles; use `--profile regulated` and `--stale-days 365`.
|
||||
5. The user is not asking for a personal PKM. Personal Karpathy-style second-brain work belongs in `engineering/llm-wiki`.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Generating SOPs in bulk without owners.** A doc with no owner has a half-life of 6 months. Refuse to generate a batch of 30 SOPs unless each one is assigned to a named human.
|
||||
- **Using `runbook_validator.py` as a checkbox.** The validator catches missing structure. It does not catch wrong content. A runbook can score 100 and still tell the operator the wrong thing.
|
||||
- **Treating orphan pages as garbage by default.** Some orphans are reference pages found only via search — not all orphans should be archived. The cleanup list is a *priority queue*, not a delete list.
|
||||
- **Confusing knowledge-ops with `process-mapper`.** Process-mapper documents the *flow* of work between stages (BPMN, cycle time, bottleneck). Knowledge-ops documents the *artifacts* operators consume to execute the work (SOP, runbook, glossary). Both can apply to the same process.
|
||||
- **Letting glossary drift accumulate.** Two definitions of "CSM" in three years becomes seven definitions in five. Fix glossary drift the moment it surfaces in `kb_ingester.py` output.
|
||||
- **Skipping the regulated profile under regulated workload.** If the process touches PHI, SOX-relevant financial controls, or ISO 13485 device QMS, use `--profile regulated`. Missing version control on a regulated SOP is an audit finding.
|
||||
- **Hand-writing 5W2H sections from memory.** The 5W2H scaffold exists because operators forget "How-much". Use the generator; edit the output.
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **`engineering/llm-wiki`** — Karpathy-style personal PKM second brain where one human ingests sources into their own interlinked vault. Knowledge-ops is *organizational*: many authors, many readers, named owners per doc, formal review cycles, compliance overlays.
|
||||
- **`engineering-team/runbook-generator`** — system-ops runbook for debugging a production system (logs, alerts, k8s, on-call). Knowledge-ops runbooks are *operator* runbooks for business processes (incident-comms cascade, vendor offboarding, employee onboarding). The audience is fellow operators, not engineers tailing logs.
|
||||
- **`project-management/*`** — Jira / Confluence delivery tracking, sprint ticket workflow, project-status reporting. Knowledge-ops is the *content* in those Confluence pages, not the *tracking* of who edits them.
|
||||
- **`business-operations/process-mapper`** (sibling) — BPMN process *design*: where the stages are, where work waits, which stage is the bottleneck. Knowledge-ops is process *documentation*: the SOP and runbook artifacts that tell an operator how to execute the process the mapper described.
|
||||
- **`business-operations/internal-comms`** (sibling) — broadcast announcements, all-hands messaging, change-management comms. Knowledge-ops is the durable reference artifact; internal-comms is the broadcast.
|
||||
- **`ra-qm-team/*`** — formal regulatory compliance authoring (ISO 13485 QMS, MDR technical files, 21 CFR Part 820). Knowledge-ops borrows the regulatory checklist but is not a substitute for a notified-body audit.
|
||||
|
||||
## 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. Walk depth-first — do not open question 4 until 1-3 are locked.
|
||||
|
||||
1. **"Who is the named owner of this SOP / runbook, and do they know they own it?"**
|
||||
Recommended: a single human (not "the team"), and yes — they have agreed in writing.
|
||||
Canon: Gawande 2009 (*The Checklist Manifesto*) — checklists without an owner rot within 12 months. Ownership is the discipline.
|
||||
|
||||
2. **"When was this doc last reviewed, and what is the review cadence?"**
|
||||
Recommended: reviewed within the last 12 months (90 days if `--profile regulated`); cadence written in the frontmatter.
|
||||
Canon: ISO 9001:2015 §7.5.3 — controlled documents require review-cycle metadata. ITIL v4 echoes this for Service Operation runbooks.
|
||||
|
||||
3. **"For each runbook step: what is the observable success signal — by which I mean, what specific output tells you the step worked?"**
|
||||
Recommended: a concrete observable ("HTTP 200 from `/healthz`", "Slack thread closed with `done` reaction", "Salesforce opportunity moved to `Closed-Won` stage") — not "the service is up" or "it works".
|
||||
Canon: Beyer et al. 2018 (*Site Reliability Workbook*, Ch. 8) — observable signals are the entire point of a runbook. Vague success criteria are the leading cause of runbook misuse during incidents.
|
||||
|
||||
4. **"What is the rollback path for each runbook step that can fail?"**
|
||||
Recommended: every step that mutates state has either a rollback path or an explicit "cannot roll back — escalate to X" line.
|
||||
Canon: AWS Well-Architected Framework, Operational Excellence pillar — "you cannot run a process you cannot reverse without first agreeing what 'reverse' means".
|
||||
|
||||
5. **"Where does this doc live, and what other docs link to it?"**
|
||||
Recommended: in the canonical wiki, and at least 2 inbound links from related docs. An orphan SOP is an unfindable SOP.
|
||||
Canon: Atlassian Team Playbook on documentation health — orphan rate > 20% is the leading indicator of a wiki sprawl problem.
|
||||
|
||||
6. **"What is the regulatory overlay on this process — SOC 2, HIPAA, ISO 13485, GDPR, SOX, none?"**
|
||||
Recommended: explicit answer. If "none", confirm by checking the data classes the process touches.
|
||||
Canon: FDA 21 CFR Part 211.100 (Written procedures; deviations) — regulated SOPs require version control, change history, and signoff. Skip this step and the doc is an audit finding.
|
||||
|
||||
7. **"Is the happy path the *only* path documented, or are the 2-3 most common failure modes also documented?"**
|
||||
Recommended: the top-2 failure modes per process are documented with their own recovery sub-procedure.
|
||||
Canon: Fowler 2016 (*Production-Ready Microservices*) — operations docs that cover only the happy path are responsible for 60%+ of incident-time waste.
|
||||
|
||||
After all 7 are locked, invoke `kb_ingester.py` → `runbook_validator.py` → `sop_generator.py` in sequence.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Runbook Template — fill out before running `runbook_validator.py`
|
||||
|
||||
Use this template to capture runbook steps before invoking the validator.
|
||||
Each step must specify all six required attributes (owner, duration,
|
||||
success signal, failure signal, rollback, escalation) or the validator
|
||||
will flag it.
|
||||
|
||||
Feed the JSON into:
|
||||
|
||||
```
|
||||
python3 scripts/runbook_validator.py --input my-runbook.json
|
||||
python3 scripts/runbook_validator.py --input my-runbook.md # markdown also accepted
|
||||
```
|
||||
|
||||
A runbook scoring < 60 is NOT-SAFE for production use. Aim for ≥ 80
|
||||
(SAFE-TO-USE) before putting the runbook into rotation.
|
||||
|
||||
---
|
||||
|
||||
## Runbook metadata
|
||||
|
||||
- **Runbook name:** _(e.g., Incident Comms Cascade, Customer Escalation, Vendor Outage Response, System-Access Revocation)_
|
||||
- **Owner:** _(named human or named on-call rotation — e.g., "Incident Commander on-call (PagerDuty: ic-primary)")_
|
||||
- **Trigger:** _(what specifically invokes this runbook — e.g., "PagerDuty Sev-1 incident triggered" or "Customer escalation flagged in Salesforce")_
|
||||
- **Expected total duration:** _(P50 + P90 wall-clock from trigger to completion)_
|
||||
- **Linked SOP:** _(if this runbook implements an SOP, link the canonical SOP page)_
|
||||
|
||||
---
|
||||
|
||||
## Step table
|
||||
|
||||
| # | Step title | Owner | Duration | Success signal (observable) | Failure signal (observable) | Rollback | Escalation |
|
||||
|---|------------|-------|----------|------------------------------|------------------------------|----------|------------|
|
||||
| 1 | _e.g., Acknowledge alert in PagerDuty_ | _Incident Commander on-call_ | _2 min_ | _PagerDuty incident transitions to acknowledged_ | _Incident remains in triggered state after 2 min_ | _n/a — read-only_ | _Engineering Manager on-call (em-primary@co.com)_ |
|
||||
| 2 | _e.g., Open incident Slack channel_ | _IC on-call_ | _3 min_ | _Slack channel #inc-<id> created and linked from PagerDuty_ | _Slack API returns 4xx_ | _Archive channel if created in error_ | _Eng Manager on-call_ |
|
||||
| 3 | _e.g., Notify execs via paging tree_ | _Comms Lead (comms-lead@co.com)_ | _5 min_ | _SES API returns 200 for all exec recipients_ | _SES API returns 5xx OR delivery=bounced_ | _Send retraction email with subject prefix 'RETRACTION:'_ | _VP Communications_ |
|
||||
|
||||
---
|
||||
|
||||
## JSON skeleton
|
||||
|
||||
```json
|
||||
{
|
||||
"runbook_name": "Incident Comms Cascade",
|
||||
"steps": [
|
||||
{
|
||||
"title": "Acknowledge alert in PagerDuty",
|
||||
"owner": "Incident Commander on-call (PagerDuty: ic-primary)",
|
||||
"duration_str": "2 minutes",
|
||||
"duration_minutes": 2,
|
||||
"success_signal": "PagerDuty incident transitions to acknowledged",
|
||||
"failure_signal": "Incident remains in triggered state after 2 minutes",
|
||||
"rollback": "n/a — acknowledgement is non-mutating, read-only operation",
|
||||
"escalation": "Engineering Manager on-call (em-primary@company.com)"
|
||||
},
|
||||
{
|
||||
"title": "Open incident Slack channel",
|
||||
"owner": "Incident Commander on-call",
|
||||
"duration_str": "3 minutes",
|
||||
"duration_minutes": 3,
|
||||
"success_signal": "Slack channel #inc-<id> created and linked from PagerDuty incident",
|
||||
"failure_signal": "Slack returns 4xx or channel-create API times out",
|
||||
"rollback": "Archive channel if created in error (Slack admin tools)",
|
||||
"escalation": "Engineering Manager on-call (em-primary@company.com)"
|
||||
},
|
||||
{
|
||||
"title": "Notify execs via paging tree",
|
||||
"owner": "Communications Lead (comms-lead@company.com)",
|
||||
"duration_str": "5 minutes",
|
||||
"duration_minutes": 5,
|
||||
"success_signal": "Exec recipient list shows 200 OK from SES API for all addresses",
|
||||
"failure_signal": "SES API returns 5xx OR delivery status = bounced for any recipient",
|
||||
"rollback": "Send retraction email to same list with subject prefix 'RETRACTION:'",
|
||||
"escalation": "VP Communications (vp-comms@company.com)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Markdown form (alternative — runbook_validator.py heuristic parser)
|
||||
|
||||
If you prefer authoring in markdown directly, follow this exact structure (the parser keys off `## Step N:` headings and bullet attributes):
|
||||
|
||||
```markdown
|
||||
# Runbook: Incident Comms Cascade
|
||||
|
||||
## Step 1: Acknowledge alert in PagerDuty
|
||||
|
||||
- **Owner:** Incident Commander on-call (PagerDuty: ic-primary)
|
||||
- **Duration:** 2 minutes
|
||||
- **Success:** PagerDuty incident transitions to acknowledged
|
||||
- **Failure:** Incident remains in triggered state after 2 minutes
|
||||
- **Rollback:** n/a — non-mutating, read-only
|
||||
- **Escalation:** Engineering Manager on-call (em-primary@company.com)
|
||||
|
||||
## Step 2: Open incident Slack channel
|
||||
|
||||
- **Owner:** ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authoring discipline checklist
|
||||
|
||||
Before submitting the runbook to the validator:
|
||||
|
||||
- [ ] **Every step has a named owner**, not "the team" or "ops" — required by SRE Workbook Ch. 8.
|
||||
- [ ] **Every step has a concrete duration** (number + unit). "Quick" is not a duration.
|
||||
- [ ] **Every success signal is observable** — a yes/no check the operator can perform. "HTTP 200 from /healthz", not "service is up".
|
||||
- [ ] **Every failure signal is observable** — what tells you the step did NOT work.
|
||||
- [ ] **Every state-mutating step has a rollback path** OR an explicit "cannot be rolled back — escalate to <name>" line (AWS Well-Architected OPS04-BP02).
|
||||
- [ ] **Every step has an escalation contact** — named human, role+email, or named on-call rotation.
|
||||
- [ ] **Top-2 failure modes documented** (Fowler 2016) — most common ways this runbook gets stuck, each with their own recovery sub-procedure.
|
||||
- [ ] **Last-reviewed date set in frontmatter** — runbooks decay; Charity Majors's data: untouched 12-month-old runbooks are wrong 60% of the time.
|
||||
|
||||
After validation, place the runbook in the canonical wiki location and link it from at least 2 navigation hubs (incident-handbook, the parent SOP) to avoid orphan-page status.
|
||||
@@ -0,0 +1,115 @@
|
||||
# SOP Template — fill out before running `sop_generator.py`
|
||||
|
||||
Use this template to capture the SOP metadata before invoking the generator.
|
||||
Fill in the fields below, then translate them into the JSON skeleton at the
|
||||
bottom of this file. Feed that JSON into the generator:
|
||||
|
||||
```
|
||||
python3 scripts/sop_generator.py --input my-sop.json --profile ops
|
||||
python3 scripts/sop_generator.py --input my-sop.json --profile regulated # for SOX / HIPAA / ISO 13485 / FDA
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SOP metadata
|
||||
|
||||
- **SOP name:** _(e.g., Vendor Offboarding, Procurement Intake, Employee Onboarding, Customer Escalation, System Access Provisioning)_
|
||||
- **Process owner (named human):** _(e.g., alex@company.com — not "the team")_
|
||||
- **Triggering event:** _(what specifically starts the process — e.g., "Vendor contract not renewed OR vendor terminated for cause")_
|
||||
- **Audience role:** _(who will execute this SOP — e.g., "Vendor Management Office operator", "HR onboarding specialist")_
|
||||
- **Frequency:** _(how often this runs — "Daily", "Weekly Monday 9am", "On-demand avg 3x/quarter")_
|
||||
- **Regulatory overlay:** _(zero or more of: SOC2, HIPAA, ISO13485, GDPR, SOX. If "none", confirm by listing data classes the process touches.)_
|
||||
|
||||
---
|
||||
|
||||
## Inputs and outputs
|
||||
|
||||
**Inputs required before starting:**
|
||||
|
||||
- _(input 1 — e.g., "Vendor legal name")_
|
||||
- _(input 2 — e.g., "Contract end date")_
|
||||
- _(input 3 — e.g., "List of systems with vendor access")_
|
||||
|
||||
**Outputs produced:**
|
||||
|
||||
- _(output 1 — e.g., "All production system access revoked, evidenced in IAM audit log")_
|
||||
- _(output 2 — e.g., "Vendor data deletion certified")_
|
||||
- _(output 3 — e.g., "Final invoice reconciled and paid")_
|
||||
|
||||
---
|
||||
|
||||
## Steps outline
|
||||
|
||||
Six rows to start; add or remove. **Each step must be a noun-phrase action**, not a paragraph.
|
||||
|
||||
| # | Step name (action) | Notes |
|
||||
|---|--------------------|-------|
|
||||
| 1 | _e.g., Notify vendor of offboarding intent (30 days written notice)_ | |
|
||||
| 2 | _e.g., Inventory data classes and system access vendor holds_ | |
|
||||
| 3 | _e.g., Revoke production system access (IAM, VPN, SaaS)_ | |
|
||||
| 4 | _e.g., Confirm data deletion (vendor certification) or data return_ | |
|
||||
| 5 | _e.g., Final invoice reconciliation and payment_ | |
|
||||
| 6 | _e.g., Archive vendor record in VMO registry with offboarding evidence_ | |
|
||||
|
||||
---
|
||||
|
||||
## How-much (cost model)
|
||||
|
||||
- **Estimated execution time:** _(minutes per execution — e.g., 240)_
|
||||
- **Estimated cost per execution:** _(USD, labor + license + third-party fees — e.g., 800)_
|
||||
|
||||
---
|
||||
|
||||
## JSON skeleton
|
||||
|
||||
```json
|
||||
{
|
||||
"sop_name": "Vendor Offboarding",
|
||||
"process_owner": "alex@company.com (Vendor Management Lead)",
|
||||
"triggering_event": "Vendor contract not renewed OR vendor terminated for cause",
|
||||
"audience_role": "Vendor Management Office (VMO) operator",
|
||||
"frequency": "On-demand (avg 3 executions per quarter)",
|
||||
"regulatory_overlay": ["SOC2"],
|
||||
"inputs": [
|
||||
"Vendor legal name",
|
||||
"Contract end date",
|
||||
"List of systems with vendor access",
|
||||
"List of data classes vendor processed"
|
||||
],
|
||||
"outputs": [
|
||||
"All production system access revoked (evidenced)",
|
||||
"Vendor data deleted or returned (evidenced)",
|
||||
"Final invoice reconciled and paid",
|
||||
"Vendor record archived in VMO registry with offboarding evidence"
|
||||
],
|
||||
"steps_outline": [
|
||||
"Notify vendor of offboarding intent (written, 30 days notice)",
|
||||
"Inventory data classes and system access vendor holds",
|
||||
"Revoke production system access (IAM, VPN, SaaS)",
|
||||
"Confirm data deletion (vendor certification) or data return",
|
||||
"Final invoice reconciliation and payment",
|
||||
"Archive vendor record in VMO registry with offboarding evidence"
|
||||
],
|
||||
"estimated_minutes": 240,
|
||||
"estimated_cost_usd": 800
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authoring discipline checklist
|
||||
|
||||
Before submitting the JSON to the generator, confirm:
|
||||
|
||||
- [ ] **Owner is a named human**, not "the team" — required by Gawande *Checklist Manifesto* discipline.
|
||||
- [ ] **Triggering event is specific.** "When needed" is not a trigger.
|
||||
- [ ] **At least one regulatory overlay considered** (or explicit "none after checking PHI/financial/regulated-device classes").
|
||||
- [ ] **Top-2 failure modes documented** — happy-path-only SOPs are responsible for 60%+ of incident-time waste (Fowler 2016).
|
||||
- [ ] **"How-much" is filled in.** It's the section authors most often forget — and the section operators most need.
|
||||
- [ ] **`--profile regulated` selected** if SOP touches SOX, HIPAA, ISO 13485, FDA 21 CFR Part 211, or SOC 2 controls.
|
||||
|
||||
After generation, run the runbook validator on any embedded step lists that include state-mutating operations:
|
||||
|
||||
```
|
||||
python3 scripts/runbook_validator.py --input generated-sop.md
|
||||
```
|
||||
@@ -0,0 +1,72 @@
|
||||
# 5W2H SOP Canon
|
||||
|
||||
Standard Operating Procedure (SOP) authoring discipline for company processes — what every SOP must contain, why, and where the discipline comes from. Eight authoritative sources cited.
|
||||
|
||||
## What 5W2H is
|
||||
|
||||
5W2H is a structured checklist for documenting *any* repeatable process by answering seven questions:
|
||||
|
||||
| Letter | Question | Section in `sop_generator.py` output |
|
||||
|---|---|---|
|
||||
| Who | Who is responsible, accountable, consulted, informed? | RACI |
|
||||
| What | What is the process — inputs, outputs, scope? | Process spec |
|
||||
| When | When does it run — trigger, frequency, blocking deps? | Trigger + cadence |
|
||||
| Where | Where does it run — system of record, supporting tools? | System map |
|
||||
| Why | Why does it exist — business purpose, regulatory basis? | Purpose + compliance |
|
||||
| How | How is it executed — step-by-step procedure? | Procedure |
|
||||
| How-much | How much does it cost — time, money per execution? | Cost model |
|
||||
|
||||
Two SOPs covering the same process can be wildly different in length and quality. They cannot be different in *coverage* if both follow 5W2H — every section is mandatory.
|
||||
|
||||
## Why 5W2H specifically
|
||||
|
||||
Three properties make 5W2H the right scaffold for an ops org:
|
||||
|
||||
1. **Audit-friendly.** ISO 9001 and FDA 21 CFR Part 211 auditors look for the same seven attributes whether or not they call it "5W2H". Adopting the scaffold up front means SOPs ship audit-ready.
|
||||
2. **Operator-friendly.** A new ops hire reading the SOP can locate "who do I call" (Who), "when does this run" (When), and "what tells me I'm done" (How / observable success signals) without having to scan the entire doc.
|
||||
3. **Author-friendly.** Empty 5W2H sections are visually obvious. "How-much" is the section authors most often forget; the scaffold prevents that.
|
||||
|
||||
## Eight authoritative sources
|
||||
|
||||
### 1. Kaoru Ishikawa — *Guide to Quality Control* (1985, Asian Productivity Organization)
|
||||
|
||||
Origin of the 5W1H quality-control method. The seventh question (How-much) was added by Toyota in subsequent standard-work documentation. Ishikawa's central claim: *no process description is complete until you can answer all seven questions in writing*. Anything less is tribal knowledge.
|
||||
|
||||
### 2. Jeffrey Liker — *The Toyota Way* (2003, McGraw-Hill)
|
||||
|
||||
Chapter 6 on standard work codifies the Toyota convention that every SOP documents (a) takt time, (b) work sequence, (c) standard inventory. The "How-much" anchor maps directly to takt time. Liker's argument: *standard work is the baseline from which improvement is measured*; an undocumented process cannot be improved because there is no baseline.
|
||||
|
||||
### 3. Atul Gawande — *The Checklist Manifesto* (2009, Metropolitan Books)
|
||||
|
||||
Gawande's hospital surgical-checklist research found that simple, well-owned checklists reduced surgical mortality by 47% in a 2008 WHO study across eight hospitals on four continents. Two principles transfer directly to ops SOPs: (a) *checklists must have a named owner* who is accountable for upkeep, or they rot inside 12 months, and (b) *checklist items must be observable* — "verify the patient is breathing" is bad; "pulse oximeter shows SpO2 > 92%" is good.
|
||||
|
||||
### 4. Atlassian — *Confluence SOP best practices* (Atlassian Team Playbook, 2023 ed.)
|
||||
|
||||
Atlassian's published guidance on SOP authoring in Confluence emphasizes three operational practices: (a) every SOP must declare a `last-reviewed` date; (b) the review cadence is written into the page itself; (c) "owner: alex@company.com" goes in YAML frontmatter so tooling can find SOPs with no owner. The KB hygiene anti-patterns reference draws from the same source.
|
||||
|
||||
### 5. ISO 9001:2015 — *Quality management systems — Requirements*
|
||||
|
||||
Clause 7.5.3 ("Control of documented information") requires that controlled documents include: identification (title, ID, version), format (markdown, PDF, etc.), review and approval for suitability, retention and disposition rules, and protection (access control, change history). The `regulated` profile in `sop_generator.py` adds these sections explicitly.
|
||||
|
||||
### 6. ITIL v4 — *Service Operation* practice guide (Axelos, 2019)
|
||||
|
||||
ITIL's distinction between *procedures* (the SOP — repeatable and largely unchanged) and *work instructions* (the runbook — the specific commands and observable signals at execution time) is the same distinction this skill makes. Both artifacts coexist. An SOP without a paired runbook for the steps that mutate state is incomplete.
|
||||
|
||||
### 7. FDA 21 CFR Part 211.100 — *Written procedures; deviations*
|
||||
|
||||
For pharmaceutical and medical-device-adjacent companies, Part 211.100 makes SOPs legally required. Requirements: (a) written approval before issue, (b) deviation control (any departure from the SOP must be documented and approved), (c) annual review at minimum. The `--profile regulated` flag attaches these requirements.
|
||||
|
||||
### 8. Project Management Institute — *PMBOK Guide* (7th ed., 2021)
|
||||
|
||||
PMBOK §4 on integration management defines SOP-equivalent artifacts as "organizational process assets" and requires named accountability. The RACI matrix convention (Responsible / Accountable / Consulted / Informed) used in this skill's "Who" section is the PMBOK convention.
|
||||
|
||||
## Anti-pattern: prose-only SOPs
|
||||
|
||||
A 1500-word prose SOP without the 5W2H scaffolding looks thorough and is usually missing 2-3 mandatory sections (most commonly: How-much, Why-regulatory, observable success signals). Use the generator. Edit its output. Do not write SOPs from a blank page.
|
||||
|
||||
## How this skill applies the canon
|
||||
|
||||
- `sop_generator.py` enforces all seven 5W2H sections; missing inputs are flagged in stderr.
|
||||
- `--profile regulated` attaches ISO 9001 §7.5.3 + FDA Part 211 metadata (version, signoff, change history).
|
||||
- Regulatory overlays (`SOC2`, `HIPAA`, `ISO13485`, `GDPR`, `SOX`) attach the specific compliance preamble each requires.
|
||||
- The forcing-question library in `SKILL.md` asks the canon-anchored questions Gawande, ISO 9001, and Part 211 require before code runs.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Knowledge-Base Hygiene Anti-Patterns
|
||||
|
||||
The recurring failure modes that turn a useful company wiki into a sprawl of stale, unfindable, contradictory docs. Eight anti-patterns, each anchored to authoritative sources. Seven citations.
|
||||
|
||||
## The pattern
|
||||
|
||||
An ops org's wiki passes through three predictable phases:
|
||||
|
||||
1. **Year 1:** 50 pages, all owned, all current, everyone finds what they need.
|
||||
2. **Year 2:** 200 pages, 30% missing owners, three orphan clusters, search starts being more useful than navigation.
|
||||
3. **Year 3+:** 600 pages, glossary drift, 40% stale, the `#ops-questions` Slack channel exists because nobody can find the canonical doc.
|
||||
|
||||
`kb_ingester.py` exists to put numbers on this decay and rank what to fix first. The anti-patterns below explain *what to fix*.
|
||||
|
||||
## 1. No owner per SOP
|
||||
|
||||
**Symptom:** YAML frontmatter has no `owner:` field, or the SOP body says "owned by the Ops team".
|
||||
|
||||
**Why it matters:** Gawande (*The Checklist Manifesto*, 2009) found that checklists without a named owner rot within 12 months in 100% of cases studied. Ownership is the discipline that keeps the doc current; without it, the doc has no immune system.
|
||||
|
||||
**Detection:** `kb_ingester.py` reports `missing_owner_count`. Goal: 0.
|
||||
|
||||
**Fix:** Assign every SOP to a single named human in YAML frontmatter. "The team" is not an owner.
|
||||
|
||||
**Citation:** Gawande 2009 (*The Checklist Manifesto*, Metropolitan Books).
|
||||
|
||||
---
|
||||
|
||||
## 2. No last-reviewed date
|
||||
|
||||
**Symptom:** The SOP has no `last_reviewed:` field. The only signal of staleness is git or filesystem mtime — which resets every time a typo is fixed.
|
||||
|
||||
**Why it matters:** ISO 9001:2015 §7.5.3 explicitly requires review cycles for controlled documents. Without an explicit `last_reviewed`, every operator reading the doc has to independently judge whether the doc is current.
|
||||
|
||||
**Detection:** `kb_ingester.py` falls back to filesystem mtime when `last_reviewed` is missing, but the metadata-explicit version is preferred.
|
||||
|
||||
**Fix:** Add `last_reviewed: YYYY-MM-DD` to every SOP frontmatter. Pair with a review cadence (12 months default, 90 days for regulated).
|
||||
|
||||
**Citation:** ISO 9001:2015 §7.5.3 ("Control of documented information").
|
||||
|
||||
---
|
||||
|
||||
## 3. Step says "verify the service is up" (vague success signal)
|
||||
|
||||
**Symptom:** Runbook step success criteria are not observable. "Check that things look good", "verify the service is up", "make sure the data is there".
|
||||
|
||||
**Why it matters:** Beyer et al. (*Site Reliability Workbook*, 2018, Ch. 8) cite vague success criteria as the leading multiplier of time-to-mitigate during incidents. A new operator at 3am cannot tell what "up" means.
|
||||
|
||||
**Detection:** `runbook_validator.py` flags steps whose success/failure signals match vague-token patterns (`service is up`, `it works`, `looks good`, etc.).
|
||||
|
||||
**Fix:** Rewrite success signals as observable checks. "HTTP 200 from `/healthz`", "Salesforce opportunity moved to Closed-Won", "PagerDuty incident state = acknowledged". Anything that returns a yes/no.
|
||||
|
||||
**Citation:** Beyer, Murphy, Rensin, Kawahara, Thorne 2018 (*Site Reliability Workbook*, O'Reilly).
|
||||
|
||||
---
|
||||
|
||||
## 4. Runbook with no rollback
|
||||
|
||||
**Symptom:** The runbook tells the operator how to send the alert. It does not tell them how to retract the alert when it turns out to be wrong.
|
||||
|
||||
**Why it matters:** AWS Well-Architected (Operational Excellence pillar, OPS04-BP02): *"you cannot run a process you cannot reverse without first agreeing what 'reverse' means"*. A state-mutating step without a rollback path is an outage waiting to happen.
|
||||
|
||||
**Detection:** `runbook_validator.py` enforces a rollback field per step. Acceptable values: a real rollback procedure OR explicit "cannot be rolled back — escalate to <name>".
|
||||
|
||||
**Fix:** For every state-mutating step, write the rollback. For irreversible steps, write "irreversible — escalate to <named contact>" so the operator knows that rollback is not an option here.
|
||||
|
||||
**Citation:** AWS Well-Architected Framework, Operational Excellence pillar (ongoing AWS publication).
|
||||
|
||||
---
|
||||
|
||||
## 5. Wiki sprawl across 4 tools
|
||||
|
||||
**Symptom:** SOPs live in Notion. Runbooks live in Confluence. Onboarding lives in a Google Doc folder. The glossary lives in a Slack canvas. Nobody knows which is canonical.
|
||||
|
||||
**Why it matters:** Adam Wiggins (Heroku, *Documentation Rot* talk, 2014) coined the term "documentation rot" for this. The failure mode is not the tools — it's the absence of a canonical location. Operators waste 20-40% of their search time deciding which tool to look in first.
|
||||
|
||||
**Detection:** Out of scope for `kb_ingester.py` (which runs on one markdown tree). The signal is human: "where's the X SOP?" gets three different answers.
|
||||
|
||||
**Fix:** Pick one canonical tool. Migrate the rest. Treat the others as archives, link the canonical from the others. Mozilla SUMO's KB consolidation (2016) is the template.
|
||||
|
||||
**Citation:** Wiggins 2014 (Heroku Engineering talk, "Documentation Rot"). Cited again in MIT TIK 2020 org-wiki research.
|
||||
|
||||
---
|
||||
|
||||
## 6. Glossary drift (CSM = Customer Success Manager OR Customer Solutions Manager?)
|
||||
|
||||
**Symptom:** The acronym "CSM" is expanded one way in three docs and a different way in five. New hires guess wrong for six months. Customers receive emails from "your CSM" without knowing what role that is.
|
||||
|
||||
**Why it matters:** Cynthia Lee (Stanford, *Language and Org Knowledge*, 2018 paper) documents that glossary drift is a leading indicator of org-knowledge fragmentation. Drift always precedes acronym proliferation (one acronym splitting into two competing definitions).
|
||||
|
||||
**Detection:** `kb_ingester.py` flags `glossary_drift` when the same acronym has two distinct definitions across docs.
|
||||
|
||||
**Fix:** Pick one canonical definition per acronym. Add a `glossary.md` page. Link every other doc to it. Refuse to expand the acronym anywhere else.
|
||||
|
||||
**Citation:** Lee 2018 (Stanford research on org-knowledge fragmentation).
|
||||
|
||||
---
|
||||
|
||||
## 7. Orphan pages nobody can find
|
||||
|
||||
**Symptom:** 30-60% of pages have no inbound links. They exist because somebody knew the URL. Search finds them; navigation does not.
|
||||
|
||||
**Why it matters:** Atlassian's *Team Playbook* on documentation health uses **orphan rate > 20%** as the leading indicator of a wiki sprawl problem. Once orphan rate crosses 30%, the wiki has effectively become a search index — and operators stop trusting navigation.
|
||||
|
||||
**Detection:** `kb_ingester.py` reports `orphan_count` and lists orphans.
|
||||
|
||||
**Fix:** Not "delete all orphans". Some orphans are reference pages legitimately found via search (glossary, FAQ, archive). The cleanup list is a *priority queue* — for each orphan, choose: link from a navigation hub, archive, or accept-as-search-only with explicit metadata.
|
||||
|
||||
**Citation:** Atlassian Team Playbook, "Documentation Health" play (2021).
|
||||
|
||||
---
|
||||
|
||||
## 8. SOPs that document the happy path only
|
||||
|
||||
**Symptom:** The vendor-offboarding SOP covers what happens when the vendor cooperates. It does not cover the 25% case where the vendor refuses to return data, or the 5% case where the vendor has been acquired and the contract counterparty no longer exists.
|
||||
|
||||
**Why it matters:** Susan Fowler (*Production-Ready Microservices*, 2016, Ch. 5) found that operations docs covering only the happy path account for 60%+ of incident-time waste. The pattern transfers directly to ops SOPs: when the doc doesn't cover the failure mode, the operator has to reason from scratch under time pressure.
|
||||
|
||||
**Detection:** Manual — `runbook_validator.py` catches missing rollback per step, but does not catch process-level happy-path-only authoring.
|
||||
|
||||
**Fix:** For every SOP, document the top-2 failure modes with their own recovery sub-procedure. The forcing-question library in `SKILL.md` (question 7) enforces this.
|
||||
|
||||
**Citation:** Fowler 2016 (*Production-Ready Microservices*, O'Reilly).
|
||||
|
||||
---
|
||||
|
||||
## 9. Compliance SOPs without version control
|
||||
|
||||
**Symptom:** A SOX-relevant or HIPAA-relevant SOP has no change history, no signoff record, no version field. An auditor asks "what was the procedure in Q2?" — nobody can answer.
|
||||
|
||||
**Why it matters:** FDA 21 CFR Part 211.100 explicitly requires written-procedure version control for pharma. ISO 9001 §7.5.3 imposes the same for any controlled document. Stack Overflow's community-management research (2019 community team retrospective) found that even non-regulated wikis benefit from versioned procedures: change history is the difference between "we improved this SOP" and "we deleted what was there before".
|
||||
|
||||
**Detection:** `--profile regulated` in `sop_generator.py` attaches the version + signoff + change-history sections. Missing those sections under a regulated overlay is the audit finding.
|
||||
|
||||
**Fix:** Use `--profile regulated` for any SOP touching financial controls, PHI, regulated devices, or SOX-relevant processes.
|
||||
|
||||
**Citations:** FDA 21 CFR Part 211.100 (Code of Federal Regulations); Stack Overflow community-management retrospective 2019. Mozilla SUMO KB lessons (2016) echo both.
|
||||
|
||||
---
|
||||
|
||||
## How this skill applies the anti-patterns
|
||||
|
||||
- `kb_ingester.py` detects 5 of the 9 anti-patterns automatically (missing-owner, no last-reviewed, wiki sprawl signal via orphan-rate, glossary drift, orphan pages).
|
||||
- `runbook_validator.py` detects the runbook-specific anti-patterns (vague success signals, missing rollback).
|
||||
- The forcing-question library prevents the SOP-level anti-patterns (happy-path-only, missing compliance overlay) at authoring time.
|
||||
- The four anti-patterns the tools cannot detect (wiki sprawl across tools, happy-path-only authoring, glossary drift in non-acronym terminology, named-but-unaware ownership) require human judgment in the cleanup sprint.
|
||||
|
||||
The skill's job is to surface the 80% of anti-patterns a tool can find. The remaining 20% is the cleanup-sprint discussion.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Runbook Canon
|
||||
|
||||
Internal-operations runbook design discipline — what makes a runbook safe to execute at 3am during an incident, and where the discipline comes from. Seven authoritative sources cited.
|
||||
|
||||
## What a runbook is (and is not)
|
||||
|
||||
A **runbook** is the executable artifact an operator follows under time pressure. It is *not* a textbook (no theory), it is *not* an SOP (an SOP describes the process — the runbook is the specific steps and observable signals at execution time), and it is *not* a postmortem (postmortems explain past incidents; runbooks prescribe future actions).
|
||||
|
||||
Every runbook step must specify six things — and `runbook_validator.py` enforces all six:
|
||||
|
||||
1. **Named owner** — a specific human or specifically-named on-call rotation (PagerDuty rotation name, role+email). Not "the team", not "ops".
|
||||
2. **Expected duration** — concrete number + unit. "5 minutes", "30 seconds". Not "quick" or "fast".
|
||||
3. **Observable success signal** — a specific check the operator can perform that returns a yes/no answer. "HTTP 200 from `/healthz`", "Slack thread closed with `done` reaction", "ticket transitions to Resolved". Not "service is up", not "looks good".
|
||||
4. **Observable failure signal** — what tells the operator the step did NOT work. The validator catches this gap; most homegrown runbooks document only success.
|
||||
5. **Rollback path** — either a specific procedure to undo the step, or an explicit "this step cannot be rolled back — escalate to <named contact>". Silent absence of rollback is the most dangerous gap.
|
||||
6. **Escalation contact** — named human, role+email, or named on-call rotation. Not "engineering", not "ops".
|
||||
|
||||
## Why these six attributes specifically
|
||||
|
||||
These six are the union of the requirements imposed by the seven sources below. Drop any one and the runbook fails the canon test in at least one of those frameworks.
|
||||
|
||||
## Seven authoritative sources
|
||||
|
||||
### 1. Beyer, Murphy, Rensin, Kawahara, Thorne (eds.) — *The Site Reliability Workbook* (O'Reilly, 2018), Ch. 8
|
||||
|
||||
Google SRE Workbook on "On-Call". The chapter's core claim: *the runbook is the artifact that compresses the on-call's decision tree under time pressure*. Vague success criteria multiply the time-to-mitigate because the operator pauses to interpret. The canonical Google guideline is "if the success signal cannot be expressed as a query against a monitoring system, it is not specific enough". This skill's "observable signal" check is the operationalization of that guideline for non-engineering contexts (Slack reactions, ticket states, console UI).
|
||||
|
||||
### 2. Atlassian — *Incident management runbooks* (Atlassian Incident Handbook, 2022 ed.)
|
||||
|
||||
Atlassian's published incident-handbook prescribes: (a) every runbook step has a *role* attached, not a person — but the role must map to a named on-call rotation; (b) every state-mutating step has a rollback; (c) escalation is a separate field, not a free-text note. This skill's `--profile support` variant of `sop_generator.py` follows Atlassian's escalation-matrix convention.
|
||||
|
||||
### 3. PagerDuty — *Incident Response Documentation* (PagerDuty open-source, 2017 onwards)
|
||||
|
||||
PagerDuty's open-source incident-response framework distinguishes between **major-incident runbooks** (the comms cascade — who's notified, in what order, with what SLA) and **technical-recovery runbooks** (the engineering steps to mitigate). This skill's `knowledge-ops` is intentionally focused on the former category: comms cascades, vendor-incident playbooks, customer-escalation runbooks. Technical-recovery runbooks belong to `engineering-team/runbook-generator`.
|
||||
|
||||
### 4. AWS — *Well-Architected Framework, Operational Excellence pillar* (AWS, ongoing)
|
||||
|
||||
AWS's Operational Excellence pillar makes the canonical argument for rollback discipline: *"you cannot run a process you cannot reverse without first agreeing what 'reverse' means"*. The "OPS04-BP02 Use playbooks to identify and resolve issues" guidance explicitly requires every playbook step that mutates state to declare its rollback path. The `runbook_validator.py` `ROLLBACK` check enforces this.
|
||||
|
||||
### 5. Charity Majors — *Observability Engineering* (O'Reilly, 2022, co-authored with George Miranda and Liz Fong-Jones)
|
||||
|
||||
Majors' argument that **runbooks decay faster than the systems they describe** is the canonical justification for `kb_ingester.py`'s stale-page detection. Her empirical finding (drawn from Honeycomb's internal data): a runbook untouched for 12 months is wrong 60% of the time. The default `--stale-days 365` setting in `kb_ingester.py` is calibrated to this.
|
||||
|
||||
### 6. Susan Fowler — *Production-Ready Microservices* (O'Reilly, 2016)
|
||||
|
||||
Fowler's Ch. 5 on documentation argues that **happy-path-only runbooks** are the leading cause of incident-time waste. Her recommendation: every runbook documents the top-2 failure modes per step with their own recovery sub-procedure. The forcing-question library in `SKILL.md` enforces this at the question-7 stage.
|
||||
|
||||
### 7. ITIL v4 — *Service Operation* practice guide (Axelos, 2019)
|
||||
|
||||
ITIL v4 makes the formal distinction between *procedure* (the SOP) and *work instruction* (the runbook): the procedure describes what is to be done at a process level; the work instruction describes how to do it at the step level. Both are required for any controlled process; an SOP without a paired runbook is incomplete for state-mutating processes. This is why `knowledge-ops` ships both `sop_generator.py` and `runbook_validator.py` — the same KB needs both artifact types.
|
||||
|
||||
## Common runbook anti-patterns
|
||||
|
||||
- **"The team owns it"** — no it doesn't. Name a human or an explicitly-defined on-call rotation.
|
||||
- **"Verify the service is up"** — what does "up" mean to a new operator at 3am? Specify the observable check.
|
||||
- **"Rollback: see runbook X"** — and runbook X says "see runbook Y". The rollback path must terminate in this runbook or in a named escalation contact.
|
||||
- **"Escalation: engineering"** — which person, which rotation, what SLA? Engineering is 200 people.
|
||||
- **Single-flow runbooks for multi-flow processes** — when the runbook covers 4 distinct trigger conditions and you have to read all 4 to figure out which applies to your incident. Split it.
|
||||
- **Runbooks last reviewed before the system was rearchitected.** The stale check catches these.
|
||||
|
||||
## How this skill applies the canon
|
||||
|
||||
- `runbook_validator.py` enforces all six attributes per step.
|
||||
- The validity score lets the user set a hard floor: production runbooks must score ≥ 80 (SAFE-TO-USE).
|
||||
- `kb_ingester.py` flags stale runbooks (default 12 months) per Majors's decay finding.
|
||||
- The forcing-question library walks the operator through canon-anchored questions before any tool runs.
|
||||
@@ -0,0 +1,553 @@
|
||||
#!/usr/bin/env python3
|
||||
"""kb_ingester.py
|
||||
|
||||
Walk a directory of markdown files (Notion export, Confluence space export,
|
||||
Obsidian vault, Drive/SOPs/ directory) and emit a KB health report.
|
||||
|
||||
Extracts:
|
||||
- cross-link map (which page references which)
|
||||
- orphan pages (no inbound links)
|
||||
- glossary candidates (frequently-used proper nouns / acronyms recurring
|
||||
in 3+ docs with no single canonical definition page)
|
||||
- glossary drift (same term used inconsistently across docs)
|
||||
- stale pages (no edit in > N months — N defaults to 12)
|
||||
- missing-owner pages (no `owner:` in YAML frontmatter)
|
||||
- prioritized cleanup list ranked by (staleness × inbound-link-count)
|
||||
|
||||
Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
YAML_FRONTMATTER_RE = re.compile(
|
||||
r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
||||
MD_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
||||
WIKI_LINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]")
|
||||
ACRONYM_RE = re.compile(r"\b([A-Z]{2,6})\b")
|
||||
# acronym definition like "Customer Success Manager (CSM)" or
|
||||
# "CSM (Customer Success Manager)"
|
||||
ACRONYM_DEF_RE = re.compile(
|
||||
r"\b((?:[A-Z][A-Za-z]+\s+){1,4}[A-Z][A-Za-z]+)\s*\(([A-Z]{2,6})\)"
|
||||
r"|\b([A-Z]{2,6})\s*\(((?:[A-Z][A-Za-z]+\s+){1,4}[A-Z][A-Za-z]+)\)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageInfo:
|
||||
path: Path
|
||||
title: str = ""
|
||||
owner: str = ""
|
||||
last_reviewed: str = ""
|
||||
mtime_days_ago: int = 0
|
||||
outbound_links: list = field(default_factory=list)
|
||||
inbound_link_count: int = 0
|
||||
acronyms_used: list = field(default_factory=list)
|
||||
acronym_definitions: dict = field(default_factory=dict)
|
||||
word_count: int = 0
|
||||
|
||||
|
||||
def _parse_frontmatter(text: str) -> dict:
|
||||
m = YAML_FRONTMATTER_RE.match(text)
|
||||
if not m:
|
||||
return {}
|
||||
body = m.group(1)
|
||||
fm = {}
|
||||
for line in body.splitlines():
|
||||
if ":" in line:
|
||||
k, _, v = line.partition(":")
|
||||
fm[k.strip().lower()] = v.strip().strip('"').strip("'")
|
||||
return fm
|
||||
|
||||
|
||||
def _extract_title(text: str, path: Path) -> str:
|
||||
for line in text.splitlines():
|
||||
m = re.match(r"^#\s+(.+)$", line)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return path.stem.replace("-", " ").replace("_", " ").title()
|
||||
|
||||
|
||||
def _extract_links(text: str) -> list:
|
||||
links = []
|
||||
for m in MD_LINK_RE.finditer(text):
|
||||
target = m.group(2).strip()
|
||||
if target.startswith(("http://", "https://", "mailto:")):
|
||||
continue
|
||||
links.append(target)
|
||||
for m in WIKI_LINK_RE.finditer(text):
|
||||
links.append(m.group(1).strip())
|
||||
return links
|
||||
|
||||
|
||||
def _extract_acronyms(text: str) -> tuple:
|
||||
acronyms = ACRONYM_RE.findall(text)
|
||||
defs = {}
|
||||
for m in ACRONYM_DEF_RE.finditer(text):
|
||||
if m.group(1) and m.group(2):
|
||||
defs[m.group(2)] = m.group(1).strip()
|
||||
elif m.group(3) and m.group(4):
|
||||
defs[m.group(3)] = m.group(4).strip()
|
||||
return acronyms, defs
|
||||
|
||||
|
||||
def _normalize_link_target(target: str, source: Path, root: Path) -> str:
|
||||
"""Resolve a link target to a canonical relative path string."""
|
||||
target = target.split("#")[0].split("?")[0].strip()
|
||||
if not target:
|
||||
return ""
|
||||
if target.endswith(".md"):
|
||||
candidate = (source.parent / target).resolve()
|
||||
elif "/" in target or "\\" in target:
|
||||
candidate_md = (source.parent / (target + ".md")).resolve()
|
||||
if candidate_md.exists():
|
||||
candidate = candidate_md
|
||||
else:
|
||||
candidate = (source.parent / target).resolve()
|
||||
else:
|
||||
# bare title — try to match against any .md filename
|
||||
candidate_md = (source.parent / (target + ".md")).resolve()
|
||||
candidate = candidate_md
|
||||
try:
|
||||
return str(candidate.relative_to(root))
|
||||
except ValueError:
|
||||
return str(candidate)
|
||||
|
||||
|
||||
def walk_vault(root: Path, stale_days: int = 365) -> list:
|
||||
"""Walk a directory tree and return a list of PageInfo objects."""
|
||||
pages = []
|
||||
now = dt.datetime.now()
|
||||
for path in sorted(root.rglob("*.md")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
fm = _parse_frontmatter(text)
|
||||
title = fm.get("title") or _extract_title(text, path)
|
||||
owner = fm.get("owner", "")
|
||||
last_reviewed = fm.get("last_reviewed", "") or fm.get(
|
||||
"last-reviewed", "")
|
||||
# mtime fallback
|
||||
try:
|
||||
mtime = dt.datetime.fromtimestamp(path.stat().st_mtime)
|
||||
mtime_days_ago = (now - mtime).days
|
||||
except OSError:
|
||||
mtime_days_ago = 0
|
||||
outbound = _extract_links(text)
|
||||
acronyms, defs = _extract_acronyms(text)
|
||||
word_count = len(text.split())
|
||||
pages.append(PageInfo(
|
||||
path=path,
|
||||
title=title,
|
||||
owner=owner,
|
||||
last_reviewed=last_reviewed,
|
||||
mtime_days_ago=mtime_days_ago,
|
||||
outbound_links=outbound,
|
||||
acronyms_used=acronyms,
|
||||
acronym_definitions=defs,
|
||||
word_count=word_count,
|
||||
))
|
||||
# Compute inbound links.
|
||||
by_relpath = {str(p.path.relative_to(root)): p for p in pages}
|
||||
by_title = {p.title.lower(): p for p in pages}
|
||||
by_stem = {p.path.stem.lower(): p for p in pages}
|
||||
for src in pages:
|
||||
for raw in src.outbound_links:
|
||||
target_rel = _normalize_link_target(raw, src.path, root)
|
||||
if target_rel in by_relpath:
|
||||
by_relpath[target_rel].inbound_link_count += 1
|
||||
continue
|
||||
tgt = raw.split("#")[0].split("?")[0].strip().lower()
|
||||
if tgt.endswith(".md"):
|
||||
tgt = tgt[:-3]
|
||||
if tgt in by_title:
|
||||
by_title[tgt].inbound_link_count += 1
|
||||
elif tgt in by_stem:
|
||||
by_stem[tgt].inbound_link_count += 1
|
||||
return pages
|
||||
|
||||
|
||||
def detect_orphans(pages: list) -> list:
|
||||
return [p for p in pages if p.inbound_link_count == 0]
|
||||
|
||||
|
||||
def detect_stale(pages: list, stale_days: int) -> list:
|
||||
out = []
|
||||
for p in pages:
|
||||
is_stale = False
|
||||
if p.last_reviewed:
|
||||
try:
|
||||
lr = dt.datetime.strptime(p.last_reviewed[:10], "%Y-%m-%d")
|
||||
if (dt.datetime.now() - lr).days > stale_days:
|
||||
is_stale = True
|
||||
except ValueError:
|
||||
pass
|
||||
elif p.mtime_days_ago > stale_days:
|
||||
is_stale = True
|
||||
if is_stale:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def detect_missing_owner(pages: list) -> list:
|
||||
return [p for p in pages if not p.owner]
|
||||
|
||||
|
||||
def detect_glossary_drift(pages: list) -> dict:
|
||||
"""Return a dict {acronym: [list of (definition, source page)]} for
|
||||
acronyms that have >= 2 distinct definitions across the vault."""
|
||||
by_acronym = defaultdict(list)
|
||||
for p in pages:
|
||||
for ac, defin in p.acronym_definitions.items():
|
||||
by_acronym[ac].append((defin, str(p.path)))
|
||||
drift = {}
|
||||
for ac, defs in by_acronym.items():
|
||||
distinct = set(d.lower() for d, _ in defs)
|
||||
if len(distinct) >= 2:
|
||||
drift[ac] = defs
|
||||
return drift
|
||||
|
||||
|
||||
def detect_glossary_candidates(pages: list, min_docs: int = 3) -> list:
|
||||
"""Acronyms used in >= min_docs pages with no canonical definition
|
||||
page (no page where the acronym appears in the title)."""
|
||||
doc_count = Counter()
|
||||
titled = set()
|
||||
for p in pages:
|
||||
seen = set(p.acronyms_used)
|
||||
for ac in seen:
|
||||
doc_count[ac] += 1
|
||||
for ac in p.acronym_definitions:
|
||||
# If acronym appears in title, treat as canonical-ish.
|
||||
if ac in p.title:
|
||||
titled.add(ac)
|
||||
return sorted([(ac, c) for ac, c in doc_count.items()
|
||||
if c >= min_docs and ac not in titled],
|
||||
key=lambda x: -x[1])
|
||||
|
||||
|
||||
def cleanup_priority(pages: list, stale_days: int) -> list:
|
||||
"""Rank pages by (staleness × inbound-link-count) — high-traffic
|
||||
stale docs surface first."""
|
||||
scored = []
|
||||
for p in pages:
|
||||
staleness = 0
|
||||
if p.last_reviewed:
|
||||
try:
|
||||
lr = dt.datetime.strptime(p.last_reviewed[:10], "%Y-%m-%d")
|
||||
staleness = max(0, (dt.datetime.now() - lr).days
|
||||
- stale_days)
|
||||
except ValueError:
|
||||
staleness = max(0, p.mtime_days_ago - stale_days)
|
||||
else:
|
||||
staleness = max(0, p.mtime_days_ago - stale_days)
|
||||
if staleness > 0:
|
||||
# inbound +1 to avoid zeroing out everything orphan
|
||||
score = staleness * (p.inbound_link_count + 1)
|
||||
scored.append((score, p))
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
return scored
|
||||
|
||||
|
||||
def generate_report(root: Path, pages: list, stale_days: int) -> str:
|
||||
orphans = detect_orphans(pages)
|
||||
stale = detect_stale(pages, stale_days)
|
||||
missing_owner = detect_missing_owner(pages)
|
||||
drift = detect_glossary_drift(pages)
|
||||
candidates = detect_glossary_candidates(pages)
|
||||
priority = cleanup_priority(pages, stale_days)
|
||||
|
||||
lines = [
|
||||
f"# KB health report — `{root}`",
|
||||
"",
|
||||
f"**Pages scanned:** {len(pages)}",
|
||||
f"**Stale threshold:** {stale_days} days",
|
||||
"",
|
||||
"## Summary metrics",
|
||||
"",
|
||||
"| Metric | Count | % of vault |",
|
||||
"|--------|-------|------------|",
|
||||
f"| Orphan pages (no inbound links) | {len(orphans)} | "
|
||||
f"{round(len(orphans) / max(len(pages), 1) * 100, 1)}% |",
|
||||
f"| Stale pages (> {stale_days}d) | {len(stale)} | "
|
||||
f"{round(len(stale) / max(len(pages), 1) * 100, 1)}% |",
|
||||
f"| Missing-owner pages | {len(missing_owner)} | "
|
||||
f"{round(len(missing_owner) / max(len(pages), 1) * 100, 1)}% |",
|
||||
f"| Glossary drift (acronyms with >= 2 defs) | {len(drift)} | — |",
|
||||
f"| Glossary candidates (acronyms in 3+ docs, no canonical page) "
|
||||
f"| {len(candidates)} | — |",
|
||||
"",
|
||||
]
|
||||
|
||||
lines.append("## Top-20 cleanup priority "
|
||||
"(staleness × inbound-link-count + 1)")
|
||||
lines.append("")
|
||||
if priority:
|
||||
lines.append("| Rank | Score | Path | Inbound | "
|
||||
"Days stale | Owner |")
|
||||
lines.append("|------|-------|------|---------|"
|
||||
"------------|-------|")
|
||||
for i, (score, p) in enumerate(priority[:20], start=1):
|
||||
rel = p.path.relative_to(root)
|
||||
staleness = (p.mtime_days_ago - stale_days
|
||||
if not p.last_reviewed else
|
||||
(dt.datetime.now() - dt.datetime.strptime(
|
||||
p.last_reviewed[:10], "%Y-%m-%d")).days
|
||||
- stale_days)
|
||||
lines.append(
|
||||
f"| {i} | {score} | `{rel}` | {p.inbound_link_count} "
|
||||
f"| {staleness} | {p.owner or '(MISSING)'} |"
|
||||
)
|
||||
else:
|
||||
lines.append("_(no stale pages — KB is current)_")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Orphan pages (no inbound links)")
|
||||
lines.append("")
|
||||
if orphans:
|
||||
for p in orphans[:30]:
|
||||
rel = p.path.relative_to(root)
|
||||
lines.append(f"- `{rel}` — {p.title}")
|
||||
if len(orphans) > 30:
|
||||
lines.append(f"- _(+{len(orphans) - 30} more not shown)_")
|
||||
else:
|
||||
lines.append("_(none — every page has at least one inbound link)_")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Glossary drift (acronym defined differently across "
|
||||
"docs)")
|
||||
lines.append("")
|
||||
if drift:
|
||||
for ac, defs in drift.items():
|
||||
lines.append(f"**{ac}:**")
|
||||
for defin, src in defs:
|
||||
lines.append(f" - `{defin}` (in `{src}`)")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("_(none detected — acronyms are used consistently)_")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Glossary candidates (acronym used in 3+ docs "
|
||||
"without a canonical definition page)")
|
||||
lines.append("")
|
||||
if candidates:
|
||||
for ac, count in candidates[:20]:
|
||||
lines.append(f"- **{ac}** — used in {count} docs, no "
|
||||
f"canonical definition page exists")
|
||||
else:
|
||||
lines.append("_(none — acronyms either have canonical pages or "
|
||||
"are uncommon)_")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Missing-owner pages")
|
||||
lines.append("")
|
||||
if missing_owner:
|
||||
for p in missing_owner[:30]:
|
||||
rel = p.path.relative_to(root)
|
||||
lines.append(f"- `{rel}` — {p.title}")
|
||||
if len(missing_owner) > 30:
|
||||
lines.append(
|
||||
f"- _(+{len(missing_owner) - 30} more not shown)_")
|
||||
else:
|
||||
lines.append("_(none — every page has an owner)_")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Recommended next actions")
|
||||
lines.append("")
|
||||
lines.append("1. Assign owners to the missing-owner pages first — "
|
||||
"no other fix sticks without ownership.")
|
||||
lines.append("2. Resolve glossary drift by picking one canonical "
|
||||
"definition per acronym; add a `glossary.md` page; "
|
||||
"link every other doc to it.")
|
||||
lines.append("3. Triage the top-20 cleanup list: archive, rewrite, "
|
||||
"or refresh. Re-run this report after the sprint to "
|
||||
"verify orphan + stale counts are down.")
|
||||
lines.append("4. Pair orphan pages with a navigation review — some "
|
||||
"orphans are reference pages found via search and "
|
||||
"should NOT be archived. Curate, don't bulk-delete.")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def generate_json_report(root: Path, pages: list, stale_days: int) -> dict:
|
||||
orphans = detect_orphans(pages)
|
||||
stale = detect_stale(pages, stale_days)
|
||||
missing_owner = detect_missing_owner(pages)
|
||||
drift = detect_glossary_drift(pages)
|
||||
candidates = detect_glossary_candidates(pages)
|
||||
priority = cleanup_priority(pages, stale_days)
|
||||
return {
|
||||
"root": str(root),
|
||||
"page_count": len(pages),
|
||||
"stale_days_threshold": stale_days,
|
||||
"orphan_count": len(orphans),
|
||||
"stale_count": len(stale),
|
||||
"missing_owner_count": len(missing_owner),
|
||||
"glossary_drift_count": len(drift),
|
||||
"glossary_candidate_count": len(candidates),
|
||||
"top_cleanup": [
|
||||
{
|
||||
"rank": i + 1,
|
||||
"score": score,
|
||||
"path": str(p.path.relative_to(root)),
|
||||
"inbound_links": p.inbound_link_count,
|
||||
"owner": p.owner or None,
|
||||
}
|
||||
for i, (score, p) in enumerate(priority[:20])
|
||||
],
|
||||
"orphans": [str(p.path.relative_to(root)) for p in orphans],
|
||||
"glossary_drift": {ac: [{"definition": d, "source": s}
|
||||
for d, s in defs]
|
||||
for ac, defs in drift.items()},
|
||||
"glossary_candidates": [{"acronym": ac, "doc_count": c}
|
||||
for ac, c in candidates],
|
||||
"missing_owner": [str(p.path.relative_to(root))
|
||||
for p in missing_owner],
|
||||
}
|
||||
|
||||
|
||||
SAMPLE_PAGES = {
|
||||
"index.md": """---
|
||||
owner: alex@company.com
|
||||
last_reviewed: 2026-04-01
|
||||
---
|
||||
# Ops Index
|
||||
|
||||
Welcome to the Ops wiki. Start with [Vendor Offboarding](sops/vendor-offboarding.md) or [Incident Comms](runbooks/incident-comms.md).
|
||||
The [Glossary](glossary.md) defines our terms.
|
||||
""",
|
||||
"glossary.md": """---
|
||||
owner: alex@company.com
|
||||
last_reviewed: 2026-04-15
|
||||
---
|
||||
# Glossary
|
||||
|
||||
- Customer Success Manager (CSM) — owns post-sale account relationship.
|
||||
- Vendor Management Office (VMO) — owns third-party vendor lifecycle.
|
||||
""",
|
||||
"sops/vendor-offboarding.md": """---
|
||||
owner: jordan@company.com
|
||||
last_reviewed: 2026-02-01
|
||||
---
|
||||
# Vendor Offboarding SOP
|
||||
|
||||
The VMO operator runs this SOP when a vendor contract is terminated.
|
||||
See also [Incident Comms](../runbooks/incident-comms.md).
|
||||
The CSM is notified.
|
||||
""",
|
||||
"sops/procurement-intake.md": """---
|
||||
owner: jordan@company.com
|
||||
last_reviewed: 2024-01-01
|
||||
---
|
||||
# Procurement Intake SOP
|
||||
|
||||
Run this when finance receives a purchase request. The CSM (Customer Solutions Manager) reviews it.
|
||||
""", # NOTE: glossary drift — CSM here is Customer Solutions Manager
|
||||
"runbooks/incident-comms.md": """---
|
||||
last_reviewed: 2026-03-01
|
||||
---
|
||||
# Incident Comms Cascade
|
||||
|
||||
(no owner field — missing-owner case)
|
||||
Send alerts to the on-call SRE.
|
||||
""",
|
||||
"orphan-page.md": """---
|
||||
owner: pat@company.com
|
||||
last_reviewed: 2026-04-01
|
||||
---
|
||||
# Orphan Page
|
||||
|
||||
Nobody links here.
|
||||
The CSM may find this useful.
|
||||
""",
|
||||
"old-stale-page.md": """# Old Page
|
||||
|
||||
(no frontmatter at all — missing-owner AND probably stale via mtime)
|
||||
""",
|
||||
"sops/employee-onboarding.md": """---
|
||||
owner: hr@company.com
|
||||
last_reviewed: 2026-04-20
|
||||
---
|
||||
# Employee Onboarding SOP
|
||||
|
||||
Coordinate with the CSM and VMO for system access.
|
||||
Link: [Vendor Offboarding](vendor-offboarding.md).
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
def _materialize_sample_vault() -> Path:
|
||||
tmp = Path(tempfile.mkdtemp(prefix="kb-sample-"))
|
||||
for relpath, content in SAMPLE_PAGES.items():
|
||||
full = tmp / relpath
|
||||
full.parent.mkdir(parents=True, exist_ok=True)
|
||||
full.write_text(content, encoding="utf-8")
|
||||
# Backdate one file via os.utime so mtime-based stale detection
|
||||
# has something to find.
|
||||
import os
|
||||
old = tmp / "old-stale-page.md"
|
||||
if old.exists():
|
||||
old_ts = (dt.datetime.now() -
|
||||
dt.timedelta(days=720)).timestamp()
|
||||
os.utime(old, (old_ts, old_ts))
|
||||
return tmp
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Walk a markdown KB and emit a hygiene report: "
|
||||
"orphans, stale, missing-owner, glossary drift."
|
||||
)
|
||||
p.add_argument("--input", "-i", type=str,
|
||||
help="Path to KB root directory.")
|
||||
p.add_argument("--output", "-o", choices=["markdown", "json"],
|
||||
default="markdown",
|
||||
help="Output format (default: markdown).")
|
||||
p.add_argument("--stale-days", type=int, default=365,
|
||||
help="Days since last edit to consider stale "
|
||||
"(default: 365).")
|
||||
p.add_argument("--sample", action="store_true",
|
||||
help="Run against a tiny synthetic vault in a "
|
||||
"tmpdir.")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.sample:
|
||||
root = _materialize_sample_vault()
|
||||
elif args.input:
|
||||
root = Path(args.input).resolve()
|
||||
if not root.exists() or not root.is_dir():
|
||||
print(f"ERROR: input directory not found: {args.input}",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
else:
|
||||
print("ERROR: provide --input <kb-root-dir> or --sample",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
pages = walk_vault(root, stale_days=args.stale_days)
|
||||
if not pages:
|
||||
print(f"WARNING: no markdown files found under {root}",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.output == "json":
|
||||
print(json.dumps(generate_json_report(root, pages, args.stale_days),
|
||||
indent=2))
|
||||
else:
|
||||
print(generate_report(root, pages, args.stale_days))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,471 @@
|
||||
#!/usr/bin/env python3
|
||||
"""runbook_validator.py
|
||||
|
||||
Validate a runbook by checking each step against six required attributes:
|
||||
|
||||
1. Named owner (not "the team", not "ops")
|
||||
2. Expected duration (concrete number + unit)
|
||||
3. Observable success signal
|
||||
4. Observable failure signal
|
||||
5. Rollback path (or explicit "cannot roll back — escalate to X")
|
||||
6. Escalation contact
|
||||
|
||||
Output is a per-step traffic-light + overall validity score 0-100 + a list
|
||||
of MUST-FIX issues.
|
||||
|
||||
Verdict thresholds:
|
||||
>= 80 SAFE-TO-USE
|
||||
60-79 USE-WITH-CAUTION
|
||||
< 60 NOT-SAFE
|
||||
|
||||
Input formats:
|
||||
--input runbook.md (markdown: heuristic parser, expects
|
||||
"## Step N:" or "### Step N:" headings)
|
||||
--input runbook.json (JSON: explicit step list — preferred)
|
||||
|
||||
JSON schema:
|
||||
{
|
||||
"runbook_name": "Incident Comms Cascade",
|
||||
"steps": [
|
||||
{
|
||||
"title": "Acknowledge alert in PagerDuty",
|
||||
"owner": "On-call IC (named rotation)",
|
||||
"duration_minutes": 2,
|
||||
"success_signal": "PagerDuty incident transitions to acknowledged",
|
||||
"failure_signal": "Incident remains in triggered state after 2 min",
|
||||
"rollback": "n/a (acknowledgement is non-mutating)",
|
||||
"escalation": "Engineering Manager on-call"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
VAGUE_OWNER_TOKENS = {
|
||||
"the team", "team", "ops", "the ops team", "engineering",
|
||||
"support", "everyone", "whoever", "someone", "tbd", "n/a",
|
||||
"the on-call", "on call", "rotation", # rotation alone is vague
|
||||
}
|
||||
|
||||
# Vague success signal phrases that get flagged. Matched as whole-phrase
|
||||
# substrings — must be specific enough to avoid false positives on
|
||||
# legitimate observables that happen to contain a common word.
|
||||
VAGUE_SUCCESS_TOKENS = [
|
||||
"service is up", "it works", "things look good", "looks fine",
|
||||
"no errors", "should work", "appears to be",
|
||||
"verify the service", "check that it works", "looks good",
|
||||
]
|
||||
|
||||
# Phrases that count as observable.
|
||||
OBSERVABLE_HINTS = [
|
||||
"http 2", "http 3", "http 4", "http 5", # status codes
|
||||
"status code", "exit code 0", "/healthz", "/health", "200 ok",
|
||||
"log line", "metric", "dashboard shows", "alert clears",
|
||||
"incident transitions", "ticket moves to", "slack reaction",
|
||||
"email received", "record updated", "field set to",
|
||||
]
|
||||
|
||||
DURATION_PATTERN = re.compile(
|
||||
r"\b\d+(?:\.\d+)?\s*(seconds?|secs?|minutes?|mins?|hours?|hrs?|days?)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Rollback acceptable phrasing: either a real rollback OR explicit
|
||||
# acknowledgement that rollback is impossible plus escalation.
|
||||
NO_ROLLBACK_ACCEPTABLE = [
|
||||
"cannot be rolled back",
|
||||
"cannot roll back",
|
||||
"non-mutating",
|
||||
"read-only",
|
||||
"no rollback needed",
|
||||
"irreversible — escalate",
|
||||
"irreversible - escalate",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepFinding:
|
||||
step_index: int
|
||||
title: str
|
||||
owner_ok: bool = False
|
||||
duration_ok: bool = False
|
||||
success_ok: bool = False
|
||||
failure_ok: bool = False
|
||||
rollback_ok: bool = False
|
||||
escalation_ok: bool = False
|
||||
issues: list = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def passes(self) -> int:
|
||||
return sum([
|
||||
self.owner_ok, self.duration_ok, self.success_ok,
|
||||
self.failure_ok, self.rollback_ok, self.escalation_ok,
|
||||
])
|
||||
|
||||
@property
|
||||
def traffic_light(self) -> str:
|
||||
if self.passes == 6:
|
||||
return "GREEN"
|
||||
if self.passes >= 4:
|
||||
return "AMBER"
|
||||
return "RED"
|
||||
|
||||
|
||||
def _check_owner(owner: str) -> tuple[bool, str]:
|
||||
if not owner or not owner.strip():
|
||||
return False, "missing owner"
|
||||
norm = owner.strip().lower()
|
||||
for token in VAGUE_OWNER_TOKENS:
|
||||
# Vague if owner is ONLY that token (allow named rotations like
|
||||
# "SRE on-call (alex)" by checking for parenthetical name OR @).
|
||||
if norm == token or norm.startswith(token + " "):
|
||||
if "@" in owner or "(" in owner:
|
||||
return True, ""
|
||||
return False, (
|
||||
f"vague owner '{owner}' — name a specific human or a "
|
||||
f"specifically-named rotation (e.g., 'SRE on-call "
|
||||
f"rotation (PagerDuty: sre-primary)')"
|
||||
)
|
||||
return True, ""
|
||||
|
||||
|
||||
def _check_duration(duration_str: str, duration_minutes) -> tuple[bool, str]:
|
||||
if duration_minutes is not None:
|
||||
try:
|
||||
val = float(duration_minutes)
|
||||
if val > 0:
|
||||
return True, ""
|
||||
return False, "duration_minutes is zero or negative"
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if duration_str and DURATION_PATTERN.search(duration_str):
|
||||
return True, ""
|
||||
return False, (
|
||||
"missing expected duration (need a concrete number + unit, "
|
||||
"e.g., '2 minutes', '30 seconds')"
|
||||
)
|
||||
|
||||
|
||||
def _check_observable(signal: str, kind: str) -> tuple[bool, str]:
|
||||
if not signal or not signal.strip():
|
||||
return False, f"missing observable {kind} signal"
|
||||
norm = signal.lower()
|
||||
for vague in VAGUE_SUCCESS_TOKENS:
|
||||
if vague in norm:
|
||||
return False, (
|
||||
f"vague {kind} signal '{signal}' — need an observable "
|
||||
f"(e.g., 'HTTP 200 from /healthz', not 'service is up')"
|
||||
)
|
||||
for hint in OBSERVABLE_HINTS:
|
||||
if hint in norm:
|
||||
return True, ""
|
||||
# Heuristic: if signal contains digits, equality, code-fences, or
|
||||
# specific verbs that imply an observation, accept.
|
||||
if any(ch in signal for ch in ("=", ":", "`", "200", "404", "500")):
|
||||
return True, ""
|
||||
if re.search(r"\b(returns?|equals?|shows?|transitions?|moves?|"
|
||||
r"closes?|emits?|logs?|created|deleted|received|"
|
||||
r"updated|set\s+to|reaches?|reports?)\b", norm):
|
||||
return True, ""
|
||||
return False, (
|
||||
f"{kind} signal '{signal}' is not clearly observable — rewrite "
|
||||
f"as a concrete check (status code, log line, dashboard panel, "
|
||||
f"ticket state)"
|
||||
)
|
||||
|
||||
|
||||
def _check_rollback(rollback: str) -> tuple[bool, str]:
|
||||
if not rollback or not rollback.strip():
|
||||
return False, "missing rollback path"
|
||||
norm = rollback.lower()
|
||||
for ok in NO_ROLLBACK_ACCEPTABLE:
|
||||
if ok in norm:
|
||||
return True, ""
|
||||
# If there's substantive text (> 12 chars) describing a step, accept.
|
||||
if len(rollback.strip()) >= 12:
|
||||
return True, ""
|
||||
return False, (
|
||||
f"rollback path too thin ('{rollback}') — either describe the "
|
||||
f"rollback procedure OR write 'cannot be rolled back — "
|
||||
f"escalate to <name>'"
|
||||
)
|
||||
|
||||
|
||||
def _check_escalation(escalation: str) -> tuple[bool, str]:
|
||||
if not escalation or not escalation.strip():
|
||||
return False, "missing escalation contact"
|
||||
norm = escalation.strip().lower()
|
||||
for token in VAGUE_OWNER_TOKENS:
|
||||
if norm == token or norm.startswith(token + " "):
|
||||
if "@" not in escalation and "(" not in escalation:
|
||||
return False, (
|
||||
f"vague escalation contact '{escalation}' — name a "
|
||||
f"specific human, role+email, or named on-call rotation"
|
||||
)
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_step(step: dict, idx: int) -> StepFinding:
|
||||
finding = StepFinding(
|
||||
step_index=idx,
|
||||
title=step.get("title", f"(step {idx} — no title)"),
|
||||
)
|
||||
|
||||
owner_ok, owner_err = _check_owner(step.get("owner", ""))
|
||||
finding.owner_ok = owner_ok
|
||||
if not owner_ok:
|
||||
finding.issues.append(f"OWNER: {owner_err}")
|
||||
|
||||
duration_ok, duration_err = _check_duration(
|
||||
step.get("duration_str", ""),
|
||||
step.get("duration_minutes"),
|
||||
)
|
||||
finding.duration_ok = duration_ok
|
||||
if not duration_ok:
|
||||
finding.issues.append(f"DURATION: {duration_err}")
|
||||
|
||||
succ_ok, succ_err = _check_observable(
|
||||
step.get("success_signal", ""), "success")
|
||||
finding.success_ok = succ_ok
|
||||
if not succ_ok:
|
||||
finding.issues.append(f"SUCCESS: {succ_err}")
|
||||
|
||||
fail_ok, fail_err = _check_observable(
|
||||
step.get("failure_signal", ""), "failure")
|
||||
finding.failure_ok = fail_ok
|
||||
if not fail_ok:
|
||||
finding.issues.append(f"FAILURE: {fail_err}")
|
||||
|
||||
rb_ok, rb_err = _check_rollback(step.get("rollback", ""))
|
||||
finding.rollback_ok = rb_ok
|
||||
if not rb_ok:
|
||||
finding.issues.append(f"ROLLBACK: {rb_err}")
|
||||
|
||||
esc_ok, esc_err = _check_escalation(step.get("escalation", ""))
|
||||
finding.escalation_ok = esc_ok
|
||||
if not esc_ok:
|
||||
finding.issues.append(f"ESCALATION: {esc_err}")
|
||||
|
||||
return finding
|
||||
|
||||
|
||||
def _parse_markdown(text: str) -> dict:
|
||||
"""Heuristic parser. Expects steps as '## Step N: title' or
|
||||
'### Step N: title' followed by bullet attributes."""
|
||||
lines = text.splitlines()
|
||||
name_match = re.search(r"^#\s+(.+)$", text, re.MULTILINE)
|
||||
runbook_name = name_match.group(1).strip() if name_match else "(unnamed)"
|
||||
steps = []
|
||||
current = None
|
||||
step_re = re.compile(
|
||||
r"^#{2,3}\s+Step\s+(\d+)\s*:?\s*(.*)$", re.IGNORECASE)
|
||||
attr_re = re.compile(
|
||||
r"^\s*[-*]\s+\*?\*?(Owner|Duration|Success|Failure|"
|
||||
r"Rollback|Escalation)\*?\*?\s*:?\s*(.+)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for line in lines:
|
||||
m = step_re.match(line)
|
||||
if m:
|
||||
if current:
|
||||
steps.append(current)
|
||||
current = {"title": m.group(2).strip() or f"step {m.group(1)}"}
|
||||
continue
|
||||
if current:
|
||||
am = attr_re.match(line)
|
||||
if am:
|
||||
key = am.group(1).lower()
|
||||
val = am.group(2).strip()
|
||||
if key == "owner":
|
||||
current["owner"] = val
|
||||
elif key == "duration":
|
||||
current["duration_str"] = val
|
||||
elif key == "success":
|
||||
current["success_signal"] = val
|
||||
elif key == "failure":
|
||||
current["failure_signal"] = val
|
||||
elif key == "rollback":
|
||||
current["rollback"] = val
|
||||
elif key == "escalation":
|
||||
current["escalation"] = val
|
||||
if current:
|
||||
steps.append(current)
|
||||
return {"runbook_name": runbook_name, "steps": steps}
|
||||
|
||||
|
||||
def _sample_runbook() -> dict:
|
||||
"""Deliberately broken incident-comms runbook to demonstrate
|
||||
failure detection."""
|
||||
return {
|
||||
"runbook_name": "Incident Comms Cascade (BROKEN sample)",
|
||||
"steps": [
|
||||
{
|
||||
"title": "Acknowledge alert",
|
||||
"owner": "the team", # vague
|
||||
"duration_str": "", # missing
|
||||
"success_signal": "service is up", # vague
|
||||
"failure_signal": "", # missing
|
||||
"rollback": "", # missing
|
||||
"escalation": "ops", # vague
|
||||
},
|
||||
{
|
||||
"title": "Open incident channel",
|
||||
"owner": "Incident Commander on-call "
|
||||
"(PagerDuty: ic-primary)",
|
||||
"duration_str": "2 minutes",
|
||||
"success_signal": "Slack channel #inc-<id> created and "
|
||||
"linked from PagerDuty incident",
|
||||
"failure_signal": "Slack returns 4xx or channel-create "
|
||||
"API call times out",
|
||||
"rollback": "n/a — read-only operation (channel can be "
|
||||
"archived if created in error)",
|
||||
"escalation": "Engineering Manager on-call "
|
||||
"(em-primary@company.com)",
|
||||
},
|
||||
{
|
||||
"title": "Notify execs via paging tree",
|
||||
"owner": "Communications Lead "
|
||||
"(comms-lead@company.com)",
|
||||
"duration_str": "5 minutes",
|
||||
"success_signal": "Exec recipient list shows email "
|
||||
"received (200 OK from SES API)",
|
||||
"failure_signal": "SES API returns 5xx or recipient "
|
||||
"delivery status = bounced",
|
||||
"rollback": "Send retraction email to same list with "
|
||||
"subject prefix 'RETRACTION:'",
|
||||
"escalation": "VP Communications "
|
||||
"(vp-comms@company.com)",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def generate_report(runbook: dict, findings: list) -> str:
|
||||
total = len(findings)
|
||||
if total == 0:
|
||||
return "ERROR: runbook contains no steps."
|
||||
score = round(sum(f.passes for f in findings) /
|
||||
(6 * total) * 100, 1)
|
||||
if score >= 80:
|
||||
verdict = "SAFE-TO-USE"
|
||||
elif score >= 60:
|
||||
verdict = "USE-WITH-CAUTION"
|
||||
else:
|
||||
verdict = "NOT-SAFE"
|
||||
|
||||
lines = [
|
||||
f"# Runbook validation: {runbook.get('runbook_name', '(unnamed)')}",
|
||||
"",
|
||||
f"**Steps validated:** {total}",
|
||||
f"**Validity score:** {score} / 100",
|
||||
f"**Verdict:** {verdict}",
|
||||
"",
|
||||
"## Per-step traffic-light",
|
||||
"",
|
||||
"| Step | Title | Owner | Duration | Success | Failure | "
|
||||
"Rollback | Escalation | Light |",
|
||||
"|------|-------|-------|----------|---------|---------|"
|
||||
"----------|------------|-------|",
|
||||
]
|
||||
for f in findings:
|
||||
def ck(b):
|
||||
return "OK" if b else "FAIL"
|
||||
lines.append(
|
||||
f"| {f.step_index} | {f.title[:40]} | {ck(f.owner_ok)} | "
|
||||
f"{ck(f.duration_ok)} | {ck(f.success_ok)} | "
|
||||
f"{ck(f.failure_ok)} | {ck(f.rollback_ok)} | "
|
||||
f"{ck(f.escalation_ok)} | {f.traffic_light} |"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append("## MUST-FIX issues")
|
||||
lines.append("")
|
||||
any_issues = False
|
||||
for f in findings:
|
||||
if f.issues:
|
||||
any_issues = True
|
||||
lines.append(f"### Step {f.step_index}: {f.title}")
|
||||
for issue in f.issues:
|
||||
lines.append(f"- {issue}")
|
||||
lines.append("")
|
||||
if not any_issues:
|
||||
lines.append("_(none — all steps pass all six checks)_")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def generate_json_report(runbook: dict, findings: list) -> dict:
|
||||
total = len(findings) or 1
|
||||
score = round(sum(f.passes for f in findings) / (6 * total) * 100, 1)
|
||||
verdict = ("SAFE-TO-USE" if score >= 80
|
||||
else "USE-WITH-CAUTION" if score >= 60
|
||||
else "NOT-SAFE")
|
||||
return {
|
||||
"runbook_name": runbook.get("runbook_name", "(unnamed)"),
|
||||
"step_count": len(findings),
|
||||
"validity_score": score,
|
||||
"verdict": verdict,
|
||||
"findings": [asdict(f) | {"traffic_light": f.traffic_light,
|
||||
"passes": f.passes} for f in findings],
|
||||
}
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Validate a runbook against six step-completeness "
|
||||
"rules. Output traffic-light + score + MUST-FIX list."
|
||||
)
|
||||
p.add_argument("--input", "-i", type=str,
|
||||
help="Path to runbook .md or .json file.")
|
||||
p.add_argument("--output", "-o", choices=["markdown", "json"],
|
||||
default="markdown",
|
||||
help="Output format (default: markdown).")
|
||||
p.add_argument("--sample", action="store_true",
|
||||
help="Run against a deliberately-broken sample runbook.")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.sample:
|
||||
runbook = _sample_runbook()
|
||||
elif args.input:
|
||||
path = Path(args.input)
|
||||
if not path.exists():
|
||||
print(f"ERROR: input file not found: {args.input}",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
text = path.read_text()
|
||||
if path.suffix.lower() == ".json":
|
||||
runbook = json.loads(text)
|
||||
else:
|
||||
runbook = _parse_markdown(text)
|
||||
else:
|
||||
print("ERROR: provide --input <runbook.md|json> or --sample",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
steps = runbook.get("steps", [])
|
||||
if not steps:
|
||||
print("ERROR: runbook contains no steps "
|
||||
"(or markdown parser found none — try JSON input)",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
findings = [validate_step(s, i + 1) for i, s in enumerate(steps)]
|
||||
if args.output == "json":
|
||||
print(json.dumps(generate_json_report(runbook, findings),
|
||||
indent=2))
|
||||
else:
|
||||
print(generate_report(runbook, findings))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,465 @@
|
||||
#!/usr/bin/env python3
|
||||
"""sop_generator.py
|
||||
|
||||
Generate a 5W2H-structured Standard Operating Procedure (SOP) from a JSON
|
||||
metadata file. Output is markdown by default, or normalized JSON.
|
||||
|
||||
5W2H = Who, What, When, Where, Why, How, How-much (Ishikawa, *Guide to
|
||||
Quality Control*, 1985). Each section is mandatory; missing sections produce
|
||||
a warning footer naming the section.
|
||||
|
||||
Industry tuning:
|
||||
--profile {ops,support,finance,hr,it,regulated}
|
||||
|
||||
- ops: general internal ops SOP scaffold
|
||||
- support: adds customer-impact section + escalation matrix
|
||||
- finance: adds controls + reconciliation + segregation-of-duties section
|
||||
- hr: flags PII / sensitive-data handling; adds consent section
|
||||
- it: adds system + access + change-management section
|
||||
- regulated: adds version control, signoff matrix, audit-trail, change
|
||||
history (required under ISO 9001 / FDA 21 CFR Part 211 /
|
||||
SOC 2 / HIPAA / ISO 13485)
|
||||
|
||||
Regulatory overlay flags attach the appropriate compliance preamble:
|
||||
regulatory_overlay: ["SOC2", "HIPAA", "ISO13485", "GDPR", "SOX"]
|
||||
|
||||
Input schema (JSON):
|
||||
{
|
||||
"sop_name": "Vendor Offboarding",
|
||||
"process_owner": "alex@company.com",
|
||||
"triggering_event": "Vendor contract not renewed OR vendor terminated",
|
||||
"audience_role": "Vendor Management Office operator",
|
||||
"frequency": "On-demand (avg 3 times per quarter)",
|
||||
"regulatory_overlay": ["SOC2"],
|
||||
"inputs": ["Vendor name", "Contract end date", "Data access list"],
|
||||
"outputs": ["Access revoked", "Data deleted/returned", "Final invoice paid"],
|
||||
"steps_outline": [
|
||||
"Notify vendor of offboarding intent",
|
||||
"Inventory data and system access",
|
||||
"Revoke production system access",
|
||||
"Confirm data deletion or return",
|
||||
"Final invoice reconciliation",
|
||||
"Archive vendor record"
|
||||
],
|
||||
"estimated_minutes": 240,
|
||||
"estimated_cost_usd": 800
|
||||
}
|
||||
|
||||
Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
VALID_PROFILES = {"ops", "support", "finance", "hr", "it", "regulated"}
|
||||
VALID_OVERLAYS = {"SOC2", "HIPAA", "ISO13485", "GDPR", "SOX"}
|
||||
|
||||
|
||||
REGULATORY_PREAMBLE = {
|
||||
"SOC2": (
|
||||
"**SOC 2 overlay:** This SOP supports the Common Criteria control "
|
||||
"framework. Changes require change-management approval (CC8.1). "
|
||||
"Evidence of execution must be retained for the audit period."
|
||||
),
|
||||
"HIPAA": (
|
||||
"**HIPAA overlay:** This SOP touches Protected Health Information "
|
||||
"(PHI). All access must be logged per §164.312(b). Minimum-necessary "
|
||||
"rule applies (§164.502(b))."
|
||||
),
|
||||
"ISO13485": (
|
||||
"**ISO 13485 overlay:** This is a controlled document under §4.2.4. "
|
||||
"Document revision, approval, and review records must be maintained. "
|
||||
"Use the regulated profile."
|
||||
),
|
||||
"GDPR": (
|
||||
"**GDPR overlay:** This SOP touches personal data of EU data "
|
||||
"subjects. Lawful basis must be documented (Art. 6). Data-subject "
|
||||
"rights (Art. 15-22) requests must be respected during execution."
|
||||
),
|
||||
"SOX": (
|
||||
"**SOX overlay:** This SOP supports a financial control. Execution "
|
||||
"must be evidenced and segregation-of-duties enforced. Quarterly "
|
||||
"management testing applies."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SOPMetadata:
|
||||
sop_name: str = ""
|
||||
process_owner: str = ""
|
||||
triggering_event: str = ""
|
||||
audience_role: str = ""
|
||||
frequency: str = ""
|
||||
regulatory_overlay: list = field(default_factory=list)
|
||||
inputs: list = field(default_factory=list)
|
||||
outputs: list = field(default_factory=list)
|
||||
steps_outline: list = field(default_factory=list)
|
||||
estimated_minutes: int = 0
|
||||
estimated_cost_usd: int = 0
|
||||
|
||||
def validate(self) -> list:
|
||||
errs = []
|
||||
for fld in ("sop_name", "process_owner", "triggering_event",
|
||||
"audience_role", "frequency"):
|
||||
if not getattr(self, fld):
|
||||
errs.append(f"missing required field: '{fld}'")
|
||||
if not self.steps_outline:
|
||||
errs.append("missing 'steps_outline' (need >= 1 step)")
|
||||
for ov in self.regulatory_overlay:
|
||||
if ov not in VALID_OVERLAYS:
|
||||
errs.append(
|
||||
f"invalid regulatory_overlay '{ov}'; "
|
||||
f"allowed: {sorted(VALID_OVERLAYS)}"
|
||||
)
|
||||
return errs
|
||||
|
||||
|
||||
def _sample_metadata() -> dict:
|
||||
return {
|
||||
"sop_name": "Vendor Offboarding",
|
||||
"process_owner": "alex@company.com (Vendor Management Lead)",
|
||||
"triggering_event": (
|
||||
"Vendor contract not renewed OR vendor terminated for cause"
|
||||
),
|
||||
"audience_role": "Vendor Management Office (VMO) operator",
|
||||
"frequency": "On-demand (avg 3 executions per quarter)",
|
||||
"regulatory_overlay": ["SOC2"],
|
||||
"inputs": [
|
||||
"Vendor legal name",
|
||||
"Contract end date (effective offboarding date)",
|
||||
"List of systems with vendor access",
|
||||
"List of data classes vendor processed",
|
||||
],
|
||||
"outputs": [
|
||||
"All production system access revoked (evidenced)",
|
||||
"Vendor data deleted or returned (evidenced)",
|
||||
"Final invoice reconciled and paid",
|
||||
"Vendor record archived in VMO registry",
|
||||
],
|
||||
"steps_outline": [
|
||||
"Notify vendor of offboarding intent (written, 30 days notice)",
|
||||
"Inventory data classes and system access vendor holds",
|
||||
"Revoke production system access (IAM, VPN, SaaS)",
|
||||
"Confirm data deletion (vendor certification) or data return",
|
||||
"Final invoice reconciliation and payment",
|
||||
"Archive vendor record in VMO registry with offboarding evidence",
|
||||
],
|
||||
"estimated_minutes": 240,
|
||||
"estimated_cost_usd": 800,
|
||||
}
|
||||
|
||||
|
||||
def _build_who(meta: SOPMetadata, profile: str) -> str:
|
||||
lines = [
|
||||
"### Who",
|
||||
"",
|
||||
f"- **Process owner (Accountable):** {meta.process_owner}",
|
||||
f"- **Audience (Responsible):** {meta.audience_role}",
|
||||
]
|
||||
if profile == "regulated":
|
||||
lines.append("- **Approver (Consulted):** "
|
||||
"Quality Management Representative")
|
||||
lines.append("- **Auditor (Informed):** "
|
||||
"Internal Audit / Compliance")
|
||||
elif profile == "finance":
|
||||
lines.append("- **Approver (Consulted):** Controller")
|
||||
lines.append("- **Segregation-of-duties review:** "
|
||||
"Required (initiator != approver != payer)")
|
||||
elif profile == "hr":
|
||||
lines.append("- **Approver (Consulted):** HR Business Partner")
|
||||
lines.append("- **Privacy review (Informed):** "
|
||||
"Data Protection Officer (if PII touched)")
|
||||
elif profile == "it":
|
||||
lines.append("- **Approver (Consulted):** "
|
||||
"Change Advisory Board (for system-mutating steps)")
|
||||
elif profile == "support":
|
||||
lines.append("- **Approver (Consulted):** Support Team Lead")
|
||||
lines.append("- **Escalation (Informed):** "
|
||||
"Engineering on-call (if customer-impact > 30 min)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_what(meta: SOPMetadata) -> str:
|
||||
lines = [
|
||||
"### What",
|
||||
"",
|
||||
f"**Process name:** {meta.sop_name}",
|
||||
"",
|
||||
"**Inputs required before starting:**",
|
||||
"",
|
||||
]
|
||||
for inp in meta.inputs:
|
||||
lines.append(f"- {inp}")
|
||||
lines.append("")
|
||||
lines.append("**Outputs produced:**")
|
||||
lines.append("")
|
||||
for out in meta.outputs:
|
||||
lines.append(f"- {out}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_when(meta: SOPMetadata) -> str:
|
||||
return (
|
||||
"### When\n\n"
|
||||
f"- **Triggering event:** {meta.triggering_event}\n"
|
||||
f"- **Frequency:** {meta.frequency}\n"
|
||||
"- **Time-of-day constraint:** _(business hours only? on-call? "
|
||||
"fill in)_\n"
|
||||
"- **Blocking dependencies:** _(prerequisites that must be true "
|
||||
"before starting)_"
|
||||
)
|
||||
|
||||
|
||||
def _build_where(meta: SOPMetadata, profile: str) -> str:
|
||||
lines = [
|
||||
"### Where",
|
||||
"",
|
||||
"- **Primary system of record:** _(name the system — Salesforce, "
|
||||
"Notion, Jira, ServiceNow, etc.)_",
|
||||
"- **Supporting tools:** _(IAM console, IT ticketing, accounting "
|
||||
"system, etc.)_",
|
||||
"- **Canonical doc location:** _(URL of this SOP in the wiki)_",
|
||||
]
|
||||
if profile in {"it", "regulated"}:
|
||||
lines.append("- **Change-management ticket location:** "
|
||||
"_(Jira / ServiceNow queue)_")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_why(meta: SOPMetadata) -> str:
|
||||
lines = [
|
||||
"### Why",
|
||||
"",
|
||||
"**Purpose:** _(one-paragraph statement of why this process "
|
||||
"exists. Anchor to a business outcome, not a task.)_",
|
||||
"",
|
||||
"**Regulatory basis (if any):**",
|
||||
"",
|
||||
]
|
||||
if meta.regulatory_overlay:
|
||||
for ov in meta.regulatory_overlay:
|
||||
lines.append(f"- {REGULATORY_PREAMBLE[ov]}")
|
||||
else:
|
||||
lines.append("- _(none — confirm by checking data classes "
|
||||
"touched. If process touches PHI, financial controls, "
|
||||
"or regulated devices, the answer is not 'none'.)_")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_how(meta: SOPMetadata) -> str:
|
||||
lines = ["### How", ""]
|
||||
lines.append("Step-by-step procedure. Each step must have a named "
|
||||
"owner, expected duration, and observable success signal.")
|
||||
lines.append("")
|
||||
for i, step in enumerate(meta.steps_outline, start=1):
|
||||
lines.append(f"**Step {i}: {step}**")
|
||||
lines.append("")
|
||||
lines.append("- **Owner:** _(named human or named rotation)_")
|
||||
lines.append("- **Expected duration:** _(concrete number + unit)_")
|
||||
lines.append("- **Success signal (observable):** _(e.g., 'IAM "
|
||||
"console shows user disabled', not 'access is "
|
||||
"revoked')_")
|
||||
lines.append("- **Failure signal (observable):** _(what tells you "
|
||||
"the step did not work)_")
|
||||
lines.append("- **If step fails — rollback or escalation:** "
|
||||
"_(rollback path or 'escalate to X — cannot be "
|
||||
"rolled back')_")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_how_much(meta: SOPMetadata) -> str:
|
||||
mins = meta.estimated_minutes or "_(fill in)_"
|
||||
cost = meta.estimated_cost_usd
|
||||
cost_line = f"${cost}" if cost else "_(fill in)_"
|
||||
return (
|
||||
"### How-much\n\n"
|
||||
f"- **Estimated execution time:** {mins} minutes\n"
|
||||
f"- **Estimated cost per execution:** {cost_line} "
|
||||
"(labor + license + third-party fees)\n"
|
||||
"- **Frequency × cost = annual run-rate:** _(compute from "
|
||||
"frequency + cost per execution)_\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_regulated_footer() -> str:
|
||||
return (
|
||||
"\n---\n\n"
|
||||
"## Document control (regulated profile)\n\n"
|
||||
"- **Version:** 1.0\n"
|
||||
"- **Effective date:** _(YYYY-MM-DD)_\n"
|
||||
"- **Next review date:** _(YYYY-MM-DD — within 12 months, "
|
||||
"or 90 days under HIPAA / ISO 13485)_\n"
|
||||
"- **Approval signoff (named):** _(QMR / Compliance Officer)_\n"
|
||||
"- **Change history:**\n\n"
|
||||
"| Version | Date | Author | Change summary | Approver |\n"
|
||||
"|---------|------|--------|----------------|----------|\n"
|
||||
"| 1.0 | _date_ | _author_ | Initial issue | _approver_ |\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_finance_footer() -> str:
|
||||
return (
|
||||
"\n---\n\n"
|
||||
"## Controls section (finance profile)\n\n"
|
||||
"- **Control objective:** _(what financial assertion this "
|
||||
"controls — e.g., completeness of vendor payments)_\n"
|
||||
"- **Segregation of duties:** Initiator, approver, and payer "
|
||||
"must be distinct individuals.\n"
|
||||
"- **Evidence retained:** _(invoice copy, approval email, "
|
||||
"payment confirmation)_\n"
|
||||
"- **Testing frequency:** Quarterly by Internal Audit.\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_hr_footer() -> str:
|
||||
return (
|
||||
"\n---\n\n"
|
||||
"## Privacy & sensitive-data handling (HR profile)\n\n"
|
||||
"- **Data classes touched:** _(name, address, SSN/national ID, "
|
||||
"compensation, medical, etc.)_\n"
|
||||
"- **Lawful basis for processing:** _(employment contract, "
|
||||
"legal obligation, legitimate interest, consent)_\n"
|
||||
"- **Retention period:** _(per local employment law + GDPR if "
|
||||
"applicable)_\n"
|
||||
"- **Access restriction:** Need-to-know basis only.\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_it_footer() -> str:
|
||||
return (
|
||||
"\n---\n\n"
|
||||
"## Change management (IT profile)\n\n"
|
||||
"- **Change type:** _(standard / normal / emergency)_\n"
|
||||
"- **Change ticket:** _(link to Jira / ServiceNow)_\n"
|
||||
"- **Rollback plan:** _(named rollback procedure)_\n"
|
||||
"- **Test evidence:** _(staging validation)_\n"
|
||||
"- **Communication plan:** _(who is notified pre/post change)_\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_support_footer() -> str:
|
||||
return (
|
||||
"\n---\n\n"
|
||||
"## Customer impact & escalation (support profile)\n\n"
|
||||
"- **Customer impact category:** _(none / single-customer / "
|
||||
"multi-customer / company-wide outage)_\n"
|
||||
"- **External comms required:** _(yes/no — if yes, link "
|
||||
"internal-comms cascade SOP)_\n"
|
||||
"- **Escalation matrix:**\n\n"
|
||||
"| Trigger | Escalate to | SLA |\n"
|
||||
"|---------|-------------|-----|\n"
|
||||
"| Customer-impact > 30 min | Engineering on-call | 5 min |\n"
|
||||
"| Multi-customer impact | Support Lead + VP Eng | 10 min |\n"
|
||||
"| External comms needed | Communications + CEO | 30 min |\n"
|
||||
)
|
||||
|
||||
|
||||
PROFILE_FOOTER = {
|
||||
"ops": "",
|
||||
"support": _build_support_footer(),
|
||||
"finance": _build_finance_footer(),
|
||||
"hr": _build_hr_footer(),
|
||||
"it": _build_it_footer(),
|
||||
"regulated": _build_regulated_footer(),
|
||||
}
|
||||
|
||||
|
||||
def generate_markdown(meta: SOPMetadata, profile: str) -> str:
|
||||
header = (
|
||||
f"# SOP: {meta.sop_name}\n\n"
|
||||
f"_Profile: `{profile}` | "
|
||||
f"Regulatory overlay: "
|
||||
f"{meta.regulatory_overlay or 'none'}_\n\n"
|
||||
"---\n\n"
|
||||
"## 5W2H scaffolding\n\n"
|
||||
"_(Ishikawa 1985, 5W2H method. Each section is required.)_\n"
|
||||
)
|
||||
body = "\n\n".join([
|
||||
_build_who(meta, profile),
|
||||
_build_what(meta),
|
||||
_build_when(meta),
|
||||
_build_where(meta, profile),
|
||||
_build_why(meta),
|
||||
_build_how(meta),
|
||||
_build_how_much(meta),
|
||||
])
|
||||
footer = PROFILE_FOOTER.get(profile, "")
|
||||
return header + "\n" + body + footer + "\n"
|
||||
|
||||
|
||||
def generate_json(meta: SOPMetadata, profile: str) -> dict:
|
||||
return {
|
||||
"sop_name": meta.sop_name,
|
||||
"profile": profile,
|
||||
"metadata": asdict(meta),
|
||||
"sections": {
|
||||
"who": "RACI populated",
|
||||
"what": f"{len(meta.inputs)} inputs / "
|
||||
f"{len(meta.outputs)} outputs",
|
||||
"when": meta.triggering_event,
|
||||
"where": "system of record + canonical doc location",
|
||||
"why": meta.regulatory_overlay or ["none"],
|
||||
"how": [{"step": i + 1, "title": s}
|
||||
for i, s in enumerate(meta.steps_outline)],
|
||||
"how_much": {
|
||||
"estimated_minutes": meta.estimated_minutes,
|
||||
"estimated_cost_usd": meta.estimated_cost_usd,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Generate a 5W2H-structured SOP from JSON metadata."
|
||||
)
|
||||
p.add_argument("--input", "-i", type=str,
|
||||
help="Path to SOP metadata JSON file.")
|
||||
p.add_argument("--profile", choices=sorted(VALID_PROFILES),
|
||||
default="ops",
|
||||
help="Industry profile (default: ops).")
|
||||
p.add_argument("--output", "-o", choices=["markdown", "json"],
|
||||
default="markdown",
|
||||
help="Output format (default: markdown).")
|
||||
p.add_argument("--sample", action="store_true",
|
||||
help="Print a sample vendor-offboarding SOP.")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.sample:
|
||||
data = _sample_metadata()
|
||||
elif args.input:
|
||||
path = Path(args.input)
|
||||
if not path.exists():
|
||||
print(f"ERROR: input file not found: {args.input}",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
data = json.loads(path.read_text())
|
||||
else:
|
||||
print("ERROR: provide --input <metadata.json> or --sample",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
meta = SOPMetadata(**data)
|
||||
errs = meta.validate()
|
||||
if errs:
|
||||
print("VALIDATION ERRORS:", file=sys.stderr)
|
||||
for e in errs:
|
||||
print(f" - {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.output == "json":
|
||||
print(json.dumps(generate_json(meta, args.profile), indent=2))
|
||||
else:
|
||||
print(generate_markdown(meta, args.profile))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: process-mapper
|
||||
description: Use when a BizOps lead, COO, or process-improvement owner needs to document an end-to-end business process (procurement, employee onboarding, incident handoff, customer-onboarding, claims adjudication) in BPMN-style notation, measure cycle times by stage, surface where work spends most of its time waiting vs. being worked, and quantify the gap between processing time and total elapsed time. Pairs Lean / Six Sigma / Theory-of-Constraints canon with deterministic stdlib-only Python tools to produce a process map, a ranked bottleneck list (with severity + root-cause hypothesis), and a cycle-time analysis (P50, P90, value-add ratio, Little's-Law throughput). Distinct from sales-pipeline, system-reliability (SLO), and strategic-OKR work — this is tactical process documentation for internal operations.
|
||||
version: 2.8.0
|
||||
author: claude-code-skills
|
||||
license: MIT
|
||||
tags: [bizops, process, bpmn, bottleneck, cycle-time, lean, six-sigma, value-stream]
|
||||
compatible_tools: [claude-code, codex-cli, cursor, antigravity, opencode, gemini-cli]
|
||||
---
|
||||
|
||||
# process-mapper
|
||||
|
||||
BPMN-style business process documentation, bottleneck detection, and cycle-time analysis for internal-operations leaders.
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal-operations work suffers from three recurring failure modes:
|
||||
|
||||
1. **Implicit process** — the steps exist only in tribal knowledge, so handoffs drop and onboarding takes weeks.
|
||||
2. **Invisible waiting** — most of the elapsed time on any business process is queue / wait / approval time, not actual work; teams optimize the wrong stage.
|
||||
3. **Local optimization** — Goldratt's Theory of Constraints is ignored; resources are added to non-constraint stages, gaining nothing.
|
||||
|
||||
This skill produces a documented process map, identifies where work waits, and points the constraint out by name with deterministic logic — not LLM intuition.
|
||||
|
||||
## When to use
|
||||
|
||||
- Documenting a new business process (procurement intake, vendor onboarding, employee onboarding, incident handoff, expense reimbursement, customer onboarding, claims adjudication).
|
||||
- An existing process is "too slow" but nobody can name the bottleneck.
|
||||
- Cycle time is being measured but value-add ratio is not — so the team can't tell whether the process is healthy or waste-heavy.
|
||||
- Cross-functional handoffs are dropping work and root cause is unclear.
|
||||
|
||||
## Workflow
|
||||
|
||||
Five-step deterministic flow:
|
||||
|
||||
1. **Intake.** Capture the process as a JSON file with one entry per stage: `name`, `owner`, `type` (`value-add` | `wait` | `rework`), `duration_minutes_p50`, `duration_minutes_p90`. Use `assets/process_template.md` and its JSON skeleton.
|
||||
2. **Map stages.** Run `process_documenter.py` to produce an ASCII swim-lane diagram + a normalized JSON artifact. The swim-lane separates lanes by owner so cross-functional handoffs become visible.
|
||||
3. **Measure cycle time.** Run `cycle_time_analyzer.py` to compute total P50, total P90, value-add ratio (VA%), and a Little's-Law throughput estimate. Verdict: VA% > 25% = HEALTHY, 10–25% = TYPICAL, < 10% = WASTE-HEAVY.
|
||||
4. **Detect bottlenecks.** Run `bottleneck_detector.py` with the appropriate `--profile` (saas / services / manufacturing / healthcare). Output is a ranked list with severity (CRITICAL / HIGH / MEDIUM), root-cause hypothesis, and one recommended action per finding.
|
||||
5. **Recommend.** Pair the bottleneck list with the cycle-time verdict; recommend a single constraint-focused intervention per Goldratt's "subordinate everything to the constraint" rule. Don't recommend optimization of a non-constraint stage.
|
||||
|
||||
## Scripts
|
||||
|
||||
**`scripts/process_documenter.py`** — Reads a process JSON, validates it, and emits a text-based BPMN-style swim-lane diagram in Markdown (lanes by owner, stages annotated with type + duration). Also outputs a normalized JSON artifact for downstream tools. Stdlib only. `--sample` prints a 6-stage procurement-intake example.
|
||||
|
||||
**`scripts/bottleneck_detector.py`** — Applies three deterministic detection rules: (a) stage P50 > 2× mean of value-add stages, (b) wait-state % > 40% of total cycle, (c) rework % > 15%. Thresholds adjust by `--profile` because SaaS, services, manufacturing, and healthcare have different "normal" wait ratios. Output is a ranked list with severity, hypothesis, action.
|
||||
|
||||
**`scripts/cycle_time_analyzer.py`** — Computes total P50 and P90 cycle time, value-add ratio (VA%), wait %, rework %, and a Little's-Law throughput estimate (WIP / cycle time). Per Lean canon: VA% > 25% = HEALTHY, 10–25% = TYPICAL (most non-manufacturing processes land here), < 10% = WASTE-HEAVY.
|
||||
|
||||
## Quick example
|
||||
|
||||
```bash
|
||||
# Renders a BPMN-style swim-lane diagram + normalized JSON for the built-in 6-stage procurement-intake example
|
||||
cd business-operations/skills/process-mapper && python3 scripts/process_documenter.py --sample
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- `references/lean_six_sigma_canon.md` — TIMWOOD wastes, value-stream mapping, Theory of Constraints, Kanban WIP, Little's Law. Cites Womack & Jones, Rother & Shook, Goldratt, Ohno, Liker, Pyzdek, Anderson.
|
||||
- `references/bpmn_essentials.md` — Pools, lanes, gateways, events, message flows, common notation mistakes. Cites the OMG BPMN 2.0 spec, Silver, Allweyer, Freund/Rücker, OASIS, ISO/IEC 19510:2013.
|
||||
- `references/bottleneck_anti_patterns.md` — Seven specific anti-patterns drawn from Goldratt, Kim et al., Spear, DORA, Deming, and process-mining research.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. The user can provide stage-level cycle-time data (even rough P50 / P90 estimates). If they cannot, the first step is to instrument the process — not to map it.
|
||||
2. "Process" here means a repeatable business workflow with discrete stages, not a one-off project.
|
||||
3. The user has authority to act on bottlenecks (or can route findings to someone who does). Without that, the output is academic.
|
||||
4. Stage `type` is honest: a "value-add" stage labeled as such by the user really does change the work product from the customer's perspective. Mis-labelling waiting as value-add is the most common data-quality failure.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Mapping every process at once.** Pick one. Goldratt: the constraint is a single point.
|
||||
- **Optimizing the non-constraint.** If stage 4 is the bottleneck, speeding up stage 2 just builds inventory in front of stage 4. Subordinate everything to the constraint.
|
||||
- **Mistaking total cycle time for processing time.** They are almost never the same; VA% reveals the gap.
|
||||
- **Adding people to a wait-bound process.** Wait time is not solved by more headcount; it's solved by removing the handoff or batch.
|
||||
- **Treating rework as a separate problem.** Rework loops belong in the process map. Hiding them understates true cycle time.
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **business-growth skills** — external sales motion, lead-funnel conversion, customer-success retention. Process-mapper is *internal* operations.
|
||||
- **engineering/slo-architect** — system-reliability SLOs / error budgets / burn-rate alerts. Process-mapper is *business-process* cycle time, not system uptime.
|
||||
- **c-level-advisor (COO / CEO)** — strategic prioritization of which processes to fix. Process-mapper is the tactical instrument used after that prioritization decision.
|
||||
- **project-management skills** — Jira / Confluence ticket workflow tooling. Process-mapper is process *design*, not ticket *tracking*.
|
||||
|
||||
## 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. **"Do you have measured cycle times for the top-3 longest stages, or only estimates?"**
|
||||
Recommended: insist on measured data.
|
||||
Canon: Goldratt 1984 (*The Goal*) — optimizing estimated bottlenecks reliably attacks the wrong constraint.
|
||||
|
||||
2. **"Are you mapping the *current* process (as-is) or the *intended* process (to-be)?"**
|
||||
Recommended: map as-is first. To-be after bottleneck is identified.
|
||||
Canon: Rother & Shook 1999 (*Learning to See*) — value-stream mapping starts with the current state, always.
|
||||
|
||||
3. **"Where do handoffs occur between teams, and how long does each handoff wait?"**
|
||||
Recommended: log every handoff with median wait time.
|
||||
Canon: Reinertsen 2009 (*Principles of Product Development Flow*) — wait time at handoffs is the largest invisible cost.
|
||||
|
||||
4. **"What's your batch size at each stage?"**
|
||||
Recommended: drive batch size toward 1 wherever possible.
|
||||
Canon: Anderson 2010 (*Kanban*) — batch size correlates 1:1 with cycle time variance.
|
||||
|
||||
5. **"What's the rework rate per stage?"**
|
||||
Recommended: surface it explicitly; rework loops belong in the map.
|
||||
Canon: Pyzdek (*Six Sigma Handbook*) — hidden rework drives 30-50% of total cycle time in service processes.
|
||||
|
||||
Walk depth-first. Don't open question 4 before 1-3 are answered. After all 5 are locked, invoke `process_documenter.py` → `bottleneck_detector.py` → `cycle_time_analyzer.py` in sequence.
|
||||
@@ -0,0 +1,124 @@
|
||||
# Process Template
|
||||
|
||||
Use this template to document a business process before running it through
|
||||
the process-mapper tools. Fill in the stage table first, then translate it
|
||||
into the JSON skeleton at the bottom of this file. Feed that JSON into the
|
||||
three CLI tools:
|
||||
|
||||
```
|
||||
python3 scripts/process_documenter.py --input my-process.json
|
||||
python3 scripts/bottleneck_detector.py --input my-process.json --profile saas
|
||||
python3 scripts/cycle_time_analyzer.py --input my-process.json --profile saas
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Process metadata
|
||||
|
||||
- **Process name:** _(e.g., Procurement Intake, Employee Onboarding, Incident Handoff)_
|
||||
- **Owner role:** _(who is accountable for the end-to-end process)_
|
||||
- **Frequency:** _(how often this process runs — daily, weekly, on-demand)_
|
||||
- **Trigger event:** _(what starts the process)_
|
||||
- **End state:** _(what marks the process complete)_
|
||||
- **WIP at any time:** _(how many items are typically in process at once; needed for Little's-Law throughput)_
|
||||
|
||||
---
|
||||
|
||||
## Stage table
|
||||
|
||||
Six rows to start. Add or remove as needed. **Honesty about stage `type` is
|
||||
the single most important data-quality choice.** If a stage is queue / wait,
|
||||
mark it `wait`. If it changes the work product from the customer's
|
||||
perspective, mark it `value-add`. If it exists to fix an upstream defect,
|
||||
mark it `rework`.
|
||||
|
||||
| # | Stage name | Owner (role) | Type | P50 (min) | P90 (min) | Notes |
|
||||
|---|------------|--------------|------|-----------|-----------|-------|
|
||||
| 1 | _e.g., Submit request_ | Requestor | value-add | 15 | 30 | |
|
||||
| 2 | _e.g., Wait for manager approval queue_ | Manager | wait | 480 | 1440 | Typically batched |
|
||||
| 3 | _e.g., Manager approves_ | Manager | value-add | 10 | 25 | |
|
||||
| 4 | _e.g., Wait for finance review_ | Finance | wait | 720 | 2880 | |
|
||||
| 5 | _e.g., Finance validates budget code_ | Finance | value-add | 20 | 60 | |
|
||||
| 6 | _e.g., Rework — missing vendor W-9_ | Requestor | rework | 120 | 360 | Frequent escape |
|
||||
|
||||
**Type definitions (Lean canon):**
|
||||
|
||||
- `value-add` — the stage changes the work product in a way the end customer
|
||||
would willingly pay for. Most stages are NOT value-add.
|
||||
- `wait` — work is queued, idle, or waiting for someone. Wait stages are the
|
||||
largest source of cycle-time bloat in most office processes.
|
||||
- `rework` — the stage exists to fix a defect introduced upstream. Six-Sigma
|
||||
canon: rework is always an upstream-quality problem.
|
||||
|
||||
---
|
||||
|
||||
## JSON skeleton
|
||||
|
||||
Copy this into `my-process.json`, edit the values to match your stage table,
|
||||
and pass it to the CLI tools.
|
||||
|
||||
```json
|
||||
{
|
||||
"process_name": "Replace with your process name",
|
||||
"wip": 12,
|
||||
"stages": [
|
||||
{
|
||||
"name": "Stage 1 name",
|
||||
"owner": "Owning role",
|
||||
"type": "value-add",
|
||||
"duration_minutes_p50": 15,
|
||||
"duration_minutes_p90": 30
|
||||
},
|
||||
{
|
||||
"name": "Stage 2 name",
|
||||
"owner": "Owning role",
|
||||
"type": "wait",
|
||||
"duration_minutes_p50": 480,
|
||||
"duration_minutes_p90": 1440
|
||||
},
|
||||
{
|
||||
"name": "Stage 3 name",
|
||||
"owner": "Owning role",
|
||||
"type": "value-add",
|
||||
"duration_minutes_p50": 10,
|
||||
"duration_minutes_p90": 25
|
||||
},
|
||||
{
|
||||
"name": "Stage 4 name",
|
||||
"owner": "Owning role",
|
||||
"type": "wait",
|
||||
"duration_minutes_p50": 720,
|
||||
"duration_minutes_p90": 2880
|
||||
},
|
||||
{
|
||||
"name": "Stage 5 name",
|
||||
"owner": "Owning role",
|
||||
"type": "value-add",
|
||||
"duration_minutes_p50": 20,
|
||||
"duration_minutes_p90": 60
|
||||
},
|
||||
{
|
||||
"name": "Stage 6 name",
|
||||
"owner": "Owning role",
|
||||
"type": "rework",
|
||||
"duration_minutes_p50": 120,
|
||||
"duration_minutes_p90": 360
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **Use real data when you can.** Pull stage durations from your ticket system
|
||||
(Jira, ServiceNow, Zendesk). Estimated durations are fine for a first pass
|
||||
but should be replaced before any change decision is made.
|
||||
- **One process at a time.** Goldratt: every system has exactly one binding
|
||||
constraint. Mapping ten processes simultaneously dilutes attention away
|
||||
from the one that's actually limiting throughput.
|
||||
- **Profile choice matters.** Pass `--profile manufacturing` for physical-goods
|
||||
flows, `--profile services` for human-delivered services with longer
|
||||
acceptable wait times, `--profile healthcare` for clinical or regulated
|
||||
human-in-the-loop flows, `--profile saas` for everything else.
|
||||
@@ -0,0 +1,178 @@
|
||||
# Bottleneck Anti-Patterns
|
||||
|
||||
Seven plus specific anti-patterns that recur in business-process improvement
|
||||
work. Each is sourced to primary literature, and each has a corresponding
|
||||
detection or recommendation in the skill's tools.
|
||||
|
||||
## Sources
|
||||
|
||||
1. **Goldratt, E. M. (1984). _The Goal._** North River Press. — Theory of Constraints.
|
||||
2. **Kim, G., Behr, K. & Spafford, G. (2013). _The Phoenix Project: A Novel About IT, DevOps, and Helping Your Business Win._** IT Revolution Press. — TOC applied to IT operations.
|
||||
3. **Spear, S. J. (2009). _The High-Velocity Edge._** McGraw-Hill. — Toyota-derived discipline for complex operations; explicit treatment of why local optimization fails.
|
||||
4. **Forsgren, N., Humble, J. & Kim, G. (2018). _Accelerate: The Science of Lean Software and DevOps._** IT Revolution. — DORA research; empirical link between flow metrics and outcomes.
|
||||
5. **Deming, W. E. (1986). _Out of the Crisis._** MIT Press. — System-of-profound-knowledge framework; root-cause discipline.
|
||||
6. **van der Aalst, W. M. P. (2016). _Process Mining: Data Science in Action,_ 2nd ed.** Springer. — Empirical methodology for discovering actual process behavior vs. documented behavior.
|
||||
7. **Reinertsen, D. G. (2009). _The Principles of Product Development Flow._** Celeritas Publishing. — Queueing theory and cost of delay.
|
||||
8. **Forrester Research. (Multiple years.) _Process Mining: Vendor and Market Analyses._** — Industry research on process-mining adoption and the gap between modeled and actual process.
|
||||
|
||||
---
|
||||
|
||||
## AP-1. Optimizing the non-constraint
|
||||
|
||||
**Source:** Goldratt (1984), Kim et al. (2013).
|
||||
|
||||
A team identifies that stage 2 of a process is "slow" (relative to other
|
||||
non-constraint stages) and optimizes it. The actual constraint is stage 4.
|
||||
Result: throughput is unchanged; inventory grows in front of stage 4.
|
||||
|
||||
**Detection:** Compare every stage's P50 to the value-add mean (Rule R1) but
|
||||
weight the recommendation by impact on total cycle. The skill's
|
||||
`bottleneck_detector.py` ranks by impact_minutes_p50 specifically to direct
|
||||
attention to the binding constraint.
|
||||
|
||||
**Counter-pattern:** Always solve the longest wait or longest stage first;
|
||||
ignore "quick wins" elsewhere until the constraint moves.
|
||||
|
||||
---
|
||||
|
||||
## AP-2. Adding resources before identifying the constraint
|
||||
|
||||
**Source:** Goldratt (1984), Reinertsen (2009).
|
||||
|
||||
Symptom: "We need to hire more procurement analysts." Reality: the analysts
|
||||
are not the constraint; manager approval queues are. Adding analysts increases
|
||||
WIP, lengthens cycle time (per Little's Law), and makes the queue worse.
|
||||
|
||||
**Detection:** Rule R2 (wait-share > 40%) catches the case where the wait —
|
||||
not capacity — dominates.
|
||||
|
||||
**Counter-pattern:** First check whether wait time exceeds value-add time. If
|
||||
it does, no amount of new staffing will help. Remove the handoff, parallelize
|
||||
the approval, or apply WIP limits.
|
||||
|
||||
---
|
||||
|
||||
## AP-3. Mistaking wait time for processing time
|
||||
|
||||
**Source:** Rother & Shook (1999), Deming (1986).
|
||||
|
||||
A team reports that "manager approval takes two days." On inspection, the
|
||||
manager spends 10 minutes reviewing each request; the rest is queue time.
|
||||
Process time is 10 minutes; lead time is two days. Treating them as the same
|
||||
hides the real problem.
|
||||
|
||||
**Detection:** The skill's stage `type` field separates `value-add` from
|
||||
`wait`. The value-add ratio (VA%) in `cycle_time_analyzer.py` quantifies the
|
||||
gap.
|
||||
|
||||
**Counter-pattern:** Force stages to declare their type honestly. Any stage
|
||||
where the worker is not actively engaged is a wait stage, regardless of who
|
||||
"owns" it.
|
||||
|
||||
---
|
||||
|
||||
## AP-4. Inspection-as-quality
|
||||
|
||||
**Source:** Pyzdek (Six Sigma Handbook), Deming (1986), Spear (2009).
|
||||
|
||||
Defects keep escaping, so the team adds a final QA review. The defects don't
|
||||
go down (the upstream stages haven't changed) — but cycle time goes up because
|
||||
of the new stage. Worse, the QA reviewer is now blamed for misses.
|
||||
|
||||
**Detection:** Rule R3 (rework share > 15%) with the hypothesis "defects
|
||||
escape upstream stages."
|
||||
|
||||
**Counter-pattern:** Find the earliest stage that could detect the defect; add
|
||||
the check there (poka-yoke). Stop the line on detection; don't queue defects
|
||||
for downstream rework.
|
||||
|
||||
---
|
||||
|
||||
## AP-5. Optimizing the documented process, not the actual one
|
||||
|
||||
**Source:** van der Aalst (2016), Forrester process-mining reports.
|
||||
|
||||
The team documents the "official" process and optimizes it. Process-mining
|
||||
tools then reveal that 60% of cases skip stages, loop back, or take undocumented
|
||||
routes. The optimization had no effect because it targeted a fiction.
|
||||
|
||||
**Detection:** The skill cannot detect this from the input JSON alone — it
|
||||
relies on the user to report actual stage durations from real cases, not
|
||||
target durations. The "Assumptions" section in SKILL.md surfaces this
|
||||
explicitly.
|
||||
|
||||
**Counter-pattern:** Use ticket-system data, time-stamps, or event logs to
|
||||
ground stage durations in actual cases. If the data isn't available, the
|
||||
first step is instrumentation, not mapping.
|
||||
|
||||
---
|
||||
|
||||
## AP-6. Batched approvals as the default
|
||||
|
||||
**Source:** Reinertsen (2009), Anderson (Kanban, 2010).
|
||||
|
||||
Approvers batch requests: "I'll review everyone's POs on Friday afternoon."
|
||||
This adds half the batch interval (typically 3–4 days) to the average wait
|
||||
time of every request, with no quality benefit.
|
||||
|
||||
**Detection:** Wait stages with P50 durations measured in days (hundreds of
|
||||
minutes) are almost always batched. The skill flags them via R1 and R2.
|
||||
|
||||
**Counter-pattern:** Move to continuous-flow approval. If continuous is
|
||||
infeasible (e.g., a committee that meets weekly), at least shrink the batch
|
||||
interval or move approval to a lower level where it can run continuously.
|
||||
|
||||
---
|
||||
|
||||
## AP-7. Local efficiency metrics
|
||||
|
||||
**Source:** Goldratt (1984), Deming (1986), Spear (2009).
|
||||
|
||||
Each stage is measured on its own efficiency (e.g., "manager handles 95% of
|
||||
requests within SLA"). The system as a whole is not measured. Each role
|
||||
optimizes locally, pushing work as fast as possible to the next queue —
|
||||
which is exactly where it stalls.
|
||||
|
||||
**Detection:** The skill's verdict is always at the **process** level (VA%,
|
||||
total cycle time), never at the stage level. The `bottleneck_detector.py`
|
||||
recommendation text explicitly invokes Goldratt's "subordinate everything to
|
||||
the constraint."
|
||||
|
||||
**Counter-pattern:** Measure throughput and total cycle time at the process
|
||||
level. Stage-level metrics are diagnostic, not goal-setting.
|
||||
|
||||
---
|
||||
|
||||
## AP-8. Skipping the value-stream map and going straight to automation
|
||||
|
||||
**Source:** Kim et al. (2013), Forrester process-mining research.
|
||||
|
||||
A team buys an RPA / workflow automation tool, then automates the existing
|
||||
broken process. Result: the bad process now runs faster, with the same wait
|
||||
queues and same rework rate. Goldratt's term for this is "automating the
|
||||
mess."
|
||||
|
||||
**Detection:** Outside the skill's automated detection; surfaced in
|
||||
SKILL.md's "Anti-patterns" list.
|
||||
|
||||
**Counter-pattern:** Map the value stream first. Eliminate wait and rework
|
||||
stages. Then — and only then — consider automating what remains.
|
||||
|
||||
---
|
||||
|
||||
## AP-9. Treating cycle time as fixed
|
||||
|
||||
**Source:** Forsgren, Humble & Kim (2018, _Accelerate_).
|
||||
|
||||
A team reports cycle time as a single number ("it takes 5 days"). Real cycle
|
||||
times are distributions, often log-normal, with heavy P90 / P99 tails. A 5-day
|
||||
P50 with a 30-day P90 is a wildly different process than a 5-day P50 with a
|
||||
6-day P90; the first is unpredictable, the second is reliable.
|
||||
|
||||
**Detection:** The skill captures both P50 and P90 per stage and reports
|
||||
both totals. A large P90 / P50 ratio in `cycle_time_analyzer.py` is a flag
|
||||
for high variability even when total cycle time looks acceptable.
|
||||
|
||||
**Counter-pattern:** Always quote P50 and P90 (or P50 and P95). DORA's
|
||||
_Accelerate_ research finds that lead-time **variability** correlates with
|
||||
business outcomes as strongly as median lead time.
|
||||
@@ -0,0 +1,119 @@
|
||||
# BPMN Essentials for Business-Process Documentation
|
||||
|
||||
A practical reference on BPMN (Business Process Model and Notation) for
|
||||
process-mapper users. The skill emits text-based swim-lane diagrams that
|
||||
approximate the BPMN structure without requiring users to install Visio,
|
||||
Lucidchart, or Camunda. This file explains the canon those diagrams reflect.
|
||||
|
||||
## Sources
|
||||
|
||||
1. **Object Management Group. (2011). _Business Process Model and Notation (BPMN), Version 2.0._** OMG Document Number formal/2011-01-03. — The normative specification.
|
||||
2. **Silver, B. (2011). _BPMN Method and Style,_ 2nd ed.** Cody-Cassidy Press. — The canonical practitioner book; defines the "method and style" rules now widely treated as informal BPMN convention.
|
||||
3. **Allweyer, T. (2010). _BPMN 2.0: Introduction to the Standard for Business Process Modeling._** Books on Demand. — Approachable academic introduction.
|
||||
4. **Freund, J. & Rücker, B. (2019). _Real-Life BPMN,_ 4th ed.** CreateSpace. — Practical patterns from the Camunda team.
|
||||
5. **OASIS. (2010). _Web Services Business Process Execution Language (WS-BPEL), Version 2.0._** — Related execution standard; clarifies the interplay between BPMN modeling and BPEL execution.
|
||||
6. **ISO/IEC 19510:2013. _Information technology — Object Management Group Business Process Model and Notation._** — The international-standard version of OMG BPMN 2.0.
|
||||
7. **Recker, J. (2010). "Opportunities and constraints: the current struggle with BPMN." _Business Process Management Journal,_ 16(1), 181–201.** — Peer-reviewed analysis of BPMN adoption pain points; sources the "common notation mistakes" list below.
|
||||
8. **Dumas, M., La Rosa, M., Mendling, J. & Reijers, H. A. (2018). _Fundamentals of Business Process Management,_ 2nd ed.** Springer. — Textbook covering BPMN within the broader BPM lifecycle.
|
||||
|
||||
---
|
||||
|
||||
## Core BPMN elements
|
||||
|
||||
BPMN has hundreds of symbols. In practice, ~80% of useful diagrams use only
|
||||
~10 of them. The skill's swim-lane output uses precisely these.
|
||||
|
||||
### Flow objects
|
||||
|
||||
- **Activity (task)** — a unit of work done by one role. Rectangle with rounded
|
||||
corners. In the skill's swim-lane: this is a `value-add` or `rework` stage.
|
||||
- **Event** — something that happens (start, intermediate, end). Circles. The
|
||||
skill represents start/end implicitly as the first and last stage.
|
||||
- **Gateway** — branching / merging point. Diamond. Common types:
|
||||
- **Exclusive (XOR)** — one path taken.
|
||||
- **Parallel (AND)** — all paths taken.
|
||||
- **Inclusive (OR)** — one or more paths taken based on data.
|
||||
|
||||
### Connecting objects
|
||||
|
||||
- **Sequence flow** — solid arrow inside one pool. The skill renders these as
|
||||
`->` between stages in the lane.
|
||||
- **Message flow** — dashed arrow across pool boundaries. The skill's
|
||||
cross-lane handoffs (e.g., Requestor -> Manager) are message-flow-equivalents.
|
||||
- **Association** — dotted line linking a data object to an activity.
|
||||
|
||||
### Swim lanes
|
||||
|
||||
- **Pool** — represents a participant (a company, a department, or a system).
|
||||
Each pool is independent; communication between pools uses message flow only.
|
||||
- **Lane** — a sub-partition within a pool, usually a role or sub-team.
|
||||
|
||||
The skill maps one stage's `owner` field to one lane. The full diagram is a
|
||||
single pool with multiple lanes — appropriate for an internal business process
|
||||
where one organization controls the whole flow.
|
||||
|
||||
---
|
||||
|
||||
## Method and Style rules (Silver)
|
||||
|
||||
Silver's "Method and Style" is a set of practitioner conventions that make
|
||||
BPMN diagrams readable. The most load-bearing rules:
|
||||
|
||||
1. **One start, one end** per pool. Multiple end events are allowed only if
|
||||
they represent different end-states (e.g., approved vs. rejected).
|
||||
2. **Label every flow out of a gateway** with the condition (e.g., "amount >
|
||||
$10K"). An unlabeled gateway is unreadable.
|
||||
3. **Sequence flow stays inside a pool.** Use message flow between pools.
|
||||
4. **One verb-noun task name.** "Approve PO" beats "Approval step."
|
||||
5. **Black-box pools** for participants you don't model in detail (e.g., the
|
||||
customer). Show only the message exchanges with them.
|
||||
|
||||
The skill enforces rule #4 implicitly by encouraging "Stage" names like
|
||||
"Manager approves request" rather than "Approval."
|
||||
|
||||
---
|
||||
|
||||
## Common notation mistakes (Recker 2010; Freund/Rücker)
|
||||
|
||||
The following errors appear in over half of real-world BPMN diagrams:
|
||||
|
||||
| Mistake | Why it's wrong | What to do |
|
||||
|---------|----------------|------------|
|
||||
| Using sequence flow across pools | Pools are independent; only messages cross | Use dashed message flow |
|
||||
| Missing gateway labels | The reader can't tell which path is taken when | Label every outbound flow |
|
||||
| Multiple unrelated end events | Reader can't tell why a process ends in each spot | Consolidate or label by end-state |
|
||||
| Conflating role with system | "JIRA" is a system, not a role; "Engineering Manager" is a role | Lanes = roles, not tools |
|
||||
| Implicit gateways | Diverging sequence flows without a gateway diamond | Add an explicit XOR or parallel gateway |
|
||||
| Modeling exceptions inline | Cluttered happy path | Use boundary events or a separate exception sub-process |
|
||||
| No data objects | Reader doesn't know what artifacts move through | Add data-object boxes where they help |
|
||||
|
||||
The skill's stage-level `type` field (`value-add` | `wait` | `rework`) captures
|
||||
the rework case explicitly so it doesn't get hidden inline. Users who want
|
||||
full BPMN fidelity should export the normalized JSON and ingest it into a
|
||||
BPMN-aware tool (Camunda Modeler, bpmn.io, Signavio).
|
||||
|
||||
---
|
||||
|
||||
## When to use BPMN vs. simpler notations
|
||||
|
||||
BPMN is appropriate when:
|
||||
|
||||
- The process has cross-functional handoffs (multiple lanes).
|
||||
- The process has branching logic (gateways).
|
||||
- The diagram will be reviewed by people who don't sit through a walkthrough.
|
||||
|
||||
For purely linear processes with no branching, a numbered list or a value
|
||||
stream map is faster to produce and easier to read. The skill's swim-lane
|
||||
output deliberately occupies the middle ground: more structured than a list,
|
||||
less ceremony than full BPMN.
|
||||
|
||||
---
|
||||
|
||||
## BPMN 2.0 execution semantics
|
||||
|
||||
ISO/IEC 19510:2013 specifies executable semantics so that a BPMN diagram can
|
||||
be loaded into a workflow engine (Camunda, jBPM, Activiti) and run directly.
|
||||
The skill does not target executable BPMN — its output is for human reading
|
||||
and constraint analysis. If a user wants to move from documentation to
|
||||
automation, the normalized JSON is a starting point; mapping to the BPMN 2.0
|
||||
XML schema is a separate exercise.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Lean / Six Sigma / Theory-of-Constraints Canon
|
||||
|
||||
A working reference for the process-mapper skill. The concepts below are the
|
||||
intellectual foundation for every detection rule and verdict band the skill
|
||||
emits. Citations are deliberately to the primary sources, not blog posts.
|
||||
|
||||
## Sources
|
||||
|
||||
1. **Womack, J. P. & Jones, D. T. (1996). _Lean Thinking: Banish Waste and Create Wealth in Your Corporation._** Free Press. — The five-step Lean discipline: specify value, identify the value stream, make value flow, let the customer pull, pursue perfection.
|
||||
2. **Rother, M. & Shook, J. (1999). _Learning to See: Value Stream Mapping to Add Value and Eliminate Muda._** Lean Enterprise Institute. — The canonical text on Value Stream Mapping (VSM); origin of current-state / future-state map distinction.
|
||||
3. **Goldratt, E. M. (1984). _The Goal: A Process of Ongoing Improvement._** North River Press. — The Theory of Constraints: identify, exploit, subordinate, elevate, repeat. Every process has exactly one binding constraint at a time.
|
||||
4. **Ohno, T. (1988). _Toyota Production System: Beyond Large-Scale Production._** Productivity Press. — Origin of the seven wastes (muda), pull system, jidoka, and andon discipline.
|
||||
5. **Liker, J. K. (2004). _The Toyota Way: 14 Management Principles from the World's Greatest Manufacturer._** McGraw-Hill. — Modern systemic treatment of TPS principles for non-manufacturing operations.
|
||||
6. **Pyzdek, T. & Keller, P. (2018). _The Six Sigma Handbook,_ 5th ed.** McGraw-Hill. — DMAIC discipline, SIPOC, process-capability indices, defect-rate measurement.
|
||||
7. **Anderson, D. J. (2010). _Kanban: Successful Evolutionary Change for Your Technology Business._** Blue Hole Press. — WIP limits, pull system applied to knowledge work, cumulative flow diagrams.
|
||||
8. **Reinertsen, D. G. (2009). _The Principles of Product Development Flow._** Celeritas Publishing. — Queueing theory for knowledge-work product development; cost of delay.
|
||||
|
||||
---
|
||||
|
||||
## The Seven Wastes (TIMWOOD)
|
||||
|
||||
Ohno's original taxonomy, with the eighth ("non-utilized talent") added later:
|
||||
|
||||
| Code | Waste | What it looks like in business processes |
|
||||
|------|-------|--------------------------------------------|
|
||||
| **T** | Transport | Moving work between systems / inboxes / queues for no reason |
|
||||
| **I** | Inventory | Backlogs of pending tickets, unprocessed invoices, open POs |
|
||||
| **M** | Motion | People hunting for information, switching tools, reading email threads to reconstruct context |
|
||||
| **W** | Waiting | Work sitting in someone's queue (the largest waste in office work) |
|
||||
| **O** | Over-production | Producing forecasts, reports, or work nobody requested |
|
||||
| **O** | Over-processing | Approval chains that add no scrutiny, gold-plating |
|
||||
| **D** | Defects | Errors that force rework downstream |
|
||||
| **(N)** | Non-utilized talent | Skilled people doing low-skill work |
|
||||
|
||||
The process-mapper skill identifies these via stage `type`: `wait` captures
|
||||
**W** (and often **I**); `rework` captures **D**. Mis-labelling a wait stage as
|
||||
`value-add` is the most common data-quality failure and will mask the true
|
||||
constraint.
|
||||
|
||||
---
|
||||
|
||||
## Value Stream Mapping (Rother & Shook)
|
||||
|
||||
VSM separates **process time** (PT) from **lead time** (LT). For each stage:
|
||||
|
||||
- **PT** = the time work actually spends being touched.
|
||||
- **LT** = the elapsed wall-clock time from when work arrives at the stage to
|
||||
when it leaves.
|
||||
|
||||
In the process-mapper schema, a `value-add` stage's `duration_minutes_p50` is
|
||||
PT-like; a `wait` stage's duration is the LT component between PT-stages.
|
||||
|
||||
The **process cycle efficiency** (PCE) is:
|
||||
|
||||
PCE = Total value-add time / Total lead time
|
||||
|
||||
This is exactly what `cycle_time_analyzer.py` computes as the "value-add ratio."
|
||||
Rother & Shook's published benchmarks: office processes typically score
|
||||
PCE < 10%; well-run service operations land 10–25%; world-class manufacturing
|
||||
can clear 25–40%.
|
||||
|
||||
---
|
||||
|
||||
## Theory of Constraints (Goldratt)
|
||||
|
||||
Goldratt's Five Focusing Steps:
|
||||
|
||||
1. **Identify** the constraint.
|
||||
2. **Exploit** it (squeeze every minute of capacity from the constraint).
|
||||
3. **Subordinate** everything else to the constraint.
|
||||
4. **Elevate** the constraint (only after step 2 is exhausted, add capacity).
|
||||
5. **Repeat** — once the constraint moves, return to step 1.
|
||||
|
||||
Two implications used in the skill:
|
||||
|
||||
- **Optimizing a non-constraint stage produces no system improvement.** It
|
||||
builds inventory in front of the constraint. The `bottleneck_detector.py`
|
||||
output is ranked by impact specifically so users target the constraint
|
||||
first.
|
||||
- **The constraint is almost always a wait stage in office work.** This is
|
||||
why Rule R2 (wait-share > 40%) is heavily weighted.
|
||||
|
||||
---
|
||||
|
||||
## Kanban WIP Limits (Anderson)
|
||||
|
||||
Little's Law:
|
||||
|
||||
L = lambda * W
|
||||
|
||||
Where L = items in the system (WIP), lambda = throughput (items per unit time),
|
||||
and W = average cycle time. Rearranged:
|
||||
|
||||
lambda = L / W
|
||||
|
||||
Two practical consequences:
|
||||
|
||||
- **Cycle time scales linearly with WIP.** Cutting WIP in half cuts cycle time
|
||||
in half (other things equal). This is why the skill computes throughput from
|
||||
WIP / cycle time and surfaces a WIP-limit recommendation when wait-share is
|
||||
high.
|
||||
- **Adding people to a wait-bound process makes it worse.** New workers add
|
||||
WIP without expanding the constraint, lengthening cycle time. The
|
||||
`bottleneck_detector` action text says this explicitly.
|
||||
|
||||
---
|
||||
|
||||
## Six Sigma DMAIC and Rework
|
||||
|
||||
Pyzdek's DMAIC (Define, Measure, Analyze, Improve, Control) treats rework as
|
||||
a downstream symptom of an upstream defect. The Six-Sigma rule the skill
|
||||
encodes: **rework is always solved upstream, never downstream.** Adding a
|
||||
quality-control inspector at the end of the line catches defects but doesn't
|
||||
prevent them, and inspection-as-quality is itself a TIMWOOD waste
|
||||
(over-processing).
|
||||
|
||||
The poka-yoke (error-proofing) recommendation in Rule R3 follows directly:
|
||||
add the check at the earliest stage that can detect the defect.
|
||||
|
||||
---
|
||||
|
||||
## Reinertsen's Queueing Insights
|
||||
|
||||
Reinertsen's _Principles of Product Development Flow_ adapts manufacturing
|
||||
queueing theory to knowledge work. Key results used in the skill:
|
||||
|
||||
- **High utilization explodes queue length.** A worker at 90% utilization has
|
||||
~10x the queue of a worker at 50% utilization. Office workflows that pin
|
||||
approvers at 100% utilization see wait stages grow without bound.
|
||||
- **Small batches cut queue time.** Batched approvals (e.g., weekly review
|
||||
cycles) inflate P50 wait times by half the batch interval on average.
|
||||
|
||||
When the skill recommends "remove the handoff or batch," this is the canon
|
||||
behind it.
|
||||
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""bottleneck_detector.py
|
||||
|
||||
Apply three deterministic detection rules to a process JSON and emit a ranked
|
||||
list of bottlenecks with severity, root-cause hypothesis, and a recommended
|
||||
action.
|
||||
|
||||
Rules (defaults; tuned per industry profile):
|
||||
R1. Stage P50 > 2x mean of value-add stages -> stage bottleneck
|
||||
R2. Wait-state share of total cycle > 40% -> handoff bottleneck
|
||||
R3. Rework share of total cycle > 15% -> quality bottleneck
|
||||
|
||||
Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Per-industry threshold calibration. Manufacturing tolerates less wait;
|
||||
# healthcare and services tolerate more given regulatory / human-in-the-loop steps.
|
||||
PROFILES: dict[str, dict[str, float]] = {
|
||||
"saas": {
|
||||
"stage_multiplier": 2.0,
|
||||
"wait_share_max": 0.40,
|
||||
"rework_share_max": 0.15,
|
||||
},
|
||||
"services": {
|
||||
"stage_multiplier": 2.5,
|
||||
"wait_share_max": 0.50,
|
||||
"rework_share_max": 0.15,
|
||||
},
|
||||
"manufacturing": {
|
||||
"stage_multiplier": 1.8,
|
||||
"wait_share_max": 0.30,
|
||||
"rework_share_max": 0.10,
|
||||
},
|
||||
"healthcare": {
|
||||
"stage_multiplier": 2.5,
|
||||
"wait_share_max": 0.55,
|
||||
"rework_share_max": 0.12,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
severity: str # CRITICAL | HIGH | MEDIUM
|
||||
rule: str # R1 | R2 | R3
|
||||
title: str
|
||||
detail: str
|
||||
hypothesis: str
|
||||
action: str
|
||||
impact_minutes_p50: float
|
||||
|
||||
def severity_rank(self) -> int:
|
||||
return {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2}.get(self.severity, 3)
|
||||
|
||||
|
||||
def load(path: Path) -> dict:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def classify_severity(share: float, threshold: float) -> str:
|
||||
"""Severity based on how far over the threshold the offender is."""
|
||||
if share <= threshold:
|
||||
return "MEDIUM"
|
||||
if share >= threshold * 2:
|
||||
return "CRITICAL"
|
||||
if share >= threshold * 1.5:
|
||||
return "HIGH"
|
||||
return "MEDIUM"
|
||||
|
||||
|
||||
def detect(normalized: dict, profile: str) -> list[Finding]:
|
||||
prof = PROFILES.get(profile, PROFILES["saas"])
|
||||
stages = normalized.get("stages", [])
|
||||
findings: list[Finding] = []
|
||||
|
||||
if not stages:
|
||||
return findings
|
||||
|
||||
total_p50 = sum(s["duration_minutes_p50"] for s in stages) or 1.0
|
||||
wait_p50 = sum(s["duration_minutes_p50"] for s in stages if s["type"] == "wait")
|
||||
rework_p50 = sum(s["duration_minutes_p50"] for s in stages if s["type"] == "rework")
|
||||
va_durations = [
|
||||
s["duration_minutes_p50"] for s in stages if s["type"] == "value-add"
|
||||
]
|
||||
va_mean = statistics.mean(va_durations) if va_durations else 0.0
|
||||
|
||||
# R1: per-stage runaway vs value-add mean
|
||||
if va_mean > 0:
|
||||
threshold_minutes = va_mean * prof["stage_multiplier"]
|
||||
for s in stages:
|
||||
if s["duration_minutes_p50"] > threshold_minutes:
|
||||
ratio = s["duration_minutes_p50"] / va_mean
|
||||
if ratio >= prof["stage_multiplier"] * 3:
|
||||
sev = "CRITICAL"
|
||||
elif ratio >= prof["stage_multiplier"] * 2:
|
||||
sev = "HIGH"
|
||||
else:
|
||||
sev = "MEDIUM"
|
||||
hypothesis = (
|
||||
"Stage runs much longer than the typical value-add step; "
|
||||
"common causes: batched approvals, single approver, "
|
||||
"missing self-service, or unclear acceptance criteria."
|
||||
)
|
||||
action = (
|
||||
"Decompose the stage; check if approval can be parallelized "
|
||||
"or made conditional. If wait-state, apply Kanban WIP limit "
|
||||
"or remove the handoff."
|
||||
)
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=sev,
|
||||
rule="R1",
|
||||
title=f"Slow stage: {s['name']}",
|
||||
detail=(
|
||||
f"P50 {s['duration_minutes_p50']:.0f} min vs value-add "
|
||||
f"mean {va_mean:.1f} min (ratio {ratio:.1f}x)."
|
||||
),
|
||||
hypothesis=hypothesis,
|
||||
action=action,
|
||||
impact_minutes_p50=s["duration_minutes_p50"],
|
||||
)
|
||||
)
|
||||
|
||||
# R2: wait-state share
|
||||
wait_share = wait_p50 / total_p50
|
||||
if wait_share > prof["wait_share_max"]:
|
||||
sev = classify_severity(wait_share, prof["wait_share_max"])
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=sev,
|
||||
rule="R2",
|
||||
title="Process is dominated by wait time",
|
||||
detail=(
|
||||
f"Wait stages account for {wait_share*100:.0f}% of total P50, "
|
||||
f"vs {prof['wait_share_max']*100:.0f}% profile threshold."
|
||||
),
|
||||
hypothesis=(
|
||||
"Handoffs queue work behind a single role or batch. Per "
|
||||
"Theory of Constraints, the system throughput is set by "
|
||||
"whichever queue is longest, not by stage speed."
|
||||
),
|
||||
action=(
|
||||
"Identify the longest wait stage; pull it forward, eliminate "
|
||||
"it via self-service, or apply a WIP limit upstream so the "
|
||||
"queue cannot grow."
|
||||
),
|
||||
impact_minutes_p50=wait_p50,
|
||||
)
|
||||
)
|
||||
|
||||
# R3: rework share
|
||||
rework_share = rework_p50 / total_p50
|
||||
if rework_share > prof["rework_share_max"]:
|
||||
sev = classify_severity(rework_share, prof["rework_share_max"])
|
||||
findings.append(
|
||||
Finding(
|
||||
severity=sev,
|
||||
rule="R3",
|
||||
title="Process has excessive rework",
|
||||
detail=(
|
||||
f"Rework accounts for {rework_share*100:.0f}% of total P50, "
|
||||
f"vs {prof['rework_share_max']*100:.0f}% profile threshold."
|
||||
),
|
||||
hypothesis=(
|
||||
"Defects escape upstream stages. Six-Sigma canon: rework is "
|
||||
"always an upstream-quality problem, never a downstream one."
|
||||
),
|
||||
action=(
|
||||
"Add a poka-yoke (error-proofing) check at the earliest stage "
|
||||
"that can detect the defect; do not add inspection downstream."
|
||||
),
|
||||
impact_minutes_p50=rework_p50,
|
||||
)
|
||||
)
|
||||
|
||||
findings.sort(key=lambda f: (f.severity_rank(), -f.impact_minutes_p50))
|
||||
return findings
|
||||
|
||||
|
||||
def render_markdown(normalized: dict, findings: list[Finding], profile: str) -> str:
|
||||
name = normalized.get("process_name", "Untitled Process")
|
||||
lines: list[str] = []
|
||||
lines.append(f"# Bottleneck Detection: {name}")
|
||||
lines.append("")
|
||||
lines.append(f"**Profile:** `{profile}` ")
|
||||
lines.append(f"**Findings:** {len(findings)}")
|
||||
lines.append("")
|
||||
if not findings:
|
||||
lines.append("_No bottlenecks detected at the configured thresholds._")
|
||||
return "\n".join(lines)
|
||||
for i, f in enumerate(findings, 1):
|
||||
lines.append(f"## {i}. [{f.severity}] {f.title}")
|
||||
lines.append("")
|
||||
lines.append(f"- **Rule:** `{f.rule}`")
|
||||
lines.append(f"- **Detail:** {f.detail}")
|
||||
lines.append(f"- **Hypothesis:** {f.hypothesis}")
|
||||
lines.append(f"- **Recommended action:** {f.action}")
|
||||
lines.append(f"- **Impact (P50 minutes):** {f.impact_minutes_p50:.0f}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def sample_process() -> dict:
|
||||
# Reuses procurement-intake shape from process_documenter
|
||||
return {
|
||||
"process_name": "Procurement Intake (Sample)",
|
||||
"wip": 12,
|
||||
"stages": [
|
||||
{"name": "Submit PO", "owner": "Requestor", "type": "value-add",
|
||||
"duration_minutes_p50": 15, "duration_minutes_p90": 30},
|
||||
{"name": "Wait for manager", "owner": "Manager", "type": "wait",
|
||||
"duration_minutes_p50": 480, "duration_minutes_p90": 1440},
|
||||
{"name": "Manager approves", "owner": "Manager", "type": "value-add",
|
||||
"duration_minutes_p50": 10, "duration_minutes_p90": 25},
|
||||
{"name": "Wait for finance", "owner": "Finance", "type": "wait",
|
||||
"duration_minutes_p50": 720, "duration_minutes_p90": 2880},
|
||||
{"name": "Finance validates", "owner": "Finance", "type": "value-add",
|
||||
"duration_minutes_p50": 20, "duration_minutes_p90": 60},
|
||||
{"name": "Rework: missing W-9", "owner": "Requestor", "type": "rework",
|
||||
"duration_minutes_p50": 120, "duration_minutes_p90": 360},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Detect bottlenecks in a documented business process."
|
||||
)
|
||||
parser.add_argument("--input", type=Path, help="Path to process JSON file.")
|
||||
parser.add_argument(
|
||||
"--profile",
|
||||
choices=sorted(PROFILES.keys()),
|
||||
default="saas",
|
||||
help="Industry profile for threshold calibration (default: saas).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
choices=["markdown", "json"],
|
||||
default="markdown",
|
||||
help="Output format (default: markdown).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sample",
|
||||
action="store_true",
|
||||
help="Use a built-in sample process and exit.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.sample:
|
||||
raw = sample_process()
|
||||
else:
|
||||
if not args.input:
|
||||
parser.error("--input is required unless --sample is given")
|
||||
if not args.input.exists():
|
||||
parser.error(f"input file not found: {args.input}")
|
||||
raw = load(args.input)
|
||||
|
||||
# Minimal normalization: tolerate the same fields as process_documenter
|
||||
stages = []
|
||||
for s in raw.get("stages", []):
|
||||
stages.append(
|
||||
{
|
||||
"name": s.get("name", ""),
|
||||
"owner": s.get("owner", ""),
|
||||
"type": s.get("type", ""),
|
||||
"duration_minutes_p50": float(s.get("duration_minutes_p50", 0)),
|
||||
"duration_minutes_p90": float(s.get("duration_minutes_p90", 0)),
|
||||
}
|
||||
)
|
||||
normalized = {
|
||||
"process_name": raw.get("process_name", "Untitled Process"),
|
||||
"wip": int(raw.get("wip", 0) or 0),
|
||||
"stages": stages,
|
||||
}
|
||||
|
||||
findings = detect(normalized, args.profile)
|
||||
|
||||
if args.output == "json":
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"process_name": normalized["process_name"],
|
||||
"profile": args.profile,
|
||||
"findings": [asdict(f) for f in findings],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(render_markdown(normalized, findings, args.profile))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""cycle_time_analyzer.py
|
||||
|
||||
Compute total cycle time (P50, P90), value-add ratio (VA%), wait %, rework %,
|
||||
and a Little's-Law throughput estimate for a documented business process.
|
||||
|
||||
Verdict per Lean canon:
|
||||
VA% > 25% -> HEALTHY
|
||||
10% <= VA% <= 25% -> TYPICAL
|
||||
VA% < 10% -> WASTE-HEAVY
|
||||
|
||||
Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Per-industry verdict bands. Manufacturing benchmarks higher VA% than services.
|
||||
PROFILES: dict[str, dict[str, float]] = {
|
||||
"saas": {"healthy": 0.25, "typical": 0.10},
|
||||
"services": {"healthy": 0.20, "typical": 0.08},
|
||||
"manufacturing": {"healthy": 0.35, "typical": 0.15},
|
||||
"healthcare": {"healthy": 0.20, "typical": 0.08},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CycleTimeReport:
|
||||
process_name: str
|
||||
profile: str
|
||||
stage_count: int
|
||||
total_p50_minutes: float
|
||||
total_p90_minutes: float
|
||||
value_add_minutes_p50: float
|
||||
wait_minutes_p50: float
|
||||
rework_minutes_p50: float
|
||||
value_add_ratio: float
|
||||
wait_ratio: float
|
||||
rework_ratio: float
|
||||
verdict: str
|
||||
wip: int
|
||||
throughput_per_hour: float | None
|
||||
notes: list[str]
|
||||
|
||||
|
||||
def analyze(normalized: dict, profile: str) -> CycleTimeReport:
|
||||
prof = PROFILES.get(profile, PROFILES["saas"])
|
||||
stages = normalized.get("stages", [])
|
||||
name = normalized.get("process_name", "Untitled Process")
|
||||
wip = int(normalized.get("wip", 0) or 0)
|
||||
|
||||
total_p50 = sum(s["duration_minutes_p50"] for s in stages)
|
||||
total_p90 = sum(s["duration_minutes_p90"] for s in stages)
|
||||
va_p50 = sum(s["duration_minutes_p50"] for s in stages if s["type"] == "value-add")
|
||||
wait_p50 = sum(s["duration_minutes_p50"] for s in stages if s["type"] == "wait")
|
||||
rework_p50 = sum(s["duration_minutes_p50"] for s in stages if s["type"] == "rework")
|
||||
|
||||
denom = total_p50 if total_p50 > 0 else 1.0
|
||||
va_ratio = va_p50 / denom
|
||||
wait_ratio = wait_p50 / denom
|
||||
rework_ratio = rework_p50 / denom
|
||||
|
||||
if va_ratio >= prof["healthy"]:
|
||||
verdict = "HEALTHY"
|
||||
elif va_ratio >= prof["typical"]:
|
||||
verdict = "TYPICAL"
|
||||
else:
|
||||
verdict = "WASTE-HEAVY"
|
||||
|
||||
# Little's Law: L = lambda * W => lambda = L / W
|
||||
# WIP is items currently in process; W (cycle time) is P50.
|
||||
# Convert minutes to hours for a per-hour throughput.
|
||||
throughput = None
|
||||
if wip > 0 and total_p50 > 0:
|
||||
cycle_hours = total_p50 / 60.0
|
||||
throughput = wip / cycle_hours
|
||||
|
||||
notes: list[str] = []
|
||||
if wip <= 0:
|
||||
notes.append(
|
||||
"WIP not provided; Little's-Law throughput estimate skipped. "
|
||||
"Set 'wip' in the input JSON to enable it."
|
||||
)
|
||||
if total_p50 == 0:
|
||||
notes.append("All stage P50 durations are zero; check input data.")
|
||||
if rework_ratio > 0.0 and verdict == "HEALTHY":
|
||||
notes.append(
|
||||
"Process is healthy by VA%, but rework is non-zero. Six-Sigma canon: "
|
||||
"any rework signal is worth a poka-yoke check."
|
||||
)
|
||||
if wait_ratio > 0.5:
|
||||
notes.append(
|
||||
"More than half the cycle is wait time. Throughput improves more "
|
||||
"from queue removal than from speeding up value-add stages."
|
||||
)
|
||||
|
||||
return CycleTimeReport(
|
||||
process_name=name,
|
||||
profile=profile,
|
||||
stage_count=len(stages),
|
||||
total_p50_minutes=round(total_p50, 2),
|
||||
total_p90_minutes=round(total_p90, 2),
|
||||
value_add_minutes_p50=round(va_p50, 2),
|
||||
wait_minutes_p50=round(wait_p50, 2),
|
||||
rework_minutes_p50=round(rework_p50, 2),
|
||||
value_add_ratio=round(va_ratio, 4),
|
||||
wait_ratio=round(wait_ratio, 4),
|
||||
rework_ratio=round(rework_ratio, 4),
|
||||
verdict=verdict,
|
||||
wip=wip,
|
||||
throughput_per_hour=round(throughput, 4) if throughput is not None else None,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
def render_markdown(report: CycleTimeReport) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append(f"# Cycle-Time Analysis: {report.process_name}")
|
||||
lines.append("")
|
||||
lines.append(f"**Profile:** `{report.profile}` ")
|
||||
lines.append(f"**Verdict:** **{report.verdict}**")
|
||||
lines.append("")
|
||||
lines.append("## Summary")
|
||||
lines.append("")
|
||||
lines.append("| Metric | Value |")
|
||||
lines.append("|--------|-------|")
|
||||
lines.append(f"| Stage count | {report.stage_count} |")
|
||||
lines.append(f"| Total P50 (minutes) | {report.total_p50_minutes:.1f} |")
|
||||
lines.append(f"| Total P90 (minutes) | {report.total_p90_minutes:.1f} |")
|
||||
lines.append(
|
||||
f"| Value-add minutes (P50) | {report.value_add_minutes_p50:.1f} |"
|
||||
)
|
||||
lines.append(f"| Wait minutes (P50) | {report.wait_minutes_p50:.1f} |")
|
||||
lines.append(f"| Rework minutes (P50) | {report.rework_minutes_p50:.1f} |")
|
||||
lines.append(f"| Value-add ratio (VA%) | {report.value_add_ratio*100:.1f}% |")
|
||||
lines.append(f"| Wait ratio | {report.wait_ratio*100:.1f}% |")
|
||||
lines.append(f"| Rework ratio | {report.rework_ratio*100:.1f}% |")
|
||||
lines.append(f"| WIP (items in process) | {report.wip} |")
|
||||
if report.throughput_per_hour is not None:
|
||||
lines.append(
|
||||
f"| Little's-Law throughput | {report.throughput_per_hour:.3f} items/hour |"
|
||||
)
|
||||
else:
|
||||
lines.append("| Little's-Law throughput | _(needs WIP > 0 in input)_ |")
|
||||
lines.append("")
|
||||
if report.notes:
|
||||
lines.append("## Notes")
|
||||
lines.append("")
|
||||
for n in report.notes:
|
||||
lines.append(f"- {n}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def sample_process() -> dict:
|
||||
return {
|
||||
"process_name": "Procurement Intake (Sample)",
|
||||
"wip": 12,
|
||||
"stages": [
|
||||
{"name": "Submit PO", "owner": "Requestor", "type": "value-add",
|
||||
"duration_minutes_p50": 15, "duration_minutes_p90": 30},
|
||||
{"name": "Wait for manager", "owner": "Manager", "type": "wait",
|
||||
"duration_minutes_p50": 480, "duration_minutes_p90": 1440},
|
||||
{"name": "Manager approves", "owner": "Manager", "type": "value-add",
|
||||
"duration_minutes_p50": 10, "duration_minutes_p90": 25},
|
||||
{"name": "Wait for finance", "owner": "Finance", "type": "wait",
|
||||
"duration_minutes_p50": 720, "duration_minutes_p90": 2880},
|
||||
{"name": "Finance validates", "owner": "Finance", "type": "value-add",
|
||||
"duration_minutes_p50": 20, "duration_minutes_p90": 60},
|
||||
{"name": "Rework: missing W-9", "owner": "Requestor", "type": "rework",
|
||||
"duration_minutes_p50": 120, "duration_minutes_p90": 360},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Analyze cycle time, value-add ratio, and throughput of a process."
|
||||
)
|
||||
parser.add_argument("--input", type=Path, help="Path to process JSON file.")
|
||||
parser.add_argument(
|
||||
"--profile",
|
||||
choices=sorted(PROFILES.keys()),
|
||||
default="saas",
|
||||
help="Industry profile for verdict band (default: saas).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
choices=["markdown", "json"],
|
||||
default="markdown",
|
||||
help="Output format (default: markdown).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sample",
|
||||
action="store_true",
|
||||
help="Use a built-in sample process and exit.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.sample:
|
||||
raw = sample_process()
|
||||
else:
|
||||
if not args.input:
|
||||
parser.error("--input is required unless --sample is given")
|
||||
if not args.input.exists():
|
||||
parser.error(f"input file not found: {args.input}")
|
||||
with args.input.open("r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
|
||||
stages = []
|
||||
for s in raw.get("stages", []):
|
||||
stages.append(
|
||||
{
|
||||
"name": s.get("name", ""),
|
||||
"owner": s.get("owner", ""),
|
||||
"type": s.get("type", ""),
|
||||
"duration_minutes_p50": float(s.get("duration_minutes_p50", 0)),
|
||||
"duration_minutes_p90": float(s.get("duration_minutes_p90", 0)),
|
||||
}
|
||||
)
|
||||
normalized = {
|
||||
"process_name": raw.get("process_name", "Untitled Process"),
|
||||
"wip": int(raw.get("wip", 0) or 0),
|
||||
"stages": stages,
|
||||
}
|
||||
|
||||
report = analyze(normalized, args.profile)
|
||||
|
||||
if args.output == "json":
|
||||
print(json.dumps(asdict(report), indent=2))
|
||||
else:
|
||||
print(render_markdown(report))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""process_documenter.py
|
||||
|
||||
Read a JSON description of a business process (one entry per stage) and emit:
|
||||
- a text-based BPMN-style swim-lane diagram in Markdown, OR
|
||||
- a normalized JSON artifact for downstream tools.
|
||||
|
||||
Stdlib only. Use `--sample` to print a 6-stage procurement-intake example to
|
||||
stdout.
|
||||
|
||||
Input schema (JSON):
|
||||
{
|
||||
"process_name": "Procurement Intake",
|
||||
"wip": 12, # optional, integer; used by cycle_time_analyzer
|
||||
"stages": [
|
||||
{
|
||||
"name": "Requestor submits PO request",
|
||||
"owner": "Requestor",
|
||||
"type": "value-add", # one of: value-add | wait | rework
|
||||
"duration_minutes_p50": 15,
|
||||
"duration_minutes_p90": 30
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
VALID_TYPES = {"value-add", "wait", "rework"}
|
||||
|
||||
|
||||
class StageType(str, Enum):
|
||||
VALUE_ADD = "value-add"
|
||||
WAIT = "wait"
|
||||
REWORK = "rework"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stage:
|
||||
name: str
|
||||
owner: str
|
||||
type: str
|
||||
duration_minutes_p50: float
|
||||
duration_minutes_p90: float
|
||||
|
||||
def validate(self, idx: int) -> list[str]:
|
||||
errs: list[str] = []
|
||||
if not self.name:
|
||||
errs.append(f"stage[{idx}]: missing 'name'")
|
||||
if not self.owner:
|
||||
errs.append(f"stage[{idx}]: missing 'owner'")
|
||||
if self.type not in VALID_TYPES:
|
||||
errs.append(
|
||||
f"stage[{idx}] ('{self.name}'): invalid type '{self.type}' "
|
||||
f"(expected one of {sorted(VALID_TYPES)})"
|
||||
)
|
||||
if self.duration_minutes_p50 < 0:
|
||||
errs.append(f"stage[{idx}] ('{self.name}'): p50 must be >= 0")
|
||||
if self.duration_minutes_p90 < self.duration_minutes_p50:
|
||||
errs.append(
|
||||
f"stage[{idx}] ('{self.name}'): p90 ({self.duration_minutes_p90}) "
|
||||
f"< p50 ({self.duration_minutes_p50})"
|
||||
)
|
||||
return errs
|
||||
|
||||
|
||||
def load_process(path: Path) -> dict:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def normalize(raw: dict) -> dict:
|
||||
"""Validate + return a normalized dict. Raises ValueError on bad input."""
|
||||
if "stages" not in raw or not isinstance(raw["stages"], list):
|
||||
raise ValueError("input must include a non-empty 'stages' list")
|
||||
|
||||
stages: list[Stage] = []
|
||||
errors: list[str] = []
|
||||
for idx, s in enumerate(raw["stages"]):
|
||||
try:
|
||||
stage = Stage(
|
||||
name=s.get("name", ""),
|
||||
owner=s.get("owner", ""),
|
||||
type=s.get("type", ""),
|
||||
duration_minutes_p50=float(s.get("duration_minutes_p50", 0)),
|
||||
duration_minutes_p90=float(s.get("duration_minutes_p90", 0)),
|
||||
)
|
||||
except (TypeError, ValueError) as e:
|
||||
errors.append(f"stage[{idx}]: parse error: {e}")
|
||||
continue
|
||||
errors.extend(stage.validate(idx))
|
||||
stages.append(stage)
|
||||
|
||||
if errors:
|
||||
raise ValueError("invalid input:\n - " + "\n - ".join(errors))
|
||||
|
||||
return {
|
||||
"process_name": raw.get("process_name", "Untitled Process"),
|
||||
"wip": int(raw.get("wip", 0)) if raw.get("wip") is not None else 0,
|
||||
"stages": [asdict(s) for s in stages],
|
||||
}
|
||||
|
||||
|
||||
def render_markdown(normalized: dict) -> str:
|
||||
"""Render a text-based BPMN-style swim-lane diagram in Markdown."""
|
||||
name = normalized["process_name"]
|
||||
stages = normalized["stages"]
|
||||
lines: list[str] = []
|
||||
|
||||
lines.append(f"# Process Map: {name}")
|
||||
lines.append("")
|
||||
lines.append(f"**Stages:** {len(stages)} ")
|
||||
lines.append(
|
||||
f"**Total P50:** {sum(s['duration_minutes_p50'] for s in stages):.1f} min "
|
||||
)
|
||||
lines.append(
|
||||
f"**Total P90:** {sum(s['duration_minutes_p90'] for s in stages):.1f} min"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Group by owner -> swim lane
|
||||
lanes: dict[str, list[tuple[int, dict]]] = {}
|
||||
for idx, s in enumerate(stages):
|
||||
lanes.setdefault(s["owner"], []).append((idx, s))
|
||||
|
||||
lines.append("## Swim Lanes")
|
||||
lines.append("")
|
||||
type_glyph = {"value-add": "[V]", "wait": "[W]", "rework": "[R]"}
|
||||
lane_width = max(20, max((len(o) for o in lanes), default=20) + 4)
|
||||
sep = "+" + "-" * (lane_width + 2) + "+" + "-" * 72 + "+"
|
||||
|
||||
lines.append("```")
|
||||
lines.append(sep)
|
||||
lines.append(
|
||||
"| " + "OWNER".ljust(lane_width) + " | " + "STAGES (in process order)".ljust(70) + " |"
|
||||
)
|
||||
lines.append(sep)
|
||||
for owner, owned in lanes.items():
|
||||
owner_cell = owner.ljust(lane_width)
|
||||
cells = []
|
||||
for idx, s in owned:
|
||||
glyph = type_glyph.get(s["type"], "[?]")
|
||||
cells.append(
|
||||
f"#{idx+1} {glyph} {s['name'][:32]} "
|
||||
f"(p50={s['duration_minutes_p50']:.0f}m)"
|
||||
)
|
||||
row_text = " -> ".join(cells)
|
||||
# Wrap row_text to 70 chars
|
||||
wrapped = []
|
||||
cur = ""
|
||||
for token in row_text.split(" "):
|
||||
if len(cur) + len(token) + 1 > 70:
|
||||
wrapped.append(cur)
|
||||
cur = token
|
||||
else:
|
||||
cur = (cur + " " + token).strip()
|
||||
if cur:
|
||||
wrapped.append(cur)
|
||||
for i, line in enumerate(wrapped):
|
||||
left = owner_cell if i == 0 else " " * lane_width
|
||||
lines.append(f"| {left} | {line.ljust(70)} |")
|
||||
lines.append(sep)
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
lines.append("Legend: `[V]` value-add `[W]` wait `[R]` rework")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Linear sequence")
|
||||
lines.append("")
|
||||
lines.append("| # | Stage | Owner | Type | P50 (min) | P90 (min) |")
|
||||
lines.append("|---|-------|-------|------|-----------|-----------|")
|
||||
for idx, s in enumerate(stages):
|
||||
lines.append(
|
||||
f"| {idx+1} | {s['name']} | {s['owner']} | {s['type']} | "
|
||||
f"{s['duration_minutes_p50']:.1f} | {s['duration_minutes_p90']:.1f} |"
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def sample_process() -> dict:
|
||||
return {
|
||||
"process_name": "Procurement Intake (Sample)",
|
||||
"wip": 12,
|
||||
"stages": [
|
||||
{
|
||||
"name": "Requestor submits PO request",
|
||||
"owner": "Requestor",
|
||||
"type": "value-add",
|
||||
"duration_minutes_p50": 15,
|
||||
"duration_minutes_p90": 30,
|
||||
},
|
||||
{
|
||||
"name": "Wait for manager review queue",
|
||||
"owner": "Manager",
|
||||
"type": "wait",
|
||||
"duration_minutes_p50": 480,
|
||||
"duration_minutes_p90": 1440,
|
||||
},
|
||||
{
|
||||
"name": "Manager approves request",
|
||||
"owner": "Manager",
|
||||
"type": "value-add",
|
||||
"duration_minutes_p50": 10,
|
||||
"duration_minutes_p90": 25,
|
||||
},
|
||||
{
|
||||
"name": "Wait for finance review queue",
|
||||
"owner": "Finance",
|
||||
"type": "wait",
|
||||
"duration_minutes_p50": 720,
|
||||
"duration_minutes_p90": 2880,
|
||||
},
|
||||
{
|
||||
"name": "Finance validates budget code",
|
||||
"owner": "Finance",
|
||||
"type": "value-add",
|
||||
"duration_minutes_p50": 20,
|
||||
"duration_minutes_p90": 60,
|
||||
},
|
||||
{
|
||||
"name": "Rework: missing vendor W-9",
|
||||
"owner": "Requestor",
|
||||
"type": "rework",
|
||||
"duration_minutes_p50": 120,
|
||||
"duration_minutes_p90": 360,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Document a business process as a BPMN-style swim-lane diagram."
|
||||
)
|
||||
parser.add_argument("--input", type=Path, help="Path to process JSON file.")
|
||||
parser.add_argument(
|
||||
"--output", type=Path, help="Output file path (default: stdout)."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["markdown", "json"],
|
||||
default="markdown",
|
||||
help="Output format (default: markdown).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sample",
|
||||
action="store_true",
|
||||
help="Print a 6-stage procurement-intake sample and exit.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.sample:
|
||||
raw = sample_process()
|
||||
else:
|
||||
if not args.input:
|
||||
parser.error("--input is required unless --sample is given")
|
||||
if not args.input.exists():
|
||||
parser.error(f"input file not found: {args.input}")
|
||||
raw = load_process(args.input)
|
||||
|
||||
try:
|
||||
normalized = normalize(raw)
|
||||
except ValueError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.format == "json":
|
||||
out = json.dumps(normalized, indent=2)
|
||||
else:
|
||||
out = render_markdown(normalized)
|
||||
|
||||
if args.output:
|
||||
args.output.write_text(out, encoding="utf-8")
|
||||
print(f"wrote {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,174 @@
|
||||
---
|
||||
name: procurement-optimizer
|
||||
description: Use when running an annual SaaS audit, doing category-level spend review, or rationalizing the supplier base — when the user needs a spend audit, spend categorization (UNSPSC-aligned with Pareto breakdown and industry profiles), purchasing-cycle analysis (bottleneck categories per Goldratt's Theory of Constraints), or risk-balanced supplier consolidation that refuses single-source recommendations for tier-1 categories without a documented break-glass plan. Triggers on "spend audit", "SaaS audit", "spend categorization", "supplier rationalization", "supplier consolidation", "category strategy", "duplicate SaaS", "renewal cluster".
|
||||
version: 2.8.0
|
||||
author: claude-code-skills
|
||||
license: MIT
|
||||
tags: [bizops, procurement, spend-categorization, supplier-consolidation, unspsc, saas-audit, purchasing-cycle]
|
||||
compatible_tools: [claude-code, codex-cli, cursor, antigravity, opencode, gemini-cli]
|
||||
---
|
||||
|
||||
# Procurement Optimizer — Spend Categorization + Supplier Rationalization
|
||||
|
||||
You are a Head of Procurement / Head of BizOps / VP Finance operator running the annual category review. Your job is **what to buy, from whom, on what cadence** — not how the vendor you already chose is performing (that's `vendor-management`). You categorize spend along a UNSPSC-aligned taxonomy, find the Pareto-20% of categories driving 80% of cost, surface purchasing-cycle bottlenecks, and produce a **risk-balanced** supplier-consolidation plan that refuses to collapse tier-1 categories to single-source without a documented contingency.
|
||||
|
||||
## Purpose
|
||||
|
||||
A typical mid-stage company has:
|
||||
- Software spend up 40% YoY with no single owner who can name the top growth categories.
|
||||
- 3 monitoring tools, 2 expense platforms, 4 email-marketing tools — duplicate-function clusters that nobody consolidated because no one had the data to defend the recommendation.
|
||||
- A purchasing cycle where some categories close in 5 days and others take 90, but the "average" hides the constraint.
|
||||
- Renewal dates clustered in the same month, destroying negotiation leverage.
|
||||
|
||||
This skill produces a deterministic, defensible artifact for each problem: categorized spend with Pareto, cycle-time scorecard by category, and a consolidation plan with explicit risk flags.
|
||||
|
||||
## When to use
|
||||
|
||||
- Annual SaaS audit and category-level spend review.
|
||||
- A category owner wants to know which 5 categories drove this year's spend growth.
|
||||
- Finance flags that software spend is up 40% YoY and needs a Pareto by category, not by vendor.
|
||||
- BizOps suspects duplicate-function tools (monitoring, expense, email-marketing) and needs a defensible consolidation plan.
|
||||
- The CFO wants tighter approval thresholds and needs cycle-time data per category to justify it.
|
||||
- Post-acquisition, two procurement teams need to merge category taxonomies and dedupe the supplier base.
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- Scoring or auditing an individual vendor you've already decided to keep paying → sibling `vendor-management`.
|
||||
- Financial close, monthly reporting, or P&L analysis → `finance/financial-analysis`.
|
||||
- Drafting or negotiating contract terms → `c-level-advisor/general-counsel-advisor`.
|
||||
- Building outbound sales proposals → `business-growth/contract-and-proposal-writer`.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Intake spend
|
||||
|
||||
Have the user fill out `assets/spend_intake_template.md` (20 minutes for a typical mid-stage company). The skeleton expects line items with `{supplier, description, category_hint, annual_spend, frequency, currency}`. If prior-year spend is available, include it for YoY analysis.
|
||||
|
||||
### Step 2 — Categorize and find the Pareto
|
||||
|
||||
Run `scripts/spend_categorizer.py --input spend.json --profile <profile> --output categorized.md`.
|
||||
|
||||
The categorizer maps each line item to a UNSPSC-aligned Class → Family → Segment (built-in map of ~30 categories tuned for tech-startup spend: Software/SaaS, Hardware, Cloud Infrastructure, Professional Services, Marketing Services, Legal, Recruiting, Travel, Office, Insurance, Benefits, etc. — NOT the full 100k UNSPSC database). Output includes:
|
||||
|
||||
- Categorized line items
|
||||
- Pareto: which 20% of categories drive 80% of spend?
|
||||
- Top-10 YoY growth categories (when prior-year provided)
|
||||
|
||||
Profiles re-prioritize the category map: `tech-startup` (heavy SaaS / cloud), `scaleup` (sales tools / recruiting heavy), `enterprise` (professional services / facilities heavy), `services`, `manufacturing`.
|
||||
|
||||
### Step 3 — Analyze the purchasing cycle
|
||||
|
||||
Run `scripts/purchasing_cycle_analyzer.py --input pos.json --output cycle.md`.
|
||||
|
||||
For each PO record `{category, request_date, approval_date, po_issued_date, goods_received_date, payment_date, approver_hops}`, the analyzer computes per-category:
|
||||
|
||||
- Cycle time T-request → T-PO (median, P90)
|
||||
- T-PO → T-pay (median, P90)
|
||||
- Approver-hop count (median)
|
||||
|
||||
It then flags categories with cycle time > 2× the cross-category median as **bottleneck** categories. This is Goldratt's Theory of Constraints applied to procurement: the system throughput is set by the slowest step, and the slowest step is almost always one specific category (legal review on services contracts, security review on tier-1 SaaS).
|
||||
|
||||
### Step 4 — Plan supplier consolidation with risk balancing
|
||||
|
||||
Run `scripts/supplier_consolidation.py --input suppliers.json --profile <profile> --output consolidation_plan.md`.
|
||||
|
||||
The planner identifies **duplicate-function clusters** (e.g., 3 monitoring tools, 2 expense platforms). For each cluster:
|
||||
|
||||
- Picks a recommended consolidation winner (highest criticality tier survives, OR lowest switching-cost winner if the cluster is tier-3, depending on cluster type).
|
||||
- **Flags risk:** does NOT recommend collapse to single-source for any tier-1 criticality category unless the input explicitly flags a documented break-glass plan. The output says explicitly: "DO NOT CONSOLIDATE — tier-1 cluster, no break-glass on record. Add a 72-hour contingency plan first."
|
||||
- Estimates savings: current cluster spend − winner spend − migration cost (sum of switching-cost estimates of losers).
|
||||
- Renewal-date clustering analysis: flags categories where ≥ 3 contracts renew within the same calendar month (no leverage).
|
||||
|
||||
### Step 5 — Synthesize the procurement review
|
||||
|
||||
Combine the 3 artifacts into a BizOps-ready digest:
|
||||
|
||||
- Top 5 categories driving YoY spend growth (categorizer)
|
||||
- Top 3 bottleneck categories blocking throughput (cycle analyzer)
|
||||
- Top 5 consolidation opportunities with estimated savings and risk flags (consolidation planner)
|
||||
- All renewal clusters destroying leverage
|
||||
- Tier-1 single-source exposure points needing break-glass plans before any consolidation
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|---|---|
|
||||
| `scripts/spend_categorizer.py` | UNSPSC-aligned categorization + Pareto + YoY growth |
|
||||
| `scripts/purchasing_cycle_analyzer.py` | Per-category cycle time + Goldratt bottleneck flag |
|
||||
| `scripts/supplier_consolidation.py` | Duplicate-function clustering + risk-flagged consolidation plan |
|
||||
|
||||
All three accept `--input` (JSON), `--output` (markdown path), `--sample` (run with built-in sample data), and `--help`. The two with industry-specific category priorities accept `--profile {tech-startup,scaleup,enterprise,services,manufacturing}`.
|
||||
|
||||
## Quick example
|
||||
|
||||
```bash
|
||||
# Emits a UNSPSC-aligned spend categorization with Pareto breakdown for the built-in sample spend file
|
||||
cd business-operations/skills/procurement-optimizer && python3 scripts/spend_categorizer.py --sample
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- `references/spend_management_canon.md` — A.T. Kearney *Spend Management*, Procurement Leaders, Gartner Procurement, BCG Procurement value creation, Hackett benchmarks, Pierre Mitchell / Spend Matters, UNSPSC official taxonomy.
|
||||
- `references/saas_management_canon.md` — Productiv / Zylo / Vendr / Tropic SaaS sprawl reports, BetterCloud SaaS Operations, Gartner SMP Magic Quadrant, Bain SaaS spend, Forrester SaaS portfolio management, Tomasz Tunguz on SaaS sprawl, Patrick Campbell / ProfitWell on SaaS unit economics.
|
||||
- `references/procurement_anti_patterns.md` — A.T. Kearney maverick-spend, IACCM/WorldCC, McKinsey on category-strategy mistakes, Hackett purchasing-cycle research, BCG on supplier-consolidation risks, Spend Matters failed-rationalization analyses, ISM lessons learned.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. The user has access to AP / expense / SaaS-management exports, or can hand-assemble a spend list of the top 100-200 line items (the Pareto holds — top 20% of suppliers will be most of the spend).
|
||||
2. Prior-year spend is preferred (for YoY) but optional; the categorizer degrades gracefully if absent.
|
||||
3. Purchasing-cycle data is preferred but optional; if absent, the user gets categorization + consolidation only.
|
||||
4. Supplier criticality (`tier-1/2/3`) is a **judgment call by the user**, not derived from spend alone. Tier-1 = revenue-blocking if the supplier disappears. The tool refuses to infer this — the user must mark it.
|
||||
5. The output artifacts (categorized markdown, cycle scorecard, consolidation plan) are **inputs to a human decision**, not the decision itself.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Consolidate to single-source for tier-1 critical category without a break-glass plan.** Cost savings buy nothing if the consolidated supplier disappears. See `references/procurement_anti_patterns.md`.
|
||||
- **Categorize by vendor name, not by what's purchased.** Workday could be "HR Software" OR "Finance Software" depending on which modules are licensed. The line-item `description` and `category_hint` drive categorization, not the supplier name.
|
||||
- **Ignore renewal-date clustering.** Twelve tier-2 contracts that all renew in March mean zero negotiation leverage on any of them. Spread them.
|
||||
- **Approve-by-default for sub-$5K spend.** This is the death-by-a-thousand-SaaS pattern. The categorizer surfaces "small-spend, many-supplier" clusters explicitly.
|
||||
- **No quarterly renewal review.** Annual is too coarse for SaaS, which renews continuously across the year.
|
||||
- **Rationalize without measuring switching cost.** Consolidating 3 tools to save $50k when migration costs $200k is not a savings.
|
||||
- **Consolidate based on price alone, ignoring integration debt.** The cheap tool that doesn't integrate with your data warehouse is more expensive than the expensive one that does.
|
||||
- **Treat shadow IT spend as marketing's problem.** It is procurement's problem. Marketing-tool sprawl is the #1 driver of SaaS-spend growth in scaleups.
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **Sibling `vendor-management`** — that's performance scoring (uptime, SLA, third-party risk) for vendors you've already decided to keep paying. This is **spend rationalization + supplier consolidation** — deciding WHICH vendors to keep.
|
||||
- **`finance/financial-analysis`** — that's financial close, P&L, reporting, DCF. This is operational procurement: category strategy and supplier rationalization, not financial reporting.
|
||||
- **`c-level-advisor/general-counsel-advisor`** — that's contract law (indemnity, IP, liquidated damages). This is category-level spend strategy. Once you've decided which 3 monitoring tools to consolidate to 1, GC reviews the contract terms of the survivor.
|
||||
- **`business-growth/contract-and-proposal-writer`** — that's outbound proposals to win customers. This is inbound supplier rationalization.
|
||||
- **`finance/budgeting`** — that's annual budget planning. This is the inside view: where the budget is actually leaking.
|
||||
|
||||
## Forcing-question library (Matt Pocock grill discipline)
|
||||
|
||||
Walked one at a time by `/cs:grill-bizops` or the BizOps orchestrator. Recommended answer + canon citation per question. Never bundled.
|
||||
|
||||
1. **"Before we categorize, do you have a UNSPSC-aligned taxonomy or are you categorizing by vendor name?"**
|
||||
Recommended: categorize by what's purchased (line-item description + category_hint), not by supplier. A single supplier can span multiple categories.
|
||||
Canon: UNSPSC official taxonomy documentation, A.T. Kearney *Spend Management* on category architecture.
|
||||
|
||||
2. **"Of your top 10 categories by spend, which 3 grew most YoY — and do you know why?"**
|
||||
Recommended: name them before opening the tool. If you can't name them, that's the diagnosis.
|
||||
Canon: BCG Procurement value-creation research, Hackett benchmarks on category-level visibility maturity.
|
||||
|
||||
3. **"For each duplicate-function cluster (e.g., 3 monitoring tools), what's the switching cost to consolidate — and does it exceed the savings?"**
|
||||
Recommended: estimate switching cost explicitly (training, integration rework, data migration). Refuse to recommend consolidation without it.
|
||||
Canon: BCG on supplier-consolidation risks, Spend Matters analyses of failed rationalization initiatives.
|
||||
|
||||
4. **"For any tier-1 category you're proposing to consolidate to single-source, what's the 72-hour break-glass plan if that supplier disappears?"**
|
||||
Recommended: documented contingency per category, tested. If absent, do not consolidate.
|
||||
Canon: NotPetya / M.E.Doc supply chain attack lessons, NIST SP 800-161, A.T. Kearney on supply concentration risk.
|
||||
|
||||
5. **"What % of your spend goes through a PO vs. expense reimbursement vs. shadow IT? Where's the maverick spend?"**
|
||||
Recommended: measure it. A.T. Kearney research finds 10-40% of spend is maverick in unmonitored companies.
|
||||
Canon: A.T. Kearney maverick-spend research, ISM (Institute for Supply Management) procurement maturity model.
|
||||
|
||||
6. **"How many of your top-20 contracts renew in the same calendar month? Do you have a renewal calendar?"**
|
||||
Recommended: build the calendar; spread renewals deliberately. Clustered renewals destroy negotiation leverage.
|
||||
Canon: IACCM/WorldCC contract-management research, Spend Matters on negotiation leverage timing.
|
||||
|
||||
7. **"What's your approval threshold for net-new SaaS purchases under $5k? Who owns the death-by-a-thousand-SaaS problem?"**
|
||||
Recommended: a tightened threshold + a single owner. Productiv / Zylo data shows 50%+ of SaaS sprawl comes from sub-$5k unmonitored purchases.
|
||||
Canon: Productiv / Zylo / Vendr industry reports on SaaS sprawl.
|
||||
|
||||
Walk depth-first. Lock 1-4 before opening 5-7. After all are answered, invoke `spend_categorizer.py` → `purchasing_cycle_analyzer.py` → `supplier_consolidation.py` in sequence.
|
||||
@@ -0,0 +1,171 @@
|
||||
# Spend Intake Template
|
||||
|
||||
20-minute fill-out for the annual SaaS audit / category-level spend review. Output is a JSON list you can paste into `scripts/spend_categorizer.py` and `scripts/supplier_consolidation.py`.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Gather sources (5 minutes)
|
||||
|
||||
Pull line-item spend from one or more of:
|
||||
|
||||
- AP / accounting system export (Bill.com, Ramp, NetSuite, QuickBooks).
|
||||
- SaaS-management platform export (Productiv, Zylo, Vendr, Tropic, BetterCloud).
|
||||
- Corporate-card export with merchant + memo.
|
||||
- Expense reimbursement export (Expensify, Concur, Navan).
|
||||
|
||||
Aim for the top 100-200 line items by spend. The Pareto holds — the top 20% will give you 80% of the answer.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Fill out the spend JSON (15 minutes)
|
||||
|
||||
For each line item, populate the schema below. Skip prior-year fields if you don't have them — the tool degrades gracefully.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"supplier": "Datadog",
|
||||
"description": "Monitoring + APM enterprise tier",
|
||||
"category_hint": "monitoring",
|
||||
"annual_spend": 180000,
|
||||
"frequency": "annual",
|
||||
"currency": "USD",
|
||||
"prior_year_spend": 120000
|
||||
},
|
||||
{
|
||||
"supplier": "AWS",
|
||||
"description": "EC2 + S3 + RDS production infrastructure",
|
||||
"category_hint": "cloud infrastructure",
|
||||
"annual_spend": 720000,
|
||||
"frequency": "monthly",
|
||||
"currency": "USD",
|
||||
"prior_year_spend": 480000
|
||||
},
|
||||
{
|
||||
"supplier": "Outside Counsel - Fenwick",
|
||||
"description": "legal services - contracts, employment, IP",
|
||||
"category_hint": "legal",
|
||||
"annual_spend": 95000,
|
||||
"frequency": "as-billed",
|
||||
"currency": "USD",
|
||||
"prior_year_spend": 60000
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Field guide
|
||||
|
||||
| Field | Required? | Notes |
|
||||
|---|---|---|
|
||||
| `supplier` | yes | The legal entity you pay (not the brand). |
|
||||
| `description` | yes | What you bought, in your words. **This drives categorization** — be specific. "Workday HR" vs "Workday Finance" categorize differently. |
|
||||
| `category_hint` | optional but recommended | A short keyword (monitoring, expense, crm, legal, etc.). Helps the categorizer when the description is ambiguous. |
|
||||
| `annual_spend` | yes | Annualized total (multiply monthly × 12). |
|
||||
| `frequency` | optional | `annual`, `monthly`, `quarterly`, `as-billed` — informational only, doesn't change categorization. |
|
||||
| `currency` | optional | Default USD. Convert before input if mixed-currency. |
|
||||
| `prior_year_spend` | optional | Enables YoY growth analysis. Set to 0 for new-this-year subscriptions. |
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Run the categorizer
|
||||
|
||||
```bash
|
||||
python scripts/spend_categorizer.py \
|
||||
--input spend.json \
|
||||
--profile tech-startup \
|
||||
--output categorized.md
|
||||
```
|
||||
|
||||
Profiles: `tech-startup`, `scaleup`, `enterprise`, `services`, `manufacturing`.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Build the supplier-criticality JSON for consolidation
|
||||
|
||||
Take the same suppliers, add criticality and switching cost. The supplier-consolidation tool needs this:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "Datadog",
|
||||
"category": "Monitoring / Observability",
|
||||
"annual_spend": 180000,
|
||||
"criticality": "tier-2",
|
||||
"contract_term_months": 12,
|
||||
"integration_count_with_other_systems": 12,
|
||||
"switching_cost_estimate": 80000,
|
||||
"renewal_date": "2026-09-15",
|
||||
"break_glass_documented": false
|
||||
},
|
||||
{
|
||||
"name": "AWS",
|
||||
"category": "Cloud Infrastructure",
|
||||
"annual_spend": 720000,
|
||||
"criticality": "tier-1",
|
||||
"contract_term_months": 36,
|
||||
"integration_count_with_other_systems": 40,
|
||||
"switching_cost_estimate": 600000,
|
||||
"renewal_date": "2027-03-31",
|
||||
"break_glass_documented": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Criticality definitions (decide before running, don't let the tool infer)
|
||||
|
||||
- **tier-1** — revenue-blocking if the supplier disappears for 24h+. Identity providers, payment processors, primary cloud, primary CRM. Tier-1 should be a short list (typically 5-15 suppliers).
|
||||
- **tier-2** — important but a workaround exists. Most SaaS lands here.
|
||||
- **tier-3** — nice-to-have. Long-tail SaaS, productivity utilities.
|
||||
|
||||
### Break-glass flag
|
||||
|
||||
`break_glass_documented: true` means you have a written 72-hour contingency plan for what happens if this supplier disappears tomorrow. The tool **refuses to recommend tier-1 consolidation** if any cluster member has this flag false.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Run consolidation
|
||||
|
||||
```bash
|
||||
python scripts/supplier_consolidation.py \
|
||||
--input suppliers.json \
|
||||
--profile tech-startup \
|
||||
--output consolidation_plan.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optional: purchasing-cycle data
|
||||
|
||||
If you have PO timestamp data, the cycle analyzer surfaces bottleneck categories:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"category": "Outside Counsel",
|
||||
"request_date": "2026-01-05",
|
||||
"approval_date": "2026-02-15",
|
||||
"po_issued_date": "2026-02-28",
|
||||
"goods_received_date": "2026-02-28",
|
||||
"payment_date": "2026-03-30",
|
||||
"approver_hops": 4
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Run with:
|
||||
|
||||
```bash
|
||||
python scripts/purchasing_cycle_analyzer.py \
|
||||
--input pos.json \
|
||||
--output cycle.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick sanity checks before you run
|
||||
|
||||
- [ ] Top 10 line items cover ≥ 50% of total spend (Pareto sanity check)
|
||||
- [ ] Each line item has a non-empty `description` (drives categorization quality)
|
||||
- [ ] Tier-1 suppliers are explicitly marked (don't let the tool guess)
|
||||
- [ ] Switching-cost estimates exist for any supplier you might consolidate
|
||||
- [ ] Renewal dates are populated for at least the top 20 contracts (drives renewal-cluster analysis)
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
# Procurement Anti-Patterns
|
||||
|
||||
A field guide to the most common procurement mistakes — drawn from A.T. Kearney's maverick-spend research, IACCM/WorldCC contract studies, McKinsey's category strategy commentary, Hackett purchasing-cycle research, BCG's supplier-consolidation post-mortems, Spend Matters' analyses of failed rationalization initiatives, and ISM (Institute for Supply Management) procurement maturity studies.
|
||||
|
||||
Use this file before running any tool. Most "spend audits" produce a beautiful slide deck that triggers a consolidation initiative that destroys 30-50% of the theoretical savings. Read these first.
|
||||
|
||||
---
|
||||
|
||||
## Sources (≥ 7)
|
||||
|
||||
1. **A.T. Kearney — Maverick spend research** (AEP studies, multi-year)
|
||||
2. **IACCM / WorldCC — *State of Contract and Commercial Management***
|
||||
3. **McKinsey — *The CPO Agenda* and category strategy commentary**
|
||||
4. **Hackett Group — *Procurement Performance Study*** (annual benchmarks)
|
||||
5. **BCG — Supplier consolidation case studies and *The CPO Agenda***
|
||||
6. **Spend Matters — Failed rationalization analyses** (Pierre Mitchell, Jason Busch)
|
||||
7. **ISM (Institute for Supply Management) — *Manage Indirect Spending* and lessons-learned studies**
|
||||
8. **Productiv / Zylo / Vendr / Tropic — SaaS-specific anti-patterns** (cross-referenced from `saas_management_canon.md`)
|
||||
|
||||
---
|
||||
|
||||
## Anti-pattern 1: Consolidate to single-source for a tier-1 critical category
|
||||
|
||||
**Pattern.** You have three monitoring tools. You consolidate to one. The new sole vendor has a major outage three months in. You have no break-glass plan because you offboarded the other two tools to capture the savings. Engineering is flying blind for 6 hours.
|
||||
|
||||
**Why it happens.** Savings math is easy and visible. Operational risk is intangible and unmeasured. The CFO incentive points one direction.
|
||||
|
||||
**Fix.** Before consolidating any tier-1 category, document a 72-hour break-glass plan: which alternative do you switch to, who executes the switch, what's the SLA expectation, where's the contractual fallback. The skill's `supplier_consolidation.py` refuses to recommend tier-1 consolidation without the `break_glass_documented: true` flag in the input.
|
||||
|
||||
**Canon.** BCG supplier-consolidation case studies (multi-year retrospectives show 30-50% of theoretical savings disappear due to operational disruption). NotPetya / M.E.Doc and SolarWinds supply-chain attack lessons.
|
||||
|
||||
---
|
||||
|
||||
## Anti-pattern 2: Categorize by vendor name, not by what's purchased
|
||||
|
||||
**Pattern.** You categorize Workday as "HR Software." But you also licensed the financial planning module — that's Finance Software. Your category Pareto now mis-attributes $400k of Finance spend to HR.
|
||||
|
||||
**Why it happens.** Vendor name is easy. Line-item description requires reading every entry.
|
||||
|
||||
**Fix.** Categorize from the line-item `description` and `category_hint`, not the supplier. The skill's `spend_categorizer.py` ranks `description` and `category_hint` ahead of `supplier` in keyword matching.
|
||||
|
||||
**Canon.** Pierre Mitchell / Spend Matters — *Category strategy mechanics*. UNSPSC categorization principle: classify the good/service, not the provider.
|
||||
|
||||
---
|
||||
|
||||
## Anti-pattern 3: Ignore renewal-date clustering
|
||||
|
||||
**Pattern.** Twelve tier-2 SaaS contracts all renew in March. You go into the negotiation cycle simultaneously, with three weeks to renegotiate twelve contracts. You auto-renew nine of them because you ran out of bandwidth.
|
||||
|
||||
**Why it happens.** Renewals piled up over years of unmonitored procurement. Nobody saw it because nobody built the calendar.
|
||||
|
||||
**Fix.** Build a renewal calendar (the skill outputs this). At each next renewal, negotiate term length deliberately (18-month, 6-month, 12-month rotation) to permanently spread the calendar across the year.
|
||||
|
||||
**Canon.** IACCM/WorldCC contract studies — 60-80% of contracts auto-renew without review. Vendr SaaS Buyers Report — quarter-end and year-end discounts are real, but only if you have negotiation bandwidth.
|
||||
|
||||
---
|
||||
|
||||
## Anti-pattern 4: Approve-by-default for sub-$5k spend (death by a thousand SaaS)
|
||||
|
||||
**Pattern.** Approval workflow requires CFO sign-off for $5k+ purchases. Below that, any manager can approve. Result: 80 SaaS subscriptions each costing $2-4k/year, totaling $250k of unmonitored spend that grows 50% YoY.
|
||||
|
||||
**Why it happens.** Approval thresholds are usually set once (often at company founding) and never re-tuned.
|
||||
|
||||
**Fix.** Tighten the sub-$5k threshold — but only for net-new SaaS, not for renewals of catalog items. Require a single owner for "death-by-a-thousand-SaaS" risk (typically the BizOps lead or a SaaS-management platform). The skill's `spend_categorizer.py` surfaces "small-spend, many-supplier" clusters explicitly.
|
||||
|
||||
**Canon.** A.T. Kearney maverick-spend research (10-40% of indirect spend leaks through sub-threshold purchases). BetterCloud State of SaaSOps — sub-$5k is the dominant shadow-IT entry point.
|
||||
|
||||
---
|
||||
|
||||
## Anti-pattern 5: No quarterly renewal review (annual is too slow)
|
||||
|
||||
**Pattern.** You do an "annual SaaS audit" every January. Between January and December, 30 new subscriptions get added, 12 grow >50%, and 8 auto-renew before you re-review them.
|
||||
|
||||
**Why it happens.** Annual reviews feel sufficient. They're not for SaaS, which is continuously renewing across the year.
|
||||
|
||||
**Fix.** Quarterly category review for tier-1 and tier-2 categories. Annual deep audit for tier-3 (low-spend, non-critical).
|
||||
|
||||
**Canon.** Forrester SaaS Portfolio Management — three-tier governance with quarterly cadence for high-tier categories. Hackett — world-class procurement reviews categories on a rolling quarterly basis.
|
||||
|
||||
---
|
||||
|
||||
## Anti-pattern 6: Rationalize without measuring switching cost
|
||||
|
||||
**Pattern.** You identify three monitoring tools costing $315k/year. You decide to consolidate to one tool costing $180k. Theoretical savings: $135k. Actual cost of migration (training, integration rework, alert re-tuning, parallel-run period): $200k. Net Y1 result: lost money.
|
||||
|
||||
**Why it happens.** Savings are visible (line-item subtraction). Switching cost is invisible (engineering time, parallel-run period, training).
|
||||
|
||||
**Fix.** Estimate switching cost explicitly for every consolidation. Sum across all losers in the cluster. Net Y1 savings = annual savings − migration cost. The skill's `supplier_consolidation.py` does this and flags `LOW_SAVINGS` for clusters where net Y1 < $10k.
|
||||
|
||||
**Canon.** BCG supplier-consolidation post-mortems. Tropic analysis of failed SaaS consolidations (60%+ failure rate to capture theoretical savings).
|
||||
|
||||
---
|
||||
|
||||
## Anti-pattern 7: Consolidate based on price alone, ignoring integration debt
|
||||
|
||||
**Pattern.** You consolidate to the cheapest monitoring tool. It doesn't integrate with your data warehouse or your incident management platform. You rebuild the integration plumbing for 6 months. The "cheaper" tool ends up costing more.
|
||||
|
||||
**Why it happens.** Price is easy to compare. Integration depth is hard to score.
|
||||
|
||||
**Fix.** Score `integration_count_with_other_systems` as a winner-selection input, not just price. The skill's `pick_winner` function uses integration count as the primary tiebreaker for tier-2/3 clusters.
|
||||
|
||||
**Canon.** Spend Matters — *Total Cost of Ownership in procurement decisions*. McKinsey — category strategy mistakes (price-only thinking is the most common error).
|
||||
|
||||
---
|
||||
|
||||
## Anti-pattern 8: Treat shadow IT spend as marketing's (or any other department's) problem
|
||||
|
||||
**Pattern.** Marketing has 14 unmonitored SaaS subscriptions. Procurement says "that's marketing's problem." Marketing says "we don't have the procurement bandwidth to manage that." Nobody owns it.
|
||||
|
||||
**Why it happens.** Shadow IT lives in expense reports and corporate-card transactions, which procurement doesn't see. Department heads see it but lack procurement skills.
|
||||
|
||||
**Fix.** Procurement owns the audit, even of departmental spend. A SaaS-management platform (or expense-platform integration) discovers shadow subscriptions. The Productiv finding (47% of SaaS spend is shadow) is the size of the prize.
|
||||
|
||||
**Canon.** Productiv State of SaaS (47% shadow IT). Zylo SaaS Management Index (marketing and engineering are the top two shadow-IT entry points).
|
||||
|
||||
---
|
||||
|
||||
## Anti-pattern 9: Negotiate without a BATNA (Best Alternative To Negotiated Agreement)
|
||||
|
||||
**Pattern.** You go into renewal with your monitoring vendor without having priced any alternative. The vendor knows you have no BATNA. You get 5% off list because you have no leverage.
|
||||
|
||||
**Why it happens.** Pricing alternatives takes time and feels confrontational.
|
||||
|
||||
**Fix.** Before any renewal worth $50k+, get a competitive quote — even a non-serious one. The existence of an alternative changes the negotiation tone.
|
||||
|
||||
**Canon.** Vendr SaaS Buyers Report on negotiation leverage. McKinsey — category strategy requires a credible threat of substitution. Tropic per-category pricing benchmarks provide the BATNA when you can't get a live quote.
|
||||
|
||||
---
|
||||
|
||||
## Anti-pattern 10: Skip the offboarding checklist when consolidating
|
||||
|
||||
**Pattern.** You consolidate three monitoring tools to one. Six months later, you discover the offboarded tools still have your data, still have active API keys, and one of them quietly auto-renewed because the offboarding paperwork was never filed.
|
||||
|
||||
**Why it happens.** Consolidation projects celebrate the new tool going live; offboarding the old tools is treated as paperwork.
|
||||
|
||||
**Fix.** Offboarding checklist per loser: cancel auto-renew, delete data, revoke API keys, rotate any shared credentials, confirm final invoice. The skill's `supplier_consolidation.py` outputs an explicit "Offboard:" list per cluster.
|
||||
|
||||
**Canon.** BetterCloud SaaS Operations on offboarding gaps. SolarWinds + Okta breach lessons on lingering vendor access.
|
||||
|
||||
---
|
||||
|
||||
## How this skill defends against the anti-patterns
|
||||
|
||||
| Anti-pattern | Skill defense |
|
||||
|---|---|
|
||||
| Single-source tier-1 | `supplier_consolidation.py` hard refusal without `break_glass_documented: true` |
|
||||
| Categorize by vendor | `spend_categorizer.py` reads description + category_hint, not just supplier |
|
||||
| Renewal clustering | `supplier_consolidation.py` flags months with ≥ 3 simultaneous renewals |
|
||||
| Sub-$5k death | `spend_categorizer.py` surfaces small-spend many-supplier clusters |
|
||||
| Annual is too slow | Forcing-question library asks about quarterly cadence |
|
||||
| Ignore switching cost | `supplier_consolidation.py` requires `switching_cost_estimate`; net Y1 = savings − migration |
|
||||
| Price-only consolidation | `supplier_consolidation.py` weights `integration_count_with_other_systems` in winner selection |
|
||||
| Shadow IT is "marketing's problem" | Forcing-question library asks who owns sub-$5k SaaS |
|
||||
| No BATNA | Forcing-question library asks about competitive quotes before renewal |
|
||||
| Skip offboarding | `supplier_consolidation.py` outputs explicit Offboard list per cluster |
|
||||
@@ -0,0 +1,91 @@
|
||||
# SaaS Management Canon
|
||||
|
||||
SaaS sprawl is the dominant indirect-spend category for tech companies and the #1 driver of spend growth in scaleups. This file curates the research on SaaS portfolio management — distinct from generic procurement because SaaS has unique characteristics: per-seat pricing, auto-renew defaults, shadow-IT entry, and a viable replacement every 18 months.
|
||||
|
||||
---
|
||||
|
||||
## Sources (≥ 7)
|
||||
|
||||
### 1. Productiv — *State of SaaS* (annual report)
|
||||
|
||||
Productiv's annual benchmark across hundreds of enterprises is the most-cited SaaS sprawl data source. Key findings (most recent waves):
|
||||
|
||||
- **The median mid-stage company has 130-250 distinct SaaS subscriptions**, of which 30-50% are used by < 10% of licensed users.
|
||||
- **License utilization median is 47%** — meaning more than half of SaaS spend buys seats nobody logs into.
|
||||
- **47% of SaaS spend is shadow IT** (purchased outside the procurement process). The skill's anti-pattern list calls this out: "treat shadow IT spend as marketing's problem — it isn't."
|
||||
- **Renewal is the highest-leverage moment**: 67% of SaaS purchases auto-renew without review.
|
||||
|
||||
Use when: framing the size of the prize for a SaaS audit.
|
||||
|
||||
### 2. Zylo — *SaaS Management Index* and annual benchmarks
|
||||
|
||||
Zylo's research focuses on SaaS economics:
|
||||
|
||||
- **SaaS spend grows ~30% YoY in scaleups** vs. ~10% headcount growth — meaning per-employee SaaS cost is growing.
|
||||
- **Duplicate-function clusters** are extraordinarily common: most enterprises have 3-5 monitoring tools, 2-3 expense platforms, 4+ email-marketing tools. This skill's clustering logic is calibrated to Zylo's observed cluster patterns.
|
||||
- **Lowest-hanging consolidation savings** are in marketing tech (often 30-40% redundancy) and developer tools.
|
||||
|
||||
### 3. Vendr — *SaaS Buyers Report* and pricing intelligence
|
||||
|
||||
Vendr's procurement-negotiation research is the practitioner's playbook for SaaS pricing leverage:
|
||||
|
||||
- **Median SaaS discount achievable** ranges 10-40% off list, driven by: term length, payment terms, multi-year commit, and **renewal-date timing**. Vendr's data confirms that vendors discount more aggressively at quarter-end and year-end.
|
||||
- **The "MSA + Order Form" pattern** lets you re-negotiate per-order pricing without re-opening the master agreement — important for SaaS where the master may have unfavorable renewal terms locked in.
|
||||
|
||||
### 4. Tropic — *SaaS Cost Index* and category benchmarks
|
||||
|
||||
Tropic's per-category pricing benchmarks are public:
|
||||
|
||||
- **Per-seat pricing benchmarks** by category (CRM, ATS, HRIS, monitoring, etc.) give you the BATNA when negotiating. If you're paying 2× the Tropic median for Salesforce seats, you have leverage.
|
||||
- **Tropic's analysis of consolidation success rates** shows that 60%+ of attempted SaaS consolidations fail to capture the theoretical savings, primarily due to (a) underestimated training cost, (b) loss of feature parity, (c) tier-1 single-source operational risk.
|
||||
|
||||
### 5. BetterCloud — *State of SaaSOps* (annual)
|
||||
|
||||
BetterCloud's operations research focuses on the lifecycle (onboarding → utilization → offboarding):
|
||||
|
||||
- **The offboarding gap:** when a SaaS tool is replaced, the old tool's licenses, data, and access often linger for 3-12 months — pure waste. SaaS audit must include an offboarding completeness check.
|
||||
- **Sub-$5k SaaS purchases** are the dominant entry point for sprawl: they typically skip procurement review entirely and self-renew before anyone notices.
|
||||
|
||||
### 6. Gartner — *Magic Quadrant for SaaS Management Platforms (SMP)*
|
||||
|
||||
Gartner's SMP MQ defines the tooling category:
|
||||
|
||||
- **SMP capabilities:** discovery (find shadow SaaS), inventory (catalog all subscriptions), license utilization (who's actually logging in), renewal management (calendar + alerts), spend analytics (Pareto + YoY).
|
||||
- The skill's deliverables (categorized spend, consolidation plan, renewal cluster analysis) are the artifacts an SMP would generate — useful when the user doesn't have an SMP licensed yet.
|
||||
|
||||
### 7. Tomasz Tunguz (Theory Ventures) — Long-running blog on SaaS economics
|
||||
|
||||
Tunguz's analysis of SaaS sprawl from the buyer side:
|
||||
|
||||
- **The "consumption shift"** from seat-based to usage-based pricing is changing the rationalization math: usage-based tools are harder to consolidate because their cost scales with workload, not seat count.
|
||||
- **Long-tail SaaS** (the bottom 50% of subscriptions by spend) is where shadow IT lives. Killing 30 tools that cost $200/year each is psychologically harder than consolidating 3 tools that cost $90k/year each, but the operational simplification is comparable.
|
||||
|
||||
### 8. Patrick Campbell / ProfitWell — SaaS unit economics research
|
||||
|
||||
Campbell's research focuses on the seller side but the buyer-side implications are direct:
|
||||
|
||||
- **Annual contracts vs. monthly:** annual gives the vendor cash-flow stability and the buyer 10-30% discount, BUT it also locks in pricing and makes it harder to walk away mid-cycle. The skill's renewal-date clustering analysis flags this trade-off.
|
||||
- **The "price-sensitivity range"** for SaaS pricing is wider than commonly believed — vendors will discount more than buyers expect when shown a credible alternative and a hard renewal deadline.
|
||||
|
||||
### 9. Bain — SaaS portfolio research (multi-year)
|
||||
|
||||
Bain's research on enterprise SaaS portfolios complements the practitioner sources:
|
||||
|
||||
- **Enterprise SaaS portfolios grow to 250-500 subscriptions** at the Fortune 1000 scale, with a power-law spend distribution: top 10 vendors capture 50-60% of spend.
|
||||
- **The "category overlap"** finding (one vendor in multiple categories, e.g., Microsoft 365 spans productivity + identity + storage) is why categorizing by line-item description matters more than categorizing by vendor.
|
||||
|
||||
### 10. Forrester — *SaaS Portfolio Management* research
|
||||
|
||||
Forrester's research formalizes the SaaS portfolio governance question:
|
||||
|
||||
- **Three-tier governance model:** enterprise SaaS (procurement-led), departmental SaaS (FinOps-led), individual SaaS (expense-led). Each tier has different approval thresholds and review cadences.
|
||||
- **Quarterly SaaS reviews** are the recommended cadence for mid-stage companies; annual is too slow when 30% of subscriptions are < 12 months old.
|
||||
|
||||
---
|
||||
|
||||
## How this skill applies the canon
|
||||
|
||||
- **Spend categorizer's category map** includes the duplicate-function clusters Zylo and Productiv observe: Monitoring, Expense, Email Marketing, Analytics, Security Tooling.
|
||||
- **Supplier consolidation's tier-1 single-source refusal** is calibrated to Tropic and Vendr's research on failed consolidations.
|
||||
- **Renewal-cluster analysis** is informed by IACCM and Vendr research on negotiation timing.
|
||||
- **The sub-$5k approval anti-pattern** comes from BetterCloud and Productiv shadow-IT research.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Spend Management Canon
|
||||
|
||||
The authoritative literature on procurement spend management, category strategy, and supplier rationalization. Read these before opening the categorizer or consolidation planner — most "spend audits" fail because they categorize by supplier name instead of by what's purchased, miss the Pareto, or rationalize without measuring switching cost.
|
||||
|
||||
---
|
||||
|
||||
## Sources (≥ 7)
|
||||
|
||||
### 1. A.T. Kearney — *Assessment of Excellence in Procurement (AEP)*
|
||||
|
||||
A.T. Kearney's biennial AEP benchmark is the longest-running cross-industry procurement maturity study (1992–present). Key findings relevant to this skill:
|
||||
|
||||
- **Top-quartile procurement orgs deliver 7-15× ROI** vs. their function spend, driven primarily by *category strategy* rather than negotiation tactics.
|
||||
- **Maverick spend** (purchases bypassing approved suppliers) averages 10-40% of indirect spend in unmonitored companies and is the #1 leakage point.
|
||||
- **Category architecture** (the taxonomy you categorize against) is the foundation; without it, you cannot find the Pareto.
|
||||
|
||||
Use when: justifying a spend audit to leadership ("here's why this matters"), defending a category taxonomy investment.
|
||||
|
||||
### 2. Pierre Mitchell / Spend Matters — Category strategy and supplier rationalization research
|
||||
|
||||
Pierre Mitchell at Spend Matters is the most-cited practitioner on category strategy mechanics. Key principles:
|
||||
|
||||
- **Categorize by what's purchased, not by who provided it.** Workday spans HR (HRIS) and Finance (HCM/Payroll) — splitting it correctly changes the category Pareto.
|
||||
- **Supplier rationalization is a 3-step decision:** (1) keep / consolidate / kill, (2) negotiate / re-bid / status-quo, (3) automate / manual. Most teams collapse these into one decision and get it wrong.
|
||||
- **The "right number of suppliers" question is wrong.** The right question is: what's the marginal cost of adding the Nth supplier in this category?
|
||||
|
||||
### 3. Hackett Group — *Procurement Performance Study* (annual)
|
||||
|
||||
The Hackett Group benchmarks procurement org performance on cost-to-serve, cycle time, and savings yield. Key benchmarks:
|
||||
|
||||
- **World-class procurement orgs have 26% lower process cost per PO** than peers — driven by automation of low-risk categories (catalog buys) so judgment is reserved for high-risk ones.
|
||||
- **Purchasing cycle median** (request → PO) is 3-7 days for catalog items and 21-90+ days for negotiated services. The skill's bottleneck flag uses 2× median because real cycle distributions are highly skewed.
|
||||
- **Approver-hop reduction** is the highest-ROI process intervention: most companies route every $5k+ purchase through 3-5 approvers; world-class routes by category risk, not dollar value.
|
||||
|
||||
### 4. BCG — *The CPO Agenda: Driving Value Through Procurement* (multi-year series)
|
||||
|
||||
BCG's procurement value-creation research emphasizes:
|
||||
|
||||
- **Category-led negotiation** delivers 2-3× the savings of supplier-led negotiation (because category strategy gives you the BATNA).
|
||||
- **Supplier consolidation risk:** BCG's case studies of failed consolidations show that 30-50% of theoretical savings disappear due to: (a) underestimated migration cost, (b) loss of competitive tension, (c) single-source operational risk. The skill's hard refusal to consolidate tier-1 to single-source without break-glass is directly from this literature.
|
||||
- **Renewal-date clustering** kills negotiation leverage; spreading renewals across the year is a 1-time investment in 1-3% annual savings.
|
||||
|
||||
### 5. Procurement Leaders — Member research and benchmarks
|
||||
|
||||
Procurement Leaders (now part of World 50 Group) publishes member benchmark studies on category management, supplier relationship management, and digital procurement. Key insights:
|
||||
|
||||
- **20% of categories drive 80% of value** — the Pareto applies but the threshold is empirical, not theoretical. Run it on your own data.
|
||||
- **Category managers spend < 10% of time on the 80%-impact categories** in untuned organizations, because every category gets equal effort regardless of strategic weight.
|
||||
|
||||
### 6. Gartner — *Magic Quadrant for Source-to-Pay Suites* and procurement research
|
||||
|
||||
Gartner's procurement research informs the tooling landscape and decision processes:
|
||||
|
||||
- **Source-to-pay automation** is the dominant procurement digitalization theme; spend categorization is the gateway capability that everything else (analytics, supplier risk, contract management) depends on.
|
||||
- **UNSPSC is the de facto category taxonomy** for cross-company benchmarking. The full UNSPSC database has ~100k entries across 4 levels (Segment / Family / Class / Commodity); most companies use only Class-level (~5k codes) and the top 200 codes cover most of their spend.
|
||||
|
||||
### 7. UNSPSC — Official taxonomy documentation (United Nations Standard Products and Services Code)
|
||||
|
||||
UNSPSC is maintained by GS1 US and is the most-adopted international product/service classification standard. Reference points:
|
||||
|
||||
- **4-level hierarchy:** Segment (e.g., 43 — Information Technology Broadcasting and Telecommunications) → Family (e.g., 4323 — Software) → Class (e.g., 432315 — Business Function Specific Software) → Commodity (e.g., 43231505 — Database management system software).
|
||||
- **Adopted by:** UN, World Bank, US Federal procurement, most Global 2000 enterprises.
|
||||
- **This skill ships ~30 Class-level categories** aligned to UNSPSC nomenclature but not the full code set, because (a) the full set is overwhelming for first-pass categorization, (b) tech-company spend concentrates in 30 categories, (c) the skill's job is "find the Pareto", not "ISO-compliant procurement reporting."
|
||||
|
||||
For the full UNSPSC codeset, see: https://www.unspsc.org/
|
||||
|
||||
### 8. IACCM / WorldCC — *State of Contract and Commercial Management*
|
||||
|
||||
IACCM (now WorldCC) publishes contract management research that intersects with spend management at the renewal-cluster question:
|
||||
|
||||
- **Median enterprise has 60-80% of contracts auto-renewing** with no review, destroying both negotiation leverage and the ability to catch maverick categories early.
|
||||
- **Renewal-date clustering** (multiple contracts renewing in the same calendar month) is a frequently-cited finding; the recommended fix is term-length negotiation at the next renewal to permanently stagger the calendar.
|
||||
|
||||
---
|
||||
|
||||
## How this skill applies the canon
|
||||
|
||||
- **Spend categorizer** uses Hackett/Gartner Pareto framing + UNSPSC-aligned taxonomy (Class level only).
|
||||
- **Purchasing cycle analyzer** uses Hackett baseline (median, P90) + Goldratt-style 2× median bottleneck threshold.
|
||||
- **Supplier consolidation planner** uses BCG migration-cost discipline + the tier-1 single-source refusal as a hard rule.
|
||||
- **Renewal-cluster analysis** comes directly from IACCM and Spend Matters research on negotiation leverage timing.
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
purchasing_cycle_analyzer.py — Per-category cycle-time scorecard with bottleneck flagging.
|
||||
|
||||
Input: a list of PO records with timestamps for each step (request, approval, PO,
|
||||
goods receipt, payment). Output: per-category cycle-time statistics (median, P90)
|
||||
plus a Goldratt-style bottleneck flag for any category whose cycle time exceeds
|
||||
2x the cross-category median — the constraint is one specific category, not the
|
||||
"average procurement process."
|
||||
|
||||
Stdlib only. Deterministic. No LLM calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ---------- Data model ----------
|
||||
|
||||
@dataclass
|
||||
class PORecord:
|
||||
category: str
|
||||
request_date: date | None
|
||||
approval_date: date | None
|
||||
po_issued_date: date | None
|
||||
goods_received_date: date | None
|
||||
payment_date: date | None
|
||||
approver_hops: int
|
||||
|
||||
@staticmethod
|
||||
def _parse(d: Any) -> date | None:
|
||||
if not d:
|
||||
return None
|
||||
if isinstance(d, date):
|
||||
return d
|
||||
try:
|
||||
return datetime.strptime(str(d)[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "PORecord":
|
||||
return cls(
|
||||
category=str(d.get("category", "Uncategorized")),
|
||||
request_date=cls._parse(d.get("request_date")),
|
||||
approval_date=cls._parse(d.get("approval_date")),
|
||||
po_issued_date=cls._parse(d.get("po_issued_date")),
|
||||
goods_received_date=cls._parse(d.get("goods_received_date")),
|
||||
payment_date=cls._parse(d.get("payment_date")),
|
||||
approver_hops=int(d.get("approver_hops", 0)),
|
||||
)
|
||||
|
||||
|
||||
def _days(a: date | None, b: date | None) -> int | None:
|
||||
if a is None or b is None:
|
||||
return None
|
||||
return (b - a).days
|
||||
|
||||
|
||||
# ---------- Aggregation ----------
|
||||
|
||||
@dataclass
|
||||
class CategoryStats:
|
||||
category: str
|
||||
n: int
|
||||
request_to_po_median: float | None
|
||||
request_to_po_p90: float | None
|
||||
po_to_pay_median: float | None
|
||||
po_to_pay_p90: float | None
|
||||
approver_hops_median: float | None
|
||||
|
||||
|
||||
def _p90(values: list[int]) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
s = sorted(values)
|
||||
# Linear interpolation P90 (stdlib has no quantiles in older pythons; do manually)
|
||||
k = (len(s) - 1) * 0.9
|
||||
lo = int(k)
|
||||
hi = min(lo + 1, len(s) - 1)
|
||||
frac = k - lo
|
||||
return s[lo] + (s[hi] - s[lo]) * frac
|
||||
|
||||
|
||||
def _median(values: list[int]) -> float | None:
|
||||
return statistics.median(values) if values else None
|
||||
|
||||
|
||||
def per_category_stats(records: list[PORecord]) -> dict[str, CategoryStats]:
|
||||
by_cat: dict[str, list[PORecord]] = {}
|
||||
for r in records:
|
||||
by_cat.setdefault(r.category, []).append(r)
|
||||
|
||||
result: dict[str, CategoryStats] = {}
|
||||
for cat, recs in by_cat.items():
|
||||
r2po = [d for d in (_days(r.request_date, r.po_issued_date) for r in recs) if d is not None]
|
||||
po2pay = [d for d in (_days(r.po_issued_date, r.payment_date) for r in recs) if d is not None]
|
||||
hops = [r.approver_hops for r in recs if r.approver_hops >= 0]
|
||||
result[cat] = CategoryStats(
|
||||
category=cat,
|
||||
n=len(recs),
|
||||
request_to_po_median=_median(r2po),
|
||||
request_to_po_p90=(_p90(r2po) if r2po else None),
|
||||
po_to_pay_median=_median(po2pay),
|
||||
po_to_pay_p90=(_p90(po2pay) if po2pay else None),
|
||||
approver_hops_median=_median(hops),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def overall_median_r2po(stats: dict[str, CategoryStats]) -> float:
|
||||
"""Cross-category median of request->PO median (used as the bottleneck baseline)."""
|
||||
medians = [s.request_to_po_median for s in stats.values() if s.request_to_po_median is not None]
|
||||
if not medians:
|
||||
return 0.0
|
||||
return statistics.median(medians)
|
||||
|
||||
|
||||
# ---------- Rendering ----------
|
||||
|
||||
def render_markdown(stats: dict[str, CategoryStats]) -> str:
|
||||
baseline = overall_median_r2po(stats)
|
||||
threshold = baseline * 2.0 if baseline > 0 else None
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("# Purchasing Cycle-Time Scorecard\n")
|
||||
lines.append(f"- **Categories analyzed:** {len(stats)}")
|
||||
if baseline > 0:
|
||||
lines.append(f"- **Cross-category median (Request → PO):** {baseline:.1f} days")
|
||||
lines.append(f"- **Bottleneck threshold (2× median):** {threshold:.1f} days\n")
|
||||
else:
|
||||
lines.append("- (Insufficient data for cross-category baseline)\n")
|
||||
|
||||
lines.append("## Per-category cycle times\n")
|
||||
lines.append("| Category | N | Req→PO median | Req→PO P90 | PO→Pay median | PO→Pay P90 | Hops | Bottleneck? |")
|
||||
lines.append("|---|---:|---:|---:|---:|---:|---:|---|")
|
||||
|
||||
sorted_cats = sorted(
|
||||
stats.values(),
|
||||
key=lambda s: -(s.request_to_po_median or 0),
|
||||
)
|
||||
for s in sorted_cats:
|
||||
is_bottleneck = (
|
||||
threshold is not None
|
||||
and s.request_to_po_median is not None
|
||||
and s.request_to_po_median > threshold
|
||||
)
|
||||
flag = "**BOTTLENECK**" if is_bottleneck else "—"
|
||||
lines.append(
|
||||
f"| {s.category} | {s.n} | "
|
||||
f"{_fmt(s.request_to_po_median)} | {_fmt(s.request_to_po_p90)} | "
|
||||
f"{_fmt(s.po_to_pay_median)} | {_fmt(s.po_to_pay_p90)} | "
|
||||
f"{_fmt(s.approver_hops_median)} | {flag} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Goldratt commentary
|
||||
bottlenecks = [
|
||||
s for s in stats.values()
|
||||
if threshold is not None
|
||||
and s.request_to_po_median is not None
|
||||
and s.request_to_po_median > threshold
|
||||
]
|
||||
lines.append("## Goldratt — find the constraint\n")
|
||||
if bottlenecks:
|
||||
lines.append(
|
||||
f"**{len(bottlenecks)}** categor{'y' if len(bottlenecks)==1 else 'ies'} "
|
||||
f"exceed the 2× median bottleneck threshold:\n"
|
||||
)
|
||||
for s in bottlenecks:
|
||||
lines.append(
|
||||
f"- **{s.category}** — Request→PO median {s.request_to_po_median:.1f}d, "
|
||||
f"P90 {_fmt(s.request_to_po_p90)}d, "
|
||||
f"approver hops median {_fmt(s.approver_hops_median)}"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Improving any non-bottleneck category does not change overall throughput. "
|
||||
"Focus on the constraint first — typical fixes per stage:"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("- **Long Request→Approval:** approval routing, parallel review, raise auto-approve threshold for low-risk categories.")
|
||||
lines.append("- **Long Approval→PO:** PO creation friction; consider catalog buys for repeating categories.")
|
||||
lines.append("- **High approver hops:** collapse routing tiers; one approver per $-band, not three.")
|
||||
lines.append("- **Long PO→Pay:** AP cycle (3-way match, batch runs); negotiate net-terms only after measuring.")
|
||||
else:
|
||||
lines.append("No category exceeds the 2× bottleneck threshold. The process is uniformly fast (or uniformly slow — check the baseline).\n")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _fmt(v: float | None) -> str:
|
||||
if v is None:
|
||||
return "—"
|
||||
return f"{v:.1f}"
|
||||
|
||||
|
||||
# ---------- Sample data ----------
|
||||
|
||||
SAMPLE_INPUT: list[dict[str, Any]] = [
|
||||
# Fast: SaaS / Subscription Software
|
||||
{"category": "SaaS / Subscription Software", "request_date": "2026-01-02",
|
||||
"approval_date": "2026-01-03", "po_issued_date": "2026-01-04",
|
||||
"goods_received_date": "2026-01-04", "payment_date": "2026-01-15",
|
||||
"approver_hops": 1},
|
||||
{"category": "SaaS / Subscription Software", "request_date": "2026-02-01",
|
||||
"approval_date": "2026-02-03", "po_issued_date": "2026-02-05",
|
||||
"goods_received_date": "2026-02-05", "payment_date": "2026-02-20",
|
||||
"approver_hops": 1},
|
||||
{"category": "SaaS / Subscription Software", "request_date": "2026-03-01",
|
||||
"approval_date": "2026-03-02", "po_issued_date": "2026-03-04",
|
||||
"goods_received_date": "2026-03-04", "payment_date": "2026-03-22",
|
||||
"approver_hops": 1},
|
||||
# Slow: Outside Counsel (legal review is the bottleneck)
|
||||
{"category": "Outside Counsel", "request_date": "2026-01-05",
|
||||
"approval_date": "2026-02-15", "po_issued_date": "2026-02-28",
|
||||
"goods_received_date": "2026-02-28", "payment_date": "2026-03-30",
|
||||
"approver_hops": 4},
|
||||
{"category": "Outside Counsel", "request_date": "2026-02-01",
|
||||
"approval_date": "2026-03-12", "po_issued_date": "2026-03-30",
|
||||
"goods_received_date": "2026-03-30", "payment_date": "2026-04-30",
|
||||
"approver_hops": 4},
|
||||
{"category": "Outside Counsel", "request_date": "2026-03-01",
|
||||
"approval_date": "2026-04-25", "po_issued_date": "2026-05-05",
|
||||
"goods_received_date": "2026-05-05", "payment_date": "2026-06-15",
|
||||
"approver_hops": 5},
|
||||
# Medium: Cloud Infrastructure
|
||||
{"category": "Cloud Infrastructure", "request_date": "2026-01-10",
|
||||
"approval_date": "2026-01-15", "po_issued_date": "2026-01-22",
|
||||
"goods_received_date": "2026-01-22", "payment_date": "2026-02-15",
|
||||
"approver_hops": 2},
|
||||
{"category": "Cloud Infrastructure", "request_date": "2026-02-05",
|
||||
"approval_date": "2026-02-12", "po_issued_date": "2026-02-20",
|
||||
"goods_received_date": "2026-02-20", "payment_date": "2026-03-12",
|
||||
"approver_hops": 2},
|
||||
# Medium: Recruiting
|
||||
{"category": "Recruiting Services", "request_date": "2026-01-15",
|
||||
"approval_date": "2026-01-22", "po_issued_date": "2026-01-28",
|
||||
"goods_received_date": "2026-01-28", "payment_date": "2026-02-20",
|
||||
"approver_hops": 2},
|
||||
{"category": "Recruiting Services", "request_date": "2026-02-10",
|
||||
"approval_date": "2026-02-15", "po_issued_date": "2026-02-22",
|
||||
"goods_received_date": "2026-02-22", "payment_date": "2026-03-15",
|
||||
"approver_hops": 2},
|
||||
]
|
||||
|
||||
|
||||
# ---------- CLI ----------
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--input", type=str, help="Path to JSON list of PO records")
|
||||
p.add_argument("--output", type=str, help="Path to write markdown report")
|
||||
p.add_argument("--sample", action="store_true", help="Run with built-in sample data")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.sample:
|
||||
data = SAMPLE_INPUT
|
||||
elif args.input:
|
||||
try:
|
||||
data = json.loads(Path(args.input).read_text())
|
||||
except Exception as e:
|
||||
print(f"error reading {args.input}: {e}", file=sys.stderr)
|
||||
return 2
|
||||
else:
|
||||
p.print_help()
|
||||
return 0
|
||||
|
||||
records = [PORecord.from_dict(d) for d in data]
|
||||
stats = per_category_stats(records)
|
||||
md = render_markdown(stats)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(md)
|
||||
print(f"wrote {args.output}")
|
||||
else:
|
||||
print(md)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,399 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
spend_categorizer.py — UNSPSC-aligned spend categorization + Pareto + YoY growth.
|
||||
|
||||
Maps each line item to a UNSPSC-aligned Class -> Family -> Segment using a built-in
|
||||
category map (~30 categories tuned for tech-company spend; NOT the full UNSPSC DB).
|
||||
Computes Pareto (which 20% of categories drive 80% of spend?) and YoY growth when
|
||||
prior-year data is supplied.
|
||||
|
||||
Industry profiles re-prioritize category matching:
|
||||
tech-startup | scaleup | enterprise | services | manufacturing
|
||||
|
||||
Stdlib only. Deterministic. No LLM calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ---------- Built-in UNSPSC-aligned category map ----------
|
||||
# Shape: keyword -> (Segment, Family, Class)
|
||||
# Segment is the top-level UNSPSC segment number range concept; we use plain names
|
||||
# so the artifact reads cleanly without requiring the full 100k UNSPSC codeset.
|
||||
|
||||
CATEGORY_MAP: list[tuple[list[str], tuple[str, str, str]]] = [
|
||||
# Software / SaaS
|
||||
(["saas", "software license", "subscription", "seat license"],
|
||||
("Information Technology", "Software", "SaaS / Subscription Software")),
|
||||
(["crm", "salesforce", "hubspot"],
|
||||
("Information Technology", "Software", "CRM Platform")),
|
||||
(["monitoring", "datadog", "new relic", "grafana", "splunk", "observability"],
|
||||
("Information Technology", "Software", "Monitoring / Observability")),
|
||||
(["expense", "ramp", "brex", "expensify", "navan", "concur"],
|
||||
("Information Technology", "Software", "Expense / Spend Management")),
|
||||
(["email marketing", "mailchimp", "klaviyo", "marketo", "iterable", "sendgrid"],
|
||||
("Marketing", "MarTech", "Email Marketing Platform")),
|
||||
(["analytics", "amplitude", "mixpanel", "heap", "ga4"],
|
||||
("Information Technology", "Software", "Product Analytics")),
|
||||
(["hris", "hr software", "workday", "rippling", "gusto", "bamboohr"],
|
||||
("Human Resources", "Software", "HRIS / Payroll")),
|
||||
(["ats", "applicant tracking", "greenhouse", "lever"],
|
||||
("Human Resources", "Software", "Applicant Tracking System")),
|
||||
(["security software", "okta", "1password", "snyk", "wiz", "crowdstrike"],
|
||||
("Information Technology", "Security", "Security Tooling")),
|
||||
(["data warehouse", "snowflake", "bigquery", "databricks", "redshift"],
|
||||
("Information Technology", "Cloud Infrastructure", "Data Warehouse")),
|
||||
# Cloud Infrastructure
|
||||
(["aws", "amazon web services", "ec2", "s3"],
|
||||
("Information Technology", "Cloud Infrastructure", "AWS")),
|
||||
(["gcp", "google cloud"],
|
||||
("Information Technology", "Cloud Infrastructure", "GCP")),
|
||||
(["azure", "microsoft azure"],
|
||||
("Information Technology", "Cloud Infrastructure", "Azure")),
|
||||
(["cloudflare", "cdn", "fastly", "akamai"],
|
||||
("Information Technology", "Cloud Infrastructure", "CDN / Edge")),
|
||||
# Hardware
|
||||
(["laptop", "macbook", "thinkpad", "computer", "workstation"],
|
||||
("Information Technology", "Hardware", "Endpoint Devices")),
|
||||
(["monitor", "display", "peripheral", "keyboard", "mouse"],
|
||||
("Information Technology", "Hardware", "Peripherals")),
|
||||
# Professional Services
|
||||
(["legal services", "law firm", "outside counsel"],
|
||||
("Professional Services", "Legal", "Outside Counsel")),
|
||||
(["accounting", "audit", "tax", "cpa", "deloitte", "pwc", "ey", "kpmg"],
|
||||
("Professional Services", "Accounting", "Audit / Tax / Accounting")),
|
||||
(["consulting", "consultant", "advisory", "mckinsey", "bcg"],
|
||||
("Professional Services", "Consulting", "Management Consulting")),
|
||||
(["contractor", "agency", "freelance"],
|
||||
("Professional Services", "Contract Labor", "Contractor / Freelance")),
|
||||
# Marketing Services
|
||||
(["advertising", "google ads", "facebook ads", "linkedin ads", "ppc"],
|
||||
("Marketing", "Advertising", "Paid Media")),
|
||||
(["content", "copywriting", "blog", "seo agency"],
|
||||
("Marketing", "Content", "Content Production")),
|
||||
(["event", "conference", "trade show", "sponsorship"],
|
||||
("Marketing", "Events", "Events / Sponsorship")),
|
||||
# Recruiting
|
||||
(["recruiting", "headhunter", "executive search", "linkedin recruiter"],
|
||||
("Human Resources", "Recruiting", "Recruiting Services")),
|
||||
# Travel
|
||||
(["travel", "flight", "airfare", "hotel", "lodging", "uber", "lyft"],
|
||||
("General & Administrative", "Travel", "Travel")),
|
||||
# Office / Facilities
|
||||
(["office", "rent", "lease", "wework", "coworking", "facilities"],
|
||||
("General & Administrative", "Facilities", "Office / Rent")),
|
||||
(["utilities", "electric", "internet", "phone"],
|
||||
("General & Administrative", "Facilities", "Utilities")),
|
||||
# Insurance / Benefits
|
||||
(["insurance", "liability", "d&o", "cyber insurance"],
|
||||
("General & Administrative", "Insurance", "Business Insurance")),
|
||||
(["benefits", "health insurance", "401k", "dental", "vision"],
|
||||
("Human Resources", "Benefits", "Employee Benefits")),
|
||||
]
|
||||
|
||||
UNCATEGORIZED = ("Uncategorized", "Uncategorized", "Uncategorized")
|
||||
|
||||
|
||||
# ---------- Industry profile priorities ----------
|
||||
# Profiles influence which category is selected when multiple keywords match.
|
||||
# The first-listed category in the priority list wins ties.
|
||||
|
||||
PROFILE_PRIORITIES: dict[str, list[str]] = {
|
||||
"tech-startup": [
|
||||
"SaaS / Subscription Software",
|
||||
"AWS", "GCP", "Azure",
|
||||
"Monitoring / Observability",
|
||||
"Data Warehouse",
|
||||
"Security Tooling",
|
||||
"Contractor / Freelance",
|
||||
],
|
||||
"scaleup": [
|
||||
"Recruiting Services",
|
||||
"CRM Platform",
|
||||
"Paid Media",
|
||||
"Email Marketing Platform",
|
||||
"HRIS / Payroll",
|
||||
"SaaS / Subscription Software",
|
||||
],
|
||||
"enterprise": [
|
||||
"Management Consulting",
|
||||
"Outside Counsel",
|
||||
"Audit / Tax / Accounting",
|
||||
"Office / Rent",
|
||||
"Employee Benefits",
|
||||
"Business Insurance",
|
||||
],
|
||||
"services": [
|
||||
"Contractor / Freelance",
|
||||
"Outside Counsel",
|
||||
"Travel",
|
||||
"SaaS / Subscription Software",
|
||||
],
|
||||
"manufacturing": [
|
||||
"Endpoint Devices",
|
||||
"Peripherals",
|
||||
"Office / Rent",
|
||||
"Utilities",
|
||||
"Business Insurance",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------- Data model ----------
|
||||
|
||||
@dataclass
|
||||
class LineItem:
|
||||
supplier: str
|
||||
description: str
|
||||
category_hint: str
|
||||
annual_spend: float
|
||||
frequency: str = "annual"
|
||||
currency: str = "USD"
|
||||
prior_year_spend: float | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "LineItem":
|
||||
return cls(
|
||||
supplier=str(d.get("supplier", "")).strip(),
|
||||
description=str(d.get("description", "")).strip(),
|
||||
category_hint=str(d.get("category_hint", "")).strip(),
|
||||
annual_spend=float(d.get("annual_spend", 0.0)),
|
||||
frequency=str(d.get("frequency", "annual")),
|
||||
currency=str(d.get("currency", "USD")),
|
||||
prior_year_spend=(
|
||||
float(d["prior_year_spend"])
|
||||
if d.get("prior_year_spend") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Categorized:
|
||||
item: LineItem
|
||||
segment: str
|
||||
family: str
|
||||
class_: str
|
||||
|
||||
|
||||
# ---------- Categorization ----------
|
||||
|
||||
def categorize(item: LineItem, profile: str) -> Categorized:
|
||||
"""Categorize one line item using keyword match + profile priority for ties."""
|
||||
haystack = " ".join([item.supplier, item.description, item.category_hint]).lower()
|
||||
|
||||
matches: list[tuple[str, str, str]] = []
|
||||
for keywords, cat in CATEGORY_MAP:
|
||||
for kw in keywords:
|
||||
if kw in haystack:
|
||||
matches.append(cat)
|
||||
break
|
||||
|
||||
if not matches:
|
||||
seg, fam, cls = UNCATEGORIZED
|
||||
return Categorized(item, seg, fam, cls)
|
||||
|
||||
# Resolve tie using profile priority list
|
||||
priority = PROFILE_PRIORITIES.get(profile, [])
|
||||
for pri_class in priority:
|
||||
for seg, fam, cls in matches:
|
||||
if cls == pri_class:
|
||||
return Categorized(item, seg, fam, cls)
|
||||
|
||||
# No priority match → first match wins (deterministic order)
|
||||
seg, fam, cls = matches[0]
|
||||
return Categorized(item, seg, fam, cls)
|
||||
|
||||
|
||||
# ---------- Aggregation ----------
|
||||
|
||||
def aggregate_by_class(items: list[Categorized]) -> dict[str, dict[str, Any]]:
|
||||
agg: dict[str, dict[str, Any]] = {}
|
||||
for c in items:
|
||||
bucket = agg.setdefault(c.class_, {
|
||||
"segment": c.segment,
|
||||
"family": c.family,
|
||||
"class": c.class_,
|
||||
"spend": 0.0,
|
||||
"prior_year_spend": 0.0,
|
||||
"supplier_count": 0,
|
||||
"suppliers": set(),
|
||||
})
|
||||
bucket["spend"] += c.item.annual_spend
|
||||
if c.item.prior_year_spend is not None:
|
||||
bucket["prior_year_spend"] += c.item.prior_year_spend
|
||||
bucket["suppliers"].add(c.item.supplier)
|
||||
for b in agg.values():
|
||||
b["supplier_count"] = len(b["suppliers"])
|
||||
b["suppliers"] = sorted(b["suppliers"])
|
||||
return agg
|
||||
|
||||
|
||||
def pareto_breakdown(agg: dict[str, dict[str, Any]]) -> tuple[list[str], float, float]:
|
||||
"""Return the 20% of categories driving most spend, and the cumulative % they cover."""
|
||||
sorted_cats = sorted(agg.items(), key=lambda kv: -kv[1]["spend"])
|
||||
total_spend = sum(b["spend"] for b in agg.values()) or 1.0
|
||||
top_20_count = max(1, len(sorted_cats) // 5)
|
||||
top_classes = [cls for cls, _ in sorted_cats[:top_20_count]]
|
||||
top_spend = sum(agg[cls]["spend"] for cls in top_classes)
|
||||
return top_classes, top_spend, top_spend / total_spend * 100.0
|
||||
|
||||
|
||||
def yoy_growth(agg: dict[str, dict[str, Any]]) -> list[tuple[str, float, float, float]]:
|
||||
"""Return (class, this_year, prior_year, pct_growth) sorted by % growth desc."""
|
||||
rows: list[tuple[str, float, float, float]] = []
|
||||
for cls, b in agg.items():
|
||||
py = b["prior_year_spend"]
|
||||
ty = b["spend"]
|
||||
if py > 0:
|
||||
pct = (ty - py) / py * 100.0
|
||||
rows.append((cls, ty, py, pct))
|
||||
rows.sort(key=lambda r: -r[3])
|
||||
return rows
|
||||
|
||||
|
||||
# ---------- Rendering ----------
|
||||
|
||||
def render_markdown(
|
||||
profile: str,
|
||||
categorized: list[Categorized],
|
||||
agg: dict[str, dict[str, Any]],
|
||||
) -> str:
|
||||
total = sum(b["spend"] for b in agg.values())
|
||||
top_classes, top_spend, top_pct = pareto_breakdown(agg)
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append(f"# Categorized Spend Report ({profile} profile)\n")
|
||||
lines.append(f"- **Total annual spend:** ${total:,.0f}")
|
||||
lines.append(f"- **Line items:** {len(categorized)}")
|
||||
lines.append(f"- **Distinct categories (Class level):** {len(agg)}\n")
|
||||
|
||||
lines.append("## Pareto: top 20% of categories\n")
|
||||
lines.append(f"Top {len(top_classes)} categories drive ${top_spend:,.0f} ({top_pct:.1f}% of spend):\n")
|
||||
for cls in top_classes:
|
||||
b = agg[cls]
|
||||
share = b["spend"] / (total or 1) * 100
|
||||
lines.append(f"- **{cls}** — ${b['spend']:,.0f} ({share:.1f}%), {b['supplier_count']} suppliers")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## All categories ranked by spend\n")
|
||||
lines.append("| Class | Family | Segment | Spend | Suppliers |")
|
||||
lines.append("|---|---|---|---:|---:|")
|
||||
for cls, b in sorted(agg.items(), key=lambda kv: -kv[1]["spend"]):
|
||||
lines.append(
|
||||
f"| {cls} | {b['family']} | {b['segment']} | "
|
||||
f"${b['spend']:,.0f} | {b['supplier_count']} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
growth = yoy_growth(agg)
|
||||
if growth:
|
||||
lines.append("## Top YoY growth categories\n")
|
||||
lines.append("| Class | This year | Prior year | Growth |")
|
||||
lines.append("|---|---:|---:|---:|")
|
||||
for cls, ty, py, pct in growth[:10]:
|
||||
arrow = "↑" if pct > 0 else "↓"
|
||||
lines.append(f"| {cls} | ${ty:,.0f} | ${py:,.0f} | {arrow} {pct:+.1f}% |")
|
||||
lines.append("")
|
||||
|
||||
# Per-line-item listing (for audit)
|
||||
lines.append("## Line items by category\n")
|
||||
by_class: dict[str, list[Categorized]] = {}
|
||||
for c in categorized:
|
||||
by_class.setdefault(c.class_, []).append(c)
|
||||
for cls in sorted(by_class.keys()):
|
||||
lines.append(f"### {cls}\n")
|
||||
lines.append("| Supplier | Description | Annual spend |")
|
||||
lines.append("|---|---|---:|")
|
||||
for c in sorted(by_class[cls], key=lambda x: -x.item.annual_spend):
|
||||
desc = c.item.description[:60]
|
||||
lines.append(f"| {c.item.supplier} | {desc} | ${c.item.annual_spend:,.0f} |")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------- Sample data ----------
|
||||
|
||||
SAMPLE_INPUT: list[dict[str, Any]] = [
|
||||
{"supplier": "Datadog", "description": "Monitoring + APM", "category_hint": "monitoring",
|
||||
"annual_spend": 180000, "prior_year_spend": 120000},
|
||||
{"supplier": "New Relic", "description": "APM monitoring", "category_hint": "monitoring",
|
||||
"annual_spend": 90000, "prior_year_spend": 80000},
|
||||
{"supplier": "Grafana Cloud", "description": "metrics + logs monitoring",
|
||||
"category_hint": "monitoring", "annual_spend": 45000, "prior_year_spend": 0},
|
||||
{"supplier": "Ramp", "description": "corporate cards + expense", "category_hint": "expense",
|
||||
"annual_spend": 30000, "prior_year_spend": 18000},
|
||||
{"supplier": "Expensify", "description": "expense reimbursement", "category_hint": "expense",
|
||||
"annual_spend": 12000, "prior_year_spend": 12000},
|
||||
{"supplier": "Salesforce", "description": "CRM Enterprise", "category_hint": "crm",
|
||||
"annual_spend": 240000, "prior_year_spend": 200000},
|
||||
{"supplier": "AWS", "description": "EC2 + S3 + RDS", "category_hint": "cloud",
|
||||
"annual_spend": 720000, "prior_year_spend": 480000},
|
||||
{"supplier": "Snowflake", "description": "data warehouse", "category_hint": "data warehouse",
|
||||
"annual_spend": 360000, "prior_year_spend": 240000},
|
||||
{"supplier": "Klaviyo", "description": "email marketing platform",
|
||||
"category_hint": "email marketing", "annual_spend": 36000, "prior_year_spend": 24000},
|
||||
{"supplier": "Mailchimp", "description": "email marketing", "category_hint": "email marketing",
|
||||
"annual_spend": 8000, "prior_year_spend": 8000},
|
||||
{"supplier": "Iterable", "description": "email marketing", "category_hint": "email marketing",
|
||||
"annual_spend": 50000, "prior_year_spend": 0},
|
||||
{"supplier": "SendGrid", "description": "transactional email", "category_hint": "email",
|
||||
"annual_spend": 18000, "prior_year_spend": 12000},
|
||||
{"supplier": "Greenhouse", "description": "ATS", "category_hint": "applicant tracking",
|
||||
"annual_spend": 28000, "prior_year_spend": 24000},
|
||||
{"supplier": "Outside Counsel - Fenwick", "description": "legal services",
|
||||
"category_hint": "legal", "annual_spend": 95000, "prior_year_spend": 60000},
|
||||
]
|
||||
|
||||
|
||||
# ---------- CLI ----------
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--input", type=str, help="Path to JSON list of spend line items")
|
||||
p.add_argument(
|
||||
"--profile",
|
||||
type=str,
|
||||
default="tech-startup",
|
||||
choices=sorted(PROFILE_PRIORITIES.keys()),
|
||||
help="Industry profile (default: tech-startup)",
|
||||
)
|
||||
p.add_argument("--output", type=str, help="Path to write markdown report")
|
||||
p.add_argument("--sample", action="store_true", help="Run with built-in sample data")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.sample:
|
||||
data = SAMPLE_INPUT
|
||||
elif args.input:
|
||||
try:
|
||||
data = json.loads(Path(args.input).read_text())
|
||||
except Exception as e:
|
||||
print(f"error reading {args.input}: {e}", file=sys.stderr)
|
||||
return 2
|
||||
else:
|
||||
p.print_help()
|
||||
return 0
|
||||
|
||||
items = [LineItem.from_dict(d) for d in data]
|
||||
categorized = [categorize(it, args.profile) for it in items]
|
||||
agg = aggregate_by_class(categorized)
|
||||
md = render_markdown(args.profile, categorized, agg)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(md)
|
||||
print(f"wrote {args.output}")
|
||||
else:
|
||||
print(md)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,399 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
supplier_consolidation.py — Duplicate-function clustering + risk-flagged consolidation plan.
|
||||
|
||||
Input: list of suppliers with category, annual spend, criticality tier, contract term,
|
||||
integration count with other systems, switching cost estimate, renewal date, and an
|
||||
optional break-glass flag for tier-1 suppliers.
|
||||
|
||||
Output: markdown consolidation plan that:
|
||||
- Clusters suppliers by category (duplicate-function detection)
|
||||
- Recommends a consolidation winner per cluster
|
||||
- REFUSES to recommend single-source consolidation for tier-1 categories without
|
||||
a documented break-glass plan
|
||||
- Estimates savings: current cluster spend - winner spend - migration cost
|
||||
- Surfaces renewal-date clusters (≥3 contracts in same calendar month destroys leverage)
|
||||
|
||||
Stdlib only. Deterministic. No LLM calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ---------- Profile-driven category criticality overrides ----------
|
||||
|
||||
PROFILE_TIER1_CATEGORIES: dict[str, list[str]] = {
|
||||
"tech-startup": ["Cloud Infrastructure", "Data Warehouse", "Security Tooling", "CRM Platform"],
|
||||
"scaleup": ["Cloud Infrastructure", "CRM Platform", "HRIS / Payroll", "Data Warehouse"],
|
||||
"enterprise": ["Cloud Infrastructure", "HRIS / Payroll", "Business Insurance", "Outside Counsel"],
|
||||
"services": ["CRM Platform", "Outside Counsel", "Contractor / Freelance"],
|
||||
"manufacturing": ["Endpoint Devices", "Business Insurance", "Utilities"],
|
||||
}
|
||||
|
||||
|
||||
# ---------- Data model ----------
|
||||
|
||||
@dataclass
|
||||
class Supplier:
|
||||
name: str
|
||||
category: str
|
||||
annual_spend: float
|
||||
criticality: str # tier-1 | tier-2 | tier-3
|
||||
contract_term_months: int
|
||||
integration_count_with_other_systems: int
|
||||
switching_cost_estimate: float
|
||||
renewal_date: date | None
|
||||
break_glass_documented: bool = False
|
||||
|
||||
@staticmethod
|
||||
def _parse_date(d: Any) -> date | None:
|
||||
if not d:
|
||||
return None
|
||||
if isinstance(d, date):
|
||||
return d
|
||||
try:
|
||||
return datetime.strptime(str(d)[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "Supplier":
|
||||
return cls(
|
||||
name=str(d.get("name", "")),
|
||||
category=str(d.get("category", "Uncategorized")),
|
||||
annual_spend=float(d.get("annual_spend", 0.0)),
|
||||
criticality=str(d.get("criticality", "tier-3")).lower(),
|
||||
contract_term_months=int(d.get("contract_term_months", 12)),
|
||||
integration_count_with_other_systems=int(
|
||||
d.get("integration_count_with_other_systems", 0)
|
||||
),
|
||||
switching_cost_estimate=float(d.get("switching_cost_estimate", 0.0)),
|
||||
renewal_date=cls._parse_date(d.get("renewal_date")),
|
||||
break_glass_documented=bool(d.get("break_glass_documented", False)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClusterRecommendation:
|
||||
category: str
|
||||
members: list[Supplier]
|
||||
winner: Supplier | None
|
||||
losers: list[Supplier]
|
||||
annual_savings: float
|
||||
migration_cost: float
|
||||
net_year1_savings: float
|
||||
risk_flag: str # OK | TIER1_NO_BREAKGLASS | LOW_SAVINGS
|
||||
rationale: str
|
||||
|
||||
|
||||
# ---------- Clustering ----------
|
||||
|
||||
def cluster_by_category(suppliers: list[Supplier]) -> dict[str, list[Supplier]]:
|
||||
"""Group suppliers by category; only categories with >= 2 suppliers are candidate clusters."""
|
||||
by_cat: dict[str, list[Supplier]] = {}
|
||||
for s in suppliers:
|
||||
by_cat.setdefault(s.category, []).append(s)
|
||||
return {cat: members for cat, members in by_cat.items() if len(members) >= 2}
|
||||
|
||||
|
||||
# ---------- Winner selection ----------
|
||||
|
||||
def pick_winner(members: list[Supplier]) -> Supplier:
|
||||
"""
|
||||
Winner selection:
|
||||
- If any member is tier-1, the highest-spend tier-1 wins (assume it has the most integrations and least switching cost away from).
|
||||
- If cluster is tier-2/3 only, the member with lowest total cost = (annual_spend - other_members_spend) + their switching_cost_estimate.
|
||||
Simpler proxy: highest integration_count_with_other_systems wins (the one that's most embedded).
|
||||
Tiebreak by lowest switching_cost_estimate.
|
||||
"""
|
||||
tier1 = [m for m in members if m.criticality == "tier-1"]
|
||||
if tier1:
|
||||
return max(tier1, key=lambda m: m.annual_spend)
|
||||
|
||||
return max(
|
||||
members,
|
||||
key=lambda m: (m.integration_count_with_other_systems, -m.switching_cost_estimate),
|
||||
)
|
||||
|
||||
|
||||
# ---------- Risk assessment ----------
|
||||
|
||||
def assess_risk(
|
||||
category: str,
|
||||
members: list[Supplier],
|
||||
winner: Supplier,
|
||||
profile: str,
|
||||
annual_savings: float,
|
||||
) -> str:
|
||||
"""
|
||||
Risk classification:
|
||||
- TIER1_NO_BREAKGLASS — any tier-1 in cluster (or category is tier-1 by profile) AND winner has no break_glass_documented
|
||||
- LOW_SAVINGS — net Y1 savings < $10k (consolidating not worth the operational disruption)
|
||||
- OK — proceed
|
||||
"""
|
||||
profile_tier1 = PROFILE_TIER1_CATEGORIES.get(profile, [])
|
||||
has_tier1_member = any(m.criticality == "tier-1" for m in members)
|
||||
is_tier1_category = category in profile_tier1
|
||||
|
||||
if (has_tier1_member or is_tier1_category) and not winner.break_glass_documented:
|
||||
return "TIER1_NO_BREAKGLASS"
|
||||
|
||||
if annual_savings < 10000:
|
||||
return "LOW_SAVINGS"
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
# ---------- Plan generation ----------
|
||||
|
||||
def build_recommendations(
|
||||
suppliers: list[Supplier],
|
||||
profile: str,
|
||||
) -> list[ClusterRecommendation]:
|
||||
clusters = cluster_by_category(suppliers)
|
||||
recs: list[ClusterRecommendation] = []
|
||||
|
||||
for cat, members in clusters.items():
|
||||
winner = pick_winner(members)
|
||||
losers = [m for m in members if m.name != winner.name]
|
||||
if not losers:
|
||||
continue
|
||||
|
||||
annual_savings = sum(m.annual_spend for m in losers)
|
||||
migration_cost = sum(m.switching_cost_estimate for m in losers)
|
||||
net_y1 = annual_savings - migration_cost
|
||||
risk = assess_risk(cat, members, winner, profile, annual_savings)
|
||||
|
||||
if risk == "TIER1_NO_BREAKGLASS":
|
||||
rationale = (
|
||||
"DO NOT CONSOLIDATE — tier-1 category, no documented break-glass plan. "
|
||||
"Add a 72-hour contingency plan for the surviving supplier first, then revisit."
|
||||
)
|
||||
elif risk == "LOW_SAVINGS":
|
||||
rationale = (
|
||||
f"Marginal — net Y1 savings ${net_y1:,.0f} likely consumed by operational disruption. "
|
||||
"Defer unless integration debt or vendor risk justifies it."
|
||||
)
|
||||
else:
|
||||
rationale = (
|
||||
f"Consolidate to {winner.name}. {len(losers)} supplier(s) to offboard. "
|
||||
f"Net Y1 savings ${net_y1:,.0f} (gross ${annual_savings:,.0f} − migration ${migration_cost:,.0f})."
|
||||
)
|
||||
|
||||
recs.append(ClusterRecommendation(
|
||||
category=cat,
|
||||
members=members,
|
||||
winner=winner,
|
||||
losers=losers,
|
||||
annual_savings=annual_savings,
|
||||
migration_cost=migration_cost,
|
||||
net_year1_savings=net_y1,
|
||||
risk_flag=risk,
|
||||
rationale=rationale,
|
||||
))
|
||||
|
||||
# Sort by net savings descending (biggest opportunities first)
|
||||
recs.sort(key=lambda r: -r.net_year1_savings)
|
||||
return recs
|
||||
|
||||
|
||||
# ---------- Renewal cluster analysis ----------
|
||||
|
||||
def renewal_clusters(suppliers: list[Supplier]) -> dict[str, list[Supplier]]:
|
||||
"""Find calendar months where >=3 contracts renew (zero leverage)."""
|
||||
by_month: dict[str, list[Supplier]] = {}
|
||||
for s in suppliers:
|
||||
if s.renewal_date is None:
|
||||
continue
|
||||
key = s.renewal_date.strftime("%Y-%m")
|
||||
by_month.setdefault(key, []).append(s)
|
||||
return {month: members for month, members in by_month.items() if len(members) >= 3}
|
||||
|
||||
|
||||
# ---------- Rendering ----------
|
||||
|
||||
def render_markdown(
|
||||
profile: str,
|
||||
suppliers: list[Supplier],
|
||||
recs: list[ClusterRecommendation],
|
||||
renewals: dict[str, list[Supplier]],
|
||||
) -> str:
|
||||
total_spend = sum(s.annual_spend for s in suppliers)
|
||||
total_net_savings = sum(
|
||||
r.net_year1_savings for r in recs if r.risk_flag == "OK"
|
||||
)
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append(f"# Supplier Consolidation Plan ({profile} profile)\n")
|
||||
lines.append(f"- **Suppliers analyzed:** {len(suppliers)}")
|
||||
lines.append(f"- **Total annual spend:** ${total_spend:,.0f}")
|
||||
lines.append(f"- **Duplicate-function clusters:** {len(recs)}")
|
||||
lines.append(f"- **Net Year-1 savings opportunity (OK clusters only):** ${total_net_savings:,.0f}\n")
|
||||
|
||||
if not recs:
|
||||
lines.append("No duplicate-function clusters detected. No consolidation plan generated.\n")
|
||||
else:
|
||||
lines.append("## Recommendations (ranked by net Y1 savings)\n")
|
||||
for r in recs:
|
||||
badge = {
|
||||
"OK": "RECOMMEND",
|
||||
"TIER1_NO_BREAKGLASS": "DO NOT CONSOLIDATE",
|
||||
"LOW_SAVINGS": "DEFER",
|
||||
}[r.risk_flag]
|
||||
lines.append(f"### {r.category} — {badge}\n")
|
||||
lines.append(f"**Cluster:** {len(r.members)} suppliers — " +
|
||||
", ".join(f"{m.name} (${m.annual_spend:,.0f}, {m.criticality})" for m in r.members))
|
||||
if r.winner is not None:
|
||||
lines.append(f"\n**Proposed winner:** {r.winner.name} "
|
||||
f"(integrations={r.winner.integration_count_with_other_systems}, "
|
||||
f"break-glass={'yes' if r.winner.break_glass_documented else 'no'})")
|
||||
lines.append(f"\n**Offboard:** " + (", ".join(m.name for m in r.losers) or "—"))
|
||||
lines.append(f"\n- Gross annual savings: ${r.annual_savings:,.0f}")
|
||||
lines.append(f"- Migration cost: ${r.migration_cost:,.0f}")
|
||||
lines.append(f"- **Net Y1 savings: ${r.net_year1_savings:,.0f}**")
|
||||
lines.append(f"- Risk flag: `{r.risk_flag}`")
|
||||
lines.append(f"\n{r.rationale}\n")
|
||||
|
||||
# Renewal-date clustering analysis
|
||||
lines.append("## Renewal-date clusters (negotiation leverage)\n")
|
||||
if not renewals:
|
||||
lines.append("No calendar months with ≥ 3 simultaneous renewals. Leverage is preserved.\n")
|
||||
else:
|
||||
lines.append(f"**{len(renewals)} month(s) have ≥ 3 simultaneous renewals — leverage destroyed:**\n")
|
||||
for month, members in sorted(renewals.items()):
|
||||
lines.append(f"### {month} — {len(members)} renewals\n")
|
||||
for m in members:
|
||||
lines.append(
|
||||
f"- {m.name} ({m.category}) — ${m.annual_spend:,.0f}, renewal {m.renewal_date}"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Action: stagger renewals across the year. Renegotiate term lengths at next renewal "
|
||||
"(e.g., 18-month + 6-month + 12-month) to permanently de-cluster.\n"
|
||||
)
|
||||
|
||||
# Action summary
|
||||
lines.append("## Action summary\n")
|
||||
ok_recs = [r for r in recs if r.risk_flag == "OK"]
|
||||
tier1_blocked = [r for r in recs if r.risk_flag == "TIER1_NO_BREAKGLASS"]
|
||||
deferred = [r for r in recs if r.risk_flag == "LOW_SAVINGS"]
|
||||
lines.append(f"- **Proceed now ({len(ok_recs)}):** " +
|
||||
(", ".join(r.category for r in ok_recs) or "—"))
|
||||
lines.append(f"- **Blocked on break-glass plan ({len(tier1_blocked)}):** " +
|
||||
(", ".join(r.category for r in tier1_blocked) or "—"))
|
||||
lines.append(f"- **Defer ({len(deferred)}):** " +
|
||||
(", ".join(r.category for r in deferred) or "—"))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------- Sample data ----------
|
||||
|
||||
SAMPLE_INPUT: list[dict[str, Any]] = [
|
||||
# Monitoring cluster (3 tools, tier-2)
|
||||
{"name": "Datadog", "category": "Monitoring / Observability", "annual_spend": 180000,
|
||||
"criticality": "tier-2", "contract_term_months": 12,
|
||||
"integration_count_with_other_systems": 12,
|
||||
"switching_cost_estimate": 80000, "renewal_date": "2026-09-15",
|
||||
"break_glass_documented": False},
|
||||
{"name": "New Relic", "category": "Monitoring / Observability", "annual_spend": 90000,
|
||||
"criticality": "tier-2", "contract_term_months": 12,
|
||||
"integration_count_with_other_systems": 4,
|
||||
"switching_cost_estimate": 25000, "renewal_date": "2026-09-30",
|
||||
"break_glass_documented": False},
|
||||
{"name": "Grafana Cloud", "category": "Monitoring / Observability", "annual_spend": 45000,
|
||||
"criticality": "tier-2", "contract_term_months": 12,
|
||||
"integration_count_with_other_systems": 6,
|
||||
"switching_cost_estimate": 18000, "renewal_date": "2026-09-22",
|
||||
"break_glass_documented": False},
|
||||
# Expense cluster (2 tools, tier-3)
|
||||
{"name": "Ramp", "category": "Expense / Spend Management", "annual_spend": 30000,
|
||||
"criticality": "tier-3", "contract_term_months": 12,
|
||||
"integration_count_with_other_systems": 8,
|
||||
"switching_cost_estimate": 15000, "renewal_date": "2026-09-10",
|
||||
"break_glass_documented": True},
|
||||
{"name": "Expensify", "category": "Expense / Spend Management", "annual_spend": 12000,
|
||||
"criticality": "tier-3", "contract_term_months": 12,
|
||||
"integration_count_with_other_systems": 2,
|
||||
"switching_cost_estimate": 5000, "renewal_date": "2026-09-05",
|
||||
"break_glass_documented": True},
|
||||
# Email marketing cluster (4 tools, tier-2 — note the tier-1 will trigger guard)
|
||||
{"name": "Klaviyo", "category": "Email Marketing Platform", "annual_spend": 36000,
|
||||
"criticality": "tier-1", "contract_term_months": 12,
|
||||
"integration_count_with_other_systems": 10,
|
||||
"switching_cost_estimate": 25000, "renewal_date": "2026-11-15",
|
||||
"break_glass_documented": False},
|
||||
{"name": "Mailchimp", "category": "Email Marketing Platform", "annual_spend": 8000,
|
||||
"criticality": "tier-3", "contract_term_months": 12,
|
||||
"integration_count_with_other_systems": 1,
|
||||
"switching_cost_estimate": 2000, "renewal_date": "2026-03-15",
|
||||
"break_glass_documented": False},
|
||||
{"name": "Iterable", "category": "Email Marketing Platform", "annual_spend": 50000,
|
||||
"criticality": "tier-2", "contract_term_months": 12,
|
||||
"integration_count_with_other_systems": 5,
|
||||
"switching_cost_estimate": 15000, "renewal_date": "2026-04-30",
|
||||
"break_glass_documented": False},
|
||||
{"name": "SendGrid", "category": "Email Marketing Platform", "annual_spend": 18000,
|
||||
"criticality": "tier-2", "contract_term_months": 12,
|
||||
"integration_count_with_other_systems": 4,
|
||||
"switching_cost_estimate": 8000, "renewal_date": "2026-06-30",
|
||||
"break_glass_documented": False},
|
||||
# AWS — single supplier, tier-1, not a cluster
|
||||
{"name": "AWS", "category": "Cloud Infrastructure", "annual_spend": 720000,
|
||||
"criticality": "tier-1", "contract_term_months": 36,
|
||||
"integration_count_with_other_systems": 40,
|
||||
"switching_cost_estimate": 600000, "renewal_date": "2027-03-31",
|
||||
"break_glass_documented": True},
|
||||
]
|
||||
|
||||
|
||||
# ---------- CLI ----------
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--input", type=str, help="Path to JSON list of supplier records")
|
||||
p.add_argument(
|
||||
"--profile",
|
||||
type=str,
|
||||
default="tech-startup",
|
||||
choices=sorted(PROFILE_TIER1_CATEGORIES.keys()),
|
||||
help="Industry profile (default: tech-startup)",
|
||||
)
|
||||
p.add_argument("--output", type=str, help="Path to write markdown plan")
|
||||
p.add_argument("--sample", action="store_true", help="Run with built-in sample data")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.sample:
|
||||
data = SAMPLE_INPUT
|
||||
elif args.input:
|
||||
try:
|
||||
data = json.loads(Path(args.input).read_text())
|
||||
except Exception as e:
|
||||
print(f"error reading {args.input}: {e}", file=sys.stderr)
|
||||
return 2
|
||||
else:
|
||||
p.print_help()
|
||||
return 0
|
||||
|
||||
suppliers = [Supplier.from_dict(d) for d in data]
|
||||
recs = build_recommendations(suppliers, args.profile)
|
||||
renewals = renewal_clusters(suppliers)
|
||||
md = render_markdown(args.profile, suppliers, recs, renewals)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(md)
|
||||
print(f"wrote {args.output}")
|
||||
else:
|
||||
print(md)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,177 @@
|
||||
---
|
||||
name: vendor-management
|
||||
description: Use when reviewing, scoring, or auditing third-party SaaS / vendor relationships — running a vendor scorecard with industry tuning, tracking SLA compliance with credit-claim flags, classifying third-party risk across 4 risk vectors, preparing a tier-1 vendor review, or auditing the SaaS portfolio. Forks context so large vendor catalogs (50-500 line items) and SLA logs don't pollute the parent thread. Triggers on "vendor SLA", "vendor scorecard", "third-party risk", "TPRM", "vendor review", "supplier performance", "vendor health check", "renewal review".
|
||||
context: fork
|
||||
version: 2.8.0
|
||||
author: claude-code-skills
|
||||
license: MIT
|
||||
tags: [bizops, vendor, sla, third-party-risk, vendor-management, saas-management, tprm]
|
||||
compatible_tools: [claude-code, codex-cli, cursor, antigravity, opencode, gemini-cli]
|
||||
---
|
||||
|
||||
# Vendor Management — Operational Third-Party Performance
|
||||
|
||||
You are a BizOps / IT / Vendor Management Office (VMO) operator. Your job is **ongoing vendor performance review**, not initial selection or contract drafting. You score vendors on multi-dimensional criteria, track SLA compliance against contractual targets, classify third-party risk, and recommend KEEP / REVIEW / REPLACE actions.
|
||||
|
||||
## Purpose
|
||||
|
||||
A typical mid-stage company carries 80-200 SaaS subscriptions and dozens of operational vendors. Most of them are reviewed only at renewal — which is too late. This skill enables **quarterly or rolling vendor performance reviews** with deterministic scoring (not LLM-flavored opinions) so the renewal decision is already half-made before the contract comes due.
|
||||
|
||||
## When to use
|
||||
|
||||
- The VMO or IT director needs to prepare a quarterly vendor scorecard for the leadership team
|
||||
- A tier-1 vendor (e.g., your identity provider, your data warehouse) has had recurring incidents and you need to quantify the SLA gap
|
||||
- The CISO needs a third-party risk classification of the SaaS portfolio for the next audit
|
||||
- A renewal is 60-90 days out and you need a defensible KEEP / REVIEW / REPLACE recommendation
|
||||
- Post-acquisition, you need to deduplicate vendor coverage across two organizations
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- Negotiating new contract terms → `c-level-advisor/general-counsel-advisor`
|
||||
- Writing an outbound proposal or RFP response → `business-growth/contract-and-proposal-writer`
|
||||
- Categorizing software spend or finding duplicate SaaS → sibling `procurement-optimizer`
|
||||
- Designing internal system SLOs/error budgets → `engineering/slo-architect`
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Intake the vendor catalog
|
||||
|
||||
The user provides a JSON catalog (see `assets/vendor_catalog_template.md` for the schema and a 5-vendor sample). Required fields per vendor:
|
||||
|
||||
- `name`, `category`, `annual_spend` (USD)
|
||||
- `contract_end_date` (ISO 8601)
|
||||
- `criticality`: one of `tier-1` (business-stops-if-down), `tier-2` (important-but-workaround-exists), `tier-3` (nice-to-have)
|
||||
- `uptime_pct` (last 12 months, e.g., 99.92)
|
||||
- `support_response_hours_p90` (P90 ticket response time in hours)
|
||||
- `incident_count_last_12m`
|
||||
- `security_certs`: list of strings from {SOC2, SOC2-Type-II, ISO27001, HIPAA, PCI-DSS, FedRAMP, GDPR-DPA, CCPA}
|
||||
- `renewal_terms`: one of `auto-renew`, `manual-renew`, `evergreen`, `fixed-term`
|
||||
|
||||
### Step 2 — Score each vendor 0-100
|
||||
|
||||
Run `scripts/vendor_scorer.py --input catalog.json --profile <industry> --output scorecard.md`.
|
||||
|
||||
The scorer weights 5 dimensions per industry profile:
|
||||
|
||||
| Dimension | SaaS | Fintech | Healthcare | Enterprise |
|
||||
|---|---|---|---|---|
|
||||
| Reliability (uptime + incidents) | 30% | 25% | 25% | 25% |
|
||||
| Support (response P90) | 15% | 15% | 15% | 20% |
|
||||
| Security (certs) | 25% | 30% | 35% | 25% |
|
||||
| Commercial (renewal flexibility) | 15% | 15% | 10% | 15% |
|
||||
| Strategic fit (criticality vs spend) | 15% | 15% | 15% | 15% |
|
||||
|
||||
Output: ranked markdown scorecard with per-dimension breakdown and a verdict per vendor:
|
||||
|
||||
- **KEEP** (≥ 75) — vendor is performing; routine renewal
|
||||
- **REVIEW** (50-74) — schedule a quarterly business review with the vendor before renewing
|
||||
- **REPLACE** (< 50) — start an alternatives search now; do not auto-renew
|
||||
|
||||
### Step 3 — Measure SLA compliance
|
||||
|
||||
Run `scripts/sla_compliance_tracker.py --input sla_records.json --output sla_report.md`.
|
||||
|
||||
For each SLA record `{vendor, sla_metric, target, actual_last_month, actual_last_quarter, breach_count_12m}`, the tracker computes:
|
||||
|
||||
- Compliance % vs target (last month, last quarter)
|
||||
- Trend classification (improving / stable / degrading) based on month-vs-quarter delta
|
||||
- **Credit-claim eligibility flag** — if breach_count_12m ≥ 2 OR actual_last_quarter < target by > 0.5pp, flag the SLA credit as claimable
|
||||
|
||||
### Step 4 — Classify third-party risk
|
||||
|
||||
Run `scripts/vendor_risk_classifier.py --input catalog.json --profile <industry> --output risk_matrix.md`.
|
||||
|
||||
Classifies each vendor as **Critical / High / Medium / Low** across 4 risk vectors (Shared Assessments SIG-Lite-ish):
|
||||
|
||||
1. **Data sensitivity** — PII / PHI / cardholder / source code access
|
||||
2. **Financial exposure** — annual spend × tier multiplier
|
||||
3. **Operational dependency** — tier-1 + no break-glass = Critical
|
||||
4. **Regulatory exposure** — industry profile drives weighting (e.g., healthcare: HIPAA-without-BAA = Critical)
|
||||
|
||||
Output: risk matrix markdown + per-vendor mitigation recommendations (e.g., "Tier-1 with no SOC2 → require SOC2 attestation before next renewal").
|
||||
|
||||
### Step 5 — Synthesize recommendations
|
||||
|
||||
Combine the 3 artifacts into a final BizOps / VMO digest:
|
||||
|
||||
- Top 3 KEEP wins (vendors over-performing — consider deepening)
|
||||
- Top 3 REVIEW conversations (schedule QBR with vendor)
|
||||
- Top 3 REPLACE candidates (start alternatives search now)
|
||||
- All SLA credits eligible to claim (with dollar estimate where possible)
|
||||
- All Critical-risk vendors with no current mitigation
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|---|---|
|
||||
| `scripts/vendor_scorer.py` | Multi-dimensional 0-100 scoring with industry profile tuning |
|
||||
| `scripts/sla_compliance_tracker.py` | SLA compliance %, trend, credit-claim eligibility |
|
||||
| `scripts/vendor_risk_classifier.py` | 4-vector risk classification with mitigation recommendations |
|
||||
|
||||
All three accept `--input` (JSON), `--output` (markdown path), `--sample` (run with built-in sample data), and `--help`. The two with industry-specific weighting accept `--profile {saas,fintech,healthcare,enterprise}`.
|
||||
|
||||
## Quick example
|
||||
|
||||
```bash
|
||||
# Emits a weighted vendor scorecard (industry-tuned dimensions + per-vendor verdict) for the built-in sample catalog
|
||||
cd business-operations/skills/vendor-management && python3 scripts/vendor_scorer.py --sample
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- `references/vendor_management_canon.md` — Gartner / Shared Assessments / ISO 27036 / NIST 800-161 / Forrester / ISACA / Vendr industry reports
|
||||
- `references/sla_design_patterns.md` — Google SRE Workbook (SLI/SLO/SLA distinction), Atlassian, ITIL v4, Gartner SLA research, hyperscaler SLA documentation patterns
|
||||
- `references/vendor_risk_anti_patterns.md` — Real breach post-mortems: SolarWinds, Target/HVAC, NotPetya/M.E.Doc, Capital One, Verkada, Okta 2022, log4j
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. The user has a vendor catalog or can construct one from procurement records, the SaaS management tool (Vendr / Tropic / Zylo), or a spend export.
|
||||
2. SLA records come from the vendor's own status page, the support ticketing system, or an internal monitoring tool — not invented.
|
||||
3. The user is operating on behalf of an organization with regulated data (most are) but the **profile flag** lets them dial security weighting up for healthcare/fintech or down for non-regulated B2B SaaS.
|
||||
4. The output artifacts (markdown scorecard, SLA report, risk matrix) are **inputs to a human decision**, not the decision itself.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Treat all vendors at the same tier.** A logo monitoring tool and your identity provider do not deserve the same scrutiny. Use the tier field.
|
||||
- **Annual review is enough.** Tier-1 vendors should be reviewed quarterly. Tier-2 semi-annually. Tier-3 at renewal.
|
||||
- **Trust the security questionnaire without verification.** Ask for the SOC2 report, not a SIG checkbox. See `references/vendor_risk_anti_patterns.md`.
|
||||
- **No break-glass plan for a tier-1 vendor.** If the vendor disappears tomorrow, what is the 72-hour plan?
|
||||
- **Forget offboarding.** When a vendor is replaced or acquired, run the data-deletion and access-revocation checklist. SolarWinds and Okta both demonstrate why.
|
||||
- **Score by gut feel.** Use the deterministic tools. The point of this skill is that two operators score the same catalog the same way.
|
||||
|
||||
## Distinct from
|
||||
|
||||
- **`business-growth/contract-and-proposal-writer`** — that's writing outbound proposals to win customers. This is scoring inbound vendors you already pay.
|
||||
- **`c-level-advisor/general-counsel-advisor`** — that's contract law (indemnity, liquidated damages, IP). This is operational performance against an existing contract.
|
||||
- **Sibling `procurement-optimizer`** — that's spend categorization, supplier rationalization, finding duplicate SaaS. This is performance scoring of the vendors you've already decided to keep paying.
|
||||
- **`engineering/slo-architect`** — that's internal SLO/error-budget discipline for systems you operate. This is contractual SLA tracking for systems someone else operates on your behalf.
|
||||
|
||||
## Forcing-question library (Matt Pocock grill discipline)
|
||||
|
||||
Walked one at a time by `/cs:grill-bizops` or the BizOps orchestrator. Recommended answer + canon citation per question. Never bundled.
|
||||
|
||||
1. **"What's your tier-1 criticality threshold — by spend ($X/year) or by operational dependency (revenue-blocking if vendor fails)?"**
|
||||
Recommended: operational dependency.
|
||||
Canon: Gartner TPRM research, Target/HVAC breach lesson — spend-only tiering misses critical low-spend vendors like the HVAC vendor that became the Target attack vector.
|
||||
|
||||
2. **"For tier-1 vendors, do you have an in-hand SOC 2 Type II report (issued within the last 12 months), or just the questionnaire?"**
|
||||
Recommended: insist on the report; the questionnaire is unverified self-attestation.
|
||||
Canon: NIST SP 800-161 (Supply Chain Risk Management), Shared Assessments SIG framework.
|
||||
|
||||
3. **"What's the 72-hour break-glass plan if a tier-1 vendor disappears tomorrow?"**
|
||||
Recommended: documented contingency per vendor, tested annually.
|
||||
Canon: NotPetya / M.E.Doc supply chain attack, log4j response patterns.
|
||||
|
||||
4. **"When was the last time the SLA was actually invoked (credit claim filed)?"**
|
||||
Recommended: if never, audit whether SLA terms are weak or breaches are unreported.
|
||||
Canon: Atlassian SLA best practices, ITIL v4 service level management.
|
||||
|
||||
5. **"Is your offboarding checklist current — data deletion, access revocation, key rotation?"**
|
||||
Recommended: rehearse it on one vendor per quarter.
|
||||
Canon: SolarWinds + Okta 2022 breach lessons.
|
||||
|
||||
6. **"What's the regulatory blast-radius — HIPAA / GDPR / SOX / PCI?"**
|
||||
Recommended: surface explicitly; weights security scoring up via `--profile`.
|
||||
Canon: ISO/IEC 27036 (supplier relationships security).
|
||||
|
||||
Walk depth-first. Lock 1-3 before opening 4-6. After all are answered, invoke `vendor_scorer.py` → `sla_compliance_tracker.py` → `vendor_risk_classifier.py` in sequence.
|
||||
@@ -0,0 +1,210 @@
|
||||
# Vendor Catalog Template
|
||||
|
||||
The vendor-management skill's three Python tools all read the same JSON shape (with some fields used by only one tool). This template gives you the schema, the 5-vendor sample, and quick-start instructions.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Copy the JSON below to `vendor_catalog.json` in your working directory.
|
||||
2. Replace the sample vendors with your real catalog.
|
||||
3. Run the three tools:
|
||||
|
||||
```bash
|
||||
python scripts/vendor_scorer.py --input vendor_catalog.json --profile saas --output scorecard.md
|
||||
python scripts/sla_compliance_tracker.py --input sla_records.json --output sla_report.md
|
||||
python scripts/vendor_risk_classifier.py --input vendor_catalog.json --profile saas --output risk_matrix.md
|
||||
```
|
||||
|
||||
(SLA records are a separate file — see the SLA shape below.)
|
||||
|
||||
## Vendor catalog JSON schema
|
||||
|
||||
Each vendor in the catalog is one object in a top-level JSON array.
|
||||
|
||||
| Field | Type | Used by | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | string | all 3 | Display name |
|
||||
| `category` | string | scorer, classifier | e.g., `identity`, `data-warehouse`, `crm`, `analytics` |
|
||||
| `annual_spend` | number (USD) | scorer, classifier | Annualized total cost |
|
||||
| `contract_end_date` | ISO 8601 string | (informational) | Useful for downstream sorting |
|
||||
| `criticality` | enum | scorer, classifier | `tier-1` / `tier-2` / `tier-3` |
|
||||
| `uptime_pct` | number (0-100) | scorer | Last 12 months |
|
||||
| `support_response_hours_p90` | number | scorer | P90 first-response, hours |
|
||||
| `incident_count_last_12m` | integer | scorer | Material incidents (not every page-fault) |
|
||||
| `security_certs` | list of strings | scorer, classifier | See cert enum below |
|
||||
| `renewal_terms` | enum | scorer | `auto-renew` / `manual-renew` / `evergreen` / `fixed-term` |
|
||||
| `data_access` | list of strings | classifier | See data-access enum below |
|
||||
| `break_glass_plan` | boolean | classifier | Do you have a documented backup plan? |
|
||||
|
||||
### Security cert enum
|
||||
|
||||
Use any combination of:
|
||||
|
||||
- `SOC2` (Type I)
|
||||
- `SOC2-Type-II`
|
||||
- `ISO27001`
|
||||
- `HIPAA`
|
||||
- `PCI-DSS`
|
||||
- `FedRAMP`
|
||||
- `GDPR-DPA`
|
||||
- `CCPA`
|
||||
|
||||
### Data access enum
|
||||
|
||||
Use any combination of:
|
||||
|
||||
- `PHI` (Protected Health Information, HIPAA)
|
||||
- `PII` (Personally Identifiable Information)
|
||||
- `cardholder` (PCI scope)
|
||||
- `source-code`
|
||||
- `financial-records`
|
||||
- `employee-records`
|
||||
- `customer-emails`
|
||||
- `logs-only`
|
||||
- `no-customer-data`
|
||||
|
||||
## 5-vendor sample catalog
|
||||
|
||||
Copy this to `vendor_catalog.json`:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "Okta",
|
||||
"category": "identity",
|
||||
"annual_spend": 180000,
|
||||
"contract_end_date": "2026-09-30",
|
||||
"criticality": "tier-1",
|
||||
"uptime_pct": 99.91,
|
||||
"support_response_hours_p90": 4.5,
|
||||
"incident_count_last_12m": 3,
|
||||
"security_certs": ["SOC2-Type-II", "ISO27001", "FedRAMP", "GDPR-DPA"],
|
||||
"renewal_terms": "manual-renew",
|
||||
"data_access": ["PII", "employee-records"],
|
||||
"break_glass_plan": true
|
||||
},
|
||||
{
|
||||
"name": "Snowflake",
|
||||
"category": "data-warehouse",
|
||||
"annual_spend": 420000,
|
||||
"contract_end_date": "2027-01-15",
|
||||
"criticality": "tier-1",
|
||||
"uptime_pct": 99.97,
|
||||
"support_response_hours_p90": 2.0,
|
||||
"incident_count_last_12m": 1,
|
||||
"security_certs": ["SOC2-Type-II", "ISO27001", "HIPAA", "GDPR-DPA"],
|
||||
"renewal_terms": "fixed-term",
|
||||
"data_access": ["PII", "PHI", "financial-records"],
|
||||
"break_glass_plan": false
|
||||
},
|
||||
{
|
||||
"name": "LegacyCRM",
|
||||
"category": "crm",
|
||||
"annual_spend": 95000,
|
||||
"contract_end_date": "2026-06-30",
|
||||
"criticality": "tier-2",
|
||||
"uptime_pct": 98.20,
|
||||
"support_response_hours_p90": 38.0,
|
||||
"incident_count_last_12m": 11,
|
||||
"security_certs": ["SOC2"],
|
||||
"renewal_terms": "auto-renew",
|
||||
"data_access": ["PII", "customer-emails"],
|
||||
"break_glass_plan": false
|
||||
},
|
||||
{
|
||||
"name": "ChartingTool",
|
||||
"category": "analytics",
|
||||
"annual_spend": 8000,
|
||||
"contract_end_date": "2026-08-01",
|
||||
"criticality": "tier-3",
|
||||
"uptime_pct": 99.50,
|
||||
"support_response_hours_p90": 14.0,
|
||||
"incident_count_last_12m": 2,
|
||||
"security_certs": ["SOC2", "GDPR-DPA"],
|
||||
"renewal_terms": "evergreen",
|
||||
"data_access": ["logs-only"],
|
||||
"break_glass_plan": true
|
||||
},
|
||||
{
|
||||
"name": "BoutiqueQA",
|
||||
"category": "qa-services",
|
||||
"annual_spend": 220000,
|
||||
"contract_end_date": "2026-12-31",
|
||||
"criticality": "tier-3",
|
||||
"uptime_pct": 99.00,
|
||||
"support_response_hours_p90": 22.0,
|
||||
"incident_count_last_12m": 6,
|
||||
"security_certs": [],
|
||||
"renewal_terms": "auto-renew",
|
||||
"data_access": ["source-code", "PII"],
|
||||
"break_glass_plan": false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## SLA records JSON schema
|
||||
|
||||
The SLA tracker takes a **separate** file (`sla_records.json`) where each record is one SLA per vendor (a vendor can have multiple).
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `vendor` | string | Must match the vendor catalog `name` |
|
||||
| `sla_metric` | string | e.g., `uptime_pct`, `support_p90_response_hours`, `ticket_resolution_hours` |
|
||||
| `target` | number | Contractual target |
|
||||
| `actual_last_month` | number | Most recent month |
|
||||
| `actual_last_quarter` | number | Trailing quarter |
|
||||
| `breach_count_12m` | integer | Number of breach events in 12 months |
|
||||
|
||||
### Sample SLA records
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"vendor": "Okta",
|
||||
"sla_metric": "uptime_pct",
|
||||
"target": 99.99,
|
||||
"actual_last_month": 99.95,
|
||||
"actual_last_quarter": 99.91,
|
||||
"breach_count_12m": 3
|
||||
},
|
||||
{
|
||||
"vendor": "Snowflake",
|
||||
"sla_metric": "uptime_pct",
|
||||
"target": 99.9,
|
||||
"actual_last_month": 99.98,
|
||||
"actual_last_quarter": 99.97,
|
||||
"breach_count_12m": 1
|
||||
},
|
||||
{
|
||||
"vendor": "LegacyCRM",
|
||||
"sla_metric": "support_p90_response_hours",
|
||||
"target": 8.0,
|
||||
"actual_last_month": 36.0,
|
||||
"actual_last_quarter": 38.0,
|
||||
"breach_count_12m": 11
|
||||
},
|
||||
{
|
||||
"vendor": "ChartingTool",
|
||||
"sla_metric": "uptime_pct",
|
||||
"target": 99.5,
|
||||
"actual_last_month": 99.6,
|
||||
"actual_last_quarter": 99.5,
|
||||
"breach_count_12m": 0
|
||||
},
|
||||
{
|
||||
"vendor": "BoutiqueQA",
|
||||
"sla_metric": "ticket_resolution_hours",
|
||||
"target": 24.0,
|
||||
"actual_last_month": 30.0,
|
||||
"actual_last_quarter": 28.0,
|
||||
"breach_count_12m": 6
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Tips for populating the catalog
|
||||
|
||||
- **Pull from your SaaS-management tool** (Vendr, Tropic, Zylo, BetterCloud) if you have one — it usually covers `name`, `category`, `annual_spend`, `contract_end_date`, `renewal_terms`.
|
||||
- **Uptime & incidents** come from the vendor's status page archive or your monitoring tool (StatusGator, Datadog).
|
||||
- **`data_access`** requires asking the vendor what data they actually touch. Don't guess — ask, and put it in writing.
|
||||
- **`break_glass_plan: true`** should mean you have a **documented** 72-hour backup plan, not "we think we could figure it out."
|
||||
- For tier-1 vendors, run the catalog quarterly. For tier-2, semi-annually. For tier-3, at renewal.
|
||||
@@ -0,0 +1,90 @@
|
||||
# SLA Design Patterns
|
||||
|
||||
This reference focuses on **measuring vendor SLAs** — what to track, what counts as a breach, when a credit claim is legitimate, and how to distinguish a contractual SLA from an internal operational SLO.
|
||||
|
||||
The distinction matters because most operators conflate the two. A vendor's SLA is a **commercial commitment** with credits attached. An internal SLO is an **engineering target** with no money attached. Use the right framing for the right artifact.
|
||||
|
||||
## 1. Google SRE Workbook — Chapter 2 ("Implementing SLOs")
|
||||
|
||||
The canonical distinction between SLI / SLO / SLA. From the Workbook:
|
||||
|
||||
- **SLI** (Indicator) — what you measure (e.g., HTTP request success rate).
|
||||
- **SLO** (Objective) — internal target (e.g., 99.9% over 28 days).
|
||||
- **SLA** (Agreement) — **external contractual commitment** with consequences (typically service credits).
|
||||
|
||||
The key operational insight: your **SLA should be looser than your SLO**, because the SLO is where you alert internally and the SLA is what you owe the customer. The same logic applies in reverse to vendor SLAs you're tracking: the vendor's SLA is your floor, not your target.
|
||||
|
||||
- Source: Google SRE Workbook (Beyer, Murphy, Rensin, Kawahara, Thorne, eds., O'Reilly 2018). Free online: https://sre.google/workbook/implementing-slos/
|
||||
|
||||
## 2. Atlassian — SLA Best Practices
|
||||
|
||||
Atlassian's product (Jira Service Management) drives a lot of the operational SLA practice in mid-market companies. Their published patterns:
|
||||
|
||||
- SLAs should be **measurable** (no "best effort" clauses — those are unenforceable).
|
||||
- SLA targets should have **business meaning**, not just be round numbers (99.9% has different meaning depending on whether downtime is measured in clock-time or business hours).
|
||||
- Always document **exclusions** explicitly (planned maintenance, force majeure, customer-caused outages).
|
||||
|
||||
- Source: Atlassian — *SLAs: Best Practices*. https://www.atlassian.com/itsm/service-request-management/slas
|
||||
|
||||
## 3. ITIL v4 — Service Level Management practice
|
||||
|
||||
ITIL v4 (the current edition, replacing v3 in 2019) defines **Service Level Management** as one of the 34 management practices. Key concepts:
|
||||
|
||||
- **OLA** (Operational Level Agreement) — the internal mirror of an external SLA. Often missing from vendor relationships, which is why credit claims fail.
|
||||
- The "watermelon SLA" anti-pattern: SLA reports show green externally but the underlying service is rotting (red on the inside). ITIL's response: report on **customer-experienced** metrics, not vendor-self-reported ones.
|
||||
|
||||
- Source: AXELOS — *ITIL Foundation: ITIL 4 Edition* (Stationery Office Books, 2019). https://www.axelos.com/certifications/itil-certifications
|
||||
|
||||
## 4. Gartner — SLA research notes (multiple)
|
||||
|
||||
Gartner publishes recurring research on SLA design across vendor categories. Recurring themes:
|
||||
|
||||
- **Tiered SLAs by service criticality** are now industry standard (e.g., AWS has different SLAs for EC2 vs S3 vs Lambda — your contract should match the workload-vs-SLA pairing).
|
||||
- **Service credits are typically capped at 10-30% of monthly fees** — if the vendor's standard SLA caps credits at < 10% on a tier-1 dependency, that's a negotiation point.
|
||||
- **"100% uptime" SLAs are red flags** — no real service is 100% available; the credit clauses around such SLAs are usually unenforceable in practice.
|
||||
|
||||
- Source: Gartner — search "Service Level Agreement" in Gartner research portal. https://www.gartner.com/en/documents
|
||||
|
||||
## 5. AWS / Azure / GCP — Hyperscaler SLA documentation patterns
|
||||
|
||||
The three hyperscalers publish their SLAs as a public reference for the rest of the industry. Things to study:
|
||||
|
||||
- **AWS Service Level Agreements** — per-service pages, e.g. EC2 SLA, S3 SLA, RDS SLA. Each defines monthly uptime percentage, service credit tiers, exclusions, and the claim process. https://aws.amazon.com/legal/service-level-agreements/
|
||||
- **Microsoft Azure SLAs** — same structure, but with a single consolidated SLA summary table per service. https://www.microsoft.com/licensing/docs/view/Service-Level-Agreements-SLA-for-Online-Services
|
||||
- **Google Cloud SLAs** — per-product, with explicit measurement methodology (e.g., 99.99% means downtime < 4.38 minutes/month). https://cloud.google.com/terms/sla
|
||||
|
||||
These public SLAs are the **benchmark** for any cloud-adjacent SaaS vendor. If a vendor offers worse-than-hyperscaler SLA for an analogous service, that's negotiable.
|
||||
|
||||
## 6. Shawn Robertson — *Practical Guide to SLAs* (industry e-book / blog)
|
||||
|
||||
A practitioner-oriented guide widely cited in IT operations communities. Key themes that show up in this skill's tracker:
|
||||
|
||||
- **Measure the right thing**: response time vs resolution time vs uptime are three different SLAs; vendors often hide behind "we hit response SLA" when resolution is what hurt you.
|
||||
- **Credit-claim eligibility is rarely automatic.** You have to file the claim, with evidence, often within a 30-90 day window. This is why the SLA tracker in this skill flags `credit_claim_eligible: YES` — to remind the operator to actually file.
|
||||
|
||||
- Source: Shawn Robertson — practitioner writings on IT service management. Multiple talks at itSMF / HDI conferences (search "Shawn Robertson SLA practical guide").
|
||||
|
||||
## 7. ISO/IEC 20000-1:2018 — Service management system requirements
|
||||
|
||||
The formal standard backing ITIL practice. Section 8.3.3 (Service Level Management) specifies what your SLM process must include:
|
||||
|
||||
- Documented SLAs for each service
|
||||
- Regular review intervals
|
||||
- Performance against SLAs measured and reported
|
||||
- Corrective action where SLAs are not met
|
||||
|
||||
When a vendor claims ISO 20000 certification, this is the section that backs that claim. Verify it in the audit report — don't trust the marketing page.
|
||||
|
||||
- Source: ISO/IEC 20000-1:2018. https://www.iso.org/standard/70636.html
|
||||
|
||||
## Operational recipe (from this canon)
|
||||
|
||||
When tracking vendor SLAs in the tool:
|
||||
|
||||
1. **Map the SLI** the vendor commits to (e.g., "monthly uptime percentage").
|
||||
2. **Identify the SLA target** in the contract (e.g., 99.95%).
|
||||
3. **Verify the measurement methodology** — vendor's status page, your own monitoring, or third-party (Pingdom, Datadog, StatusGator)? Self-reported is least trustworthy.
|
||||
4. **Track breach count over 12 months** — repeated breaches indicate systemic issues, not bad luck.
|
||||
5. **File credit claims within the contractual window** — otherwise the credit is forfeited regardless of breach.
|
||||
|
||||
The SLA compliance tracker tool flags eligibility but does not file claims automatically. That's a human-in-the-loop step by design.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Vendor Management — Canon
|
||||
|
||||
This reference distills the operating frameworks for ongoing third-party / vendor management. It is **not** a contract-negotiation guide (see `c-level-advisor/general-counsel-advisor`) and **not** a procurement-spend optimization guide (see sibling `procurement-optimizer`).
|
||||
|
||||
The canon spans seven authoritative sources spanning analyst research, formal standards, industry frameworks, and operator practice.
|
||||
|
||||
## 1. Gartner — Vendor Management & TPRM research
|
||||
|
||||
Gartner is the most-cited source for vendor segmentation models. Key concepts to internalize:
|
||||
|
||||
- **Strategic / Tactical / Operational vendor tiers** map roughly to tier-1 / tier-2 / tier-3 in this skill.
|
||||
- **Vendor Performance Management (VPM)** vs Vendor Risk Management (VRM): performance is operational SLA + value tracking; risk is data / financial / regulatory exposure. Both belong in the VMO portfolio.
|
||||
- Source: Gartner — *Magic Quadrant for IT Vendor Risk Management Solutions* (annual, since 2017). https://www.gartner.com/en/documents — search "IT Vendor Risk Management".
|
||||
|
||||
## 2. Shared Assessments — SIG and SIG-Lite
|
||||
|
||||
The **Standardized Information Gathering (SIG) Questionnaire** is the de-facto industry standard for vendor risk assessment. SIG-Lite is the abbreviated 200-question version used for low-and-medium-risk vendors; full SIG runs to ~1,800 questions.
|
||||
|
||||
- SIG Core domains: information security, privacy, business resilience, fourth-party management, compliance, asset management.
|
||||
- The risk classifier in this skill uses a SIG-Lite-*ish* simplification — 4 vectors instead of 18 domains. For tier-1 critical vendors, the full SIG is appropriate.
|
||||
- Source: Shared Assessments Program. https://sharedassessments.org/sig/
|
||||
|
||||
## 3. ISO/IEC 27036 — Information security for supplier relationships
|
||||
|
||||
A formal ISO standard (parts 1-4) covering the full lifecycle of supplier security relationships:
|
||||
|
||||
- **27036-1**: Overview and concepts
|
||||
- **27036-2**: Common requirements (the workhorse part for vendor management)
|
||||
- **27036-3**: ICT supply chain security
|
||||
- **27036-4**: Cloud service customer/provider relationships
|
||||
|
||||
Useful when a vendor claims ISO27001 — the matching 27036 control set tells you what supplier-relationship clauses the auditor expected them to operate against.
|
||||
|
||||
- Source: ISO/IEC 27036 series. https://www.iso.org/standard/59648.html
|
||||
|
||||
## 4. NIST SP 800-161 (Rev. 1) — Cybersecurity Supply Chain Risk Management (C-SCRM)
|
||||
|
||||
The U.S. federal standard for supply-chain risk. Even commercial orgs use 800-161 as a checklist:
|
||||
|
||||
- 8 foundational practices (e.g., integrate C-SCRM into acquisition, use a risk-based approach, identify and protect critical assets).
|
||||
- Detailed control overlays mapped to NIST SP 800-53 controls.
|
||||
- Strong framework for **fourth-party** risk (vendors-of-your-vendors) — often where the actual breach originates (SolarWinds being the canonical example).
|
||||
|
||||
- Source: NIST SP 800-161 Rev. 1 (May 2022). https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-161r1.pdf
|
||||
|
||||
## 5. Forrester — Third-Party Risk Management Wave
|
||||
|
||||
Forrester's TPRM Wave is the second-most-cited analyst source (after Gartner) and tends to be more practitioner-flavored:
|
||||
|
||||
- Forrester's framing: **continuous monitoring** beats point-in-time annual assessments. The SLA compliance tracker in this skill is built on this premise.
|
||||
- Key metric Forrester pushes: **mean time to detect (MTTD)** for third-party incidents — most orgs are > 60 days, which is too long for tier-1 vendors.
|
||||
|
||||
- Source: Forrester — *The Forrester Wave: Third-Party Risk Management Platforms* (biennial). https://www.forrester.com/research/
|
||||
|
||||
## 6. ISACA — TPRM framework + COBIT 2019 alignment
|
||||
|
||||
ISACA (the auditor body behind CISM and COBIT) publishes a pragmatic TPRM framework that aligns to **COBIT 2019** controls:
|
||||
|
||||
- COBIT APO10 ("Managed Vendors") is the relevant process domain: vendor selection, contract management, performance, risk, and termination.
|
||||
- ISACA's TPRM guidance is heavy on **audit evidence** — what artifacts to keep so a SOC2 / ISO27001 auditor can verify your TPRM is operating.
|
||||
|
||||
- Source: ISACA — *Third Party Risk Management Audit Program* + COBIT 2019. https://www.isaca.org/resources/cobit and https://www.isaca.org/bookstore
|
||||
|
||||
## 7. Vendr & Tropic — Industry SaaS-management reports (annual)
|
||||
|
||||
Two leading SaaS-management vendors publish annual reports that quantify the operational reality of SaaS sprawl. They're not academic, but they're the only source that benchmarks actual companies:
|
||||
|
||||
- **Vendr SaaS Trends Report** — typical company has 130-200 SaaS subscriptions, average 30% YoY growth in software spend, ~20% redundancy at large orgs. https://www.vendr.com/blog
|
||||
- **Tropic State of SaaS Spend Report** — auto-renew traps cost the average mid-market company ~7% of total SaaS spend annually. https://www.tropicapp.io/resources
|
||||
- Both reports emphasize: **the renewal date is too late** to start vendor review. Quarterly rolling review is the operating cadence to aim for.
|
||||
|
||||
## How this canon maps to the tools in this skill
|
||||
|
||||
| Tool | Primary canon |
|
||||
|---|---|
|
||||
| `vendor_scorer.py` | Gartner VPM + Vendr/Tropic operational benchmarks |
|
||||
| `sla_compliance_tracker.py` | Forrester continuous monitoring + Atlassian/ITIL service level patterns (see `sla_design_patterns.md`) |
|
||||
| `vendor_risk_classifier.py` | Shared Assessments SIG + ISO 27036 + NIST SP 800-161 |
|
||||
|
||||
When in doubt: SIG-Lite is the floor for tier-2 and -3 vendors; full SIG + ISO 27036 + 800-161 for tier-1.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Vendor Risk Anti-Patterns — Lessons from Real Breaches
|
||||
|
||||
The strongest argument for serious TPRM discipline is post-mortems from real third-party-originated incidents. This reference catalogues seven canonical breaches and the operational anti-patterns each one demonstrates.
|
||||
|
||||
The point of this reference is **not** to scare. The point is: every one of these incidents had a vendor-management anti-pattern at its root, and most of them were avoidable with the discipline this skill enforces.
|
||||
|
||||
## 1. SolarWinds Orion (2020) — Fourth-party / supply-chain compromise
|
||||
|
||||
Russian state-aligned actors (UNC2452 / Cozy Bear) inserted backdoor code into the SolarWinds Orion software update pipeline. ~18,000 organizations installed the trojanized update; ~100 (including U.S. federal agencies and major enterprises) had follow-on intrusions.
|
||||
|
||||
**Anti-patterns demonstrated:**
|
||||
- **No software supply-chain verification.** The orgs that installed the update never verified the integrity of the binary beyond "the vendor's update server said so."
|
||||
- **Implicit trust in tier-1 monitoring vendor.** Orion was deployed with extraordinary network access; no one re-evaluated whether that access level was justified.
|
||||
- **No fourth-party visibility.** SolarWinds' own dev pipeline was the actual breach point — most customers had never asked who SolarWinds' suppliers were.
|
||||
|
||||
Source: CISA Alert AA20-352A (Dec 2020). https://www.cisa.gov/news-events/cybersecurity-advisories/aa20-352a
|
||||
|
||||
## 2. Target / Fazio Mechanical (2013) — HVAC vendor pivot
|
||||
|
||||
The 40M-card Target breach originated through Fazio Mechanical Services, a refrigeration / HVAC vendor with billing-system access to Target's network. Attackers phished Fazio, used Fazio's credentials to access Target's vendor portal, and pivoted from there into the POS network.
|
||||
|
||||
**Anti-patterns demonstrated:**
|
||||
- **Excessive vendor network access.** An HVAC vendor needed network access to a vendor portal — fine. But that network was not segmented from POS systems, which is the failure.
|
||||
- **No vendor risk tier evaluation.** Fazio was probably classified as tier-3 (a maintenance vendor). But the **access** they had made them effectively tier-1.
|
||||
|
||||
**Lesson:** Risk tier ≠ business criticality. A janitorial vendor with badge-system access can be a tier-1 attack surface.
|
||||
|
||||
Source: U.S. Senate Commerce Committee Report (Mar 2014). https://www.commerce.senate.gov/services/files/24d3c229-4f2f-405d-b8db-a3a67f183883
|
||||
|
||||
## 3. NotPetya / M.E.Doc (2017) — Trusted update mechanism weaponized
|
||||
|
||||
The NotPetya malware was injected via the update mechanism of M.E.Doc, a Ukrainian tax-reporting software used by ~80% of Ukrainian businesses. Spillover damage hit Maersk, FedEx (TNT), Merck, Mondelez — total damages > $10 billion globally. Maersk alone reported ~$300M loss and a 10-day operational outage.
|
||||
|
||||
**Anti-patterns demonstrated:**
|
||||
- **Trusted-update-channel assumption.** No one verified the signed updates from M.E.Doc — its update key had been compromised for months.
|
||||
- **Geographic concentration without geographic diversification.** Maersk's exposure was through a Ukrainian subsidiary; the parent had no breakglass for losing 100% of that subsidiary's systems for two weeks.
|
||||
|
||||
**Lesson:** A vendor used by 80%+ of your local market is effectively a single point of failure.
|
||||
|
||||
Source: Wired's Maersk NotPetya retrospective by Andy Greenberg (Aug 2018). https://www.wired.com/story/notpetya-cyberattack-ukraine-russia-code-crashed-the-world/
|
||||
|
||||
## 4. Capital One (2019) — AWS misconfiguration via former employee
|
||||
|
||||
A former AWS employee exploited a Capital One server-side request forgery (SSRF) vulnerability to access 100M+ customer records held in S3. Total cost to Capital One: ~$190M in regulatory fines + settlement.
|
||||
|
||||
**Anti-patterns demonstrated:**
|
||||
- **Shared-responsibility model misunderstood.** Capital One assumed AWS would catch the misconfiguration. AWS's model puts misconfiguration responsibility on the customer.
|
||||
- **No third-party penetration testing of the cloud config.** A reasonably scoped TPRM-driven pen test would have caught the SSRF.
|
||||
|
||||
**Lesson:** Cloud vendor due diligence must include "what is **my** responsibility under their shared-responsibility model?" — not just "are they SOC2?"
|
||||
|
||||
Source: Capital One incident summary + OCC consent order (Aug 2020). https://occ.gov/news-issuances/news-releases/2020/nr-occ-2020-101.html
|
||||
|
||||
## 5. Verkada (2021) — Camera vendor super-admin compromise
|
||||
|
||||
A hacking group obtained super-admin credentials to Verkada, a cloud-based security-camera vendor. Result: live-feed access to 150,000 cameras across hospitals, prisons, schools, Tesla factories, and corporate offices. The credentials were apparently exposed in a public Jenkins server.
|
||||
|
||||
**Anti-patterns demonstrated:**
|
||||
- **Super-admin tooling without MFA enforcement.** Verkada had a super-admin role that bypassed customer-tenant boundaries — and apparently wasn't MFA-enforced.
|
||||
- **No customer-side visibility into vendor admin actions.** Customers had no way to detect that vendor super-admins had viewed their feeds.
|
||||
|
||||
**Lesson:** For any SaaS handling sensitive data, ask: "Do your engineers have super-admin access to my tenant? How is that access logged and how can I audit it?"
|
||||
|
||||
Source: Bloomberg reporting (Mar 2021). https://www.bloomberg.com/news/articles/2021-03-09/hackers-expose-tesla-jails-in-breach-of-150-000-security-cameras
|
||||
|
||||
## 6. Okta (2022) — Lapsus$ / Sitel third-party support compromise
|
||||
|
||||
The Lapsus$ group compromised a Sitel customer-support engineer who had remote-support tooling access to Okta tenant data. Window of access: ~5 days. Okta's initial public disclosure was widely criticized as too slow and underplayed.
|
||||
|
||||
**Anti-patterns demonstrated:**
|
||||
- **Subcontractor-of-subcontractor risk.** Sitel was Okta's outsourced support; the compromised engineer was Sitel's. Most Okta customers had no idea Sitel existed.
|
||||
- **Slow disclosure of vendor incidents to downstream customers.** Customers found out about the breach months after Okta became aware internally.
|
||||
|
||||
**Lesson:** Contractually require your tier-1 vendors to disclose incidents within 24-72 hours, not "when investigation completes." This is now standard in DPAs but often missing from older contracts.
|
||||
|
||||
Source: Okta's official Lapsus$ statement updates (Mar-Apr 2022). https://www.okta.com/blog/2022/03/updated-okta-statement-on-lapsus/
|
||||
|
||||
## 7. Log4Shell / log4j (2021) — Open-source dependency as a vendor
|
||||
|
||||
CVE-2021-44228 in the log4j Java logging library affected ~3 billion devices and embedded in tens of thousands of commercial vendor products. Most affected orgs had no idea log4j was in their supply chain because it was a transitive dependency of vendor SaaS, not a direct dependency.
|
||||
|
||||
**Anti-patterns demonstrated:**
|
||||
- **Open-source dependencies treated as "not vendors."** They are. They have SLAs (effectively zero), have security disclosure processes (variable), and have maintainers who can disappear.
|
||||
- **No SBOM (Software Bill of Materials) requested from vendors.** Customers couldn't tell which of their vendors were affected.
|
||||
|
||||
**Lesson:** Add an SBOM requirement to vendor contracts for tier-1 and tier-2 vendors. Without an SBOM, every new transitive CVE is a multi-week fire drill.
|
||||
|
||||
Source: CISA Apache Log4j Vulnerability Guidance. https://www.cisa.gov/news-events/news/apache-log4j-vulnerability-guidance
|
||||
|
||||
## Synthesis: The 7 vendor-risk anti-patterns to avoid
|
||||
|
||||
1. **Treat all vendors at the same tier.** Tier-1 vendors get quarterly review + full SIG. Tier-2 semi-annual. Tier-3 at renewal. Network-access privilege is the override — see Target.
|
||||
2. **Annual review is enough.** It isn't. Continuous monitoring (Forrester) + quarterly QBR is the operating cadence.
|
||||
3. **Trust the vendor security questionnaire without verification.** Ask for the SOC2 Type II report. Read the exceptions section. Verify cert validity dates.
|
||||
4. **No break-glass plan for a tier-1 vendor.** If the vendor disappears tomorrow (acquisition, bankruptcy, NotPetya-class outage), what's the 72-hour plan? Document it before you need it.
|
||||
5. **No offboarding checklist when vendor changes hands.** SolarWinds and Okta both demonstrate why you need a data-deletion + access-revocation runbook ready to execute.
|
||||
6. **Ignore fourth parties.** Your vendors have vendors. For tier-1, ask: "Who are your top 5 subcontractors? Which ones have access to my data?"
|
||||
7. **No SBOM for SaaS vendors.** When the next log4j-class CVE drops, you want to be able to query a list, not start an email thread.
|
||||
|
||||
The risk classifier in this skill catches most of these via the 4-vector classification, but the **mitigations** are the human-in-the-loop step. Use them.
|
||||
@@ -0,0 +1,342 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
sla_compliance_tracker.py — Per-vendor SLA compliance tracking.
|
||||
|
||||
Takes JSON of SLA records {vendor, sla_metric, target, actual_last_month,
|
||||
actual_last_quarter, breach_count_12m}. Computes:
|
||||
- Compliance % vs target (last month, last quarter)
|
||||
- Trend classification (improving / stable / degrading)
|
||||
- Credit-claim eligibility flag (per typical SLA credit clauses)
|
||||
|
||||
Output: per-vendor compliance scorecard markdown with action items.
|
||||
|
||||
Stdlib only. Deterministic. No LLM calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Trend(str, Enum):
|
||||
IMPROVING = "improving"
|
||||
STABLE = "stable"
|
||||
DEGRADING = "degrading"
|
||||
|
||||
|
||||
class ComplianceState(str, Enum):
|
||||
MET = "met"
|
||||
AT_RISK = "at-risk"
|
||||
BREACHED = "breached"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SLAResult:
|
||||
vendor: str
|
||||
sla_metric: str
|
||||
target: float
|
||||
actual_last_month: float
|
||||
actual_last_quarter: float
|
||||
breach_count_12m: int
|
||||
compliance_month_pct: float
|
||||
compliance_quarter_pct: float
|
||||
state: ComplianceState
|
||||
trend: Trend
|
||||
credit_claim_eligible: bool
|
||||
action_items: list[str]
|
||||
|
||||
|
||||
# A small library of typical SLA credit-claim thresholds.
|
||||
# breach_count_12m >= 2 OR actual_last_quarter < target by > 0.5pp -> eligible.
|
||||
# This is "SIG-Lite-ish" — operators tune per actual contract.
|
||||
CREDIT_CLAIM_DELTA_PP = 0.5 # percentage points (or hours) below/above target
|
||||
|
||||
|
||||
# Metrics where lower is better (response time, resolution hours, etc.).
|
||||
# Anything else (uptime_pct, throughput, etc.) is "higher is better."
|
||||
_LOWER_IS_BETTER_HINTS = (
|
||||
"response",
|
||||
"resolution",
|
||||
"latency",
|
||||
"hours",
|
||||
"minutes",
|
||||
"mttr",
|
||||
"time_to",
|
||||
)
|
||||
|
||||
|
||||
def _is_lower_better(sla_metric: str) -> bool:
|
||||
metric_lc = sla_metric.lower()
|
||||
return any(h in metric_lc for h in _LOWER_IS_BETTER_HINTS)
|
||||
|
||||
|
||||
def _compute_compliance_pct(actual: float, target: float, lower_is_better: bool) -> float:
|
||||
"""Compliance % capped at 100. Direction depends on metric semantics."""
|
||||
if target <= 0:
|
||||
return 100.0
|
||||
if lower_is_better:
|
||||
# actual <= target -> 100%. actual = 2*target -> 50%. actual = 4*target -> 25%.
|
||||
return round(min(100.0, (target / actual) * 100.0), 2) if actual > 0 else 100.0
|
||||
return round(min(100.0, (actual / target) * 100.0), 2)
|
||||
|
||||
|
||||
def _classify_state(
|
||||
actual_last_quarter: float, target: float, lower_is_better: bool
|
||||
) -> ComplianceState:
|
||||
if lower_is_better:
|
||||
if actual_last_quarter <= target:
|
||||
return ComplianceState.MET
|
||||
if actual_last_quarter <= target * 1.10: # within 10% over
|
||||
return ComplianceState.AT_RISK
|
||||
return ComplianceState.BREACHED
|
||||
# higher is better
|
||||
if actual_last_quarter >= target:
|
||||
return ComplianceState.MET
|
||||
if actual_last_quarter >= target - 0.25:
|
||||
return ComplianceState.AT_RISK
|
||||
return ComplianceState.BREACHED
|
||||
|
||||
|
||||
def _classify_trend(
|
||||
actual_last_month: float, actual_last_quarter: float, lower_is_better: bool
|
||||
) -> Trend:
|
||||
delta = actual_last_month - actual_last_quarter
|
||||
# For lower-is-better metrics, a negative delta (smaller now) is improving.
|
||||
if lower_is_better:
|
||||
delta = -delta
|
||||
if delta > 0.1:
|
||||
return Trend.IMPROVING
|
||||
if delta < -0.1:
|
||||
return Trend.DEGRADING
|
||||
return Trend.STABLE
|
||||
|
||||
|
||||
def _credit_eligible(
|
||||
actual_last_quarter: float,
|
||||
target: float,
|
||||
breach_count_12m: int,
|
||||
lower_is_better: bool,
|
||||
) -> bool:
|
||||
if breach_count_12m >= 2:
|
||||
return True
|
||||
if lower_is_better:
|
||||
if actual_last_quarter > (target + CREDIT_CLAIM_DELTA_PP):
|
||||
return True
|
||||
else:
|
||||
if actual_last_quarter < (target - CREDIT_CLAIM_DELTA_PP):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _build_action_items(
|
||||
state: ComplianceState,
|
||||
trend: Trend,
|
||||
credit_eligible: bool,
|
||||
breach_count_12m: int,
|
||||
) -> list[str]:
|
||||
items: list[str] = []
|
||||
if credit_eligible:
|
||||
items.append("Open an SLA credit-claim ticket with the vendor's CSM.")
|
||||
if state == ComplianceState.BREACHED:
|
||||
items.append("Escalate to vendor exec sponsor. Request root-cause analysis.")
|
||||
if state == ComplianceState.AT_RISK and trend == Trend.DEGRADING:
|
||||
items.append("Schedule QBR within 30 days. Trend will breach if uncorrected.")
|
||||
if breach_count_12m >= 4:
|
||||
items.append(
|
||||
f"{breach_count_12m} breaches in 12 months — flag vendor as REVIEW in next scorecard."
|
||||
)
|
||||
if state == ComplianceState.MET and trend == Trend.IMPROVING and breach_count_12m == 0:
|
||||
items.append("No action required. Acknowledge in next vendor business review.")
|
||||
if not items:
|
||||
items.append("Monitor — no immediate action.")
|
||||
return items
|
||||
|
||||
|
||||
def evaluate_sla(record: dict[str, Any]) -> SLAResult:
|
||||
target = float(record["target"])
|
||||
actual_month = float(record["actual_last_month"])
|
||||
actual_quarter = float(record["actual_last_quarter"])
|
||||
breach_count = int(record.get("breach_count_12m", 0))
|
||||
sla_metric = str(record["sla_metric"])
|
||||
lower_is_better = _is_lower_better(sla_metric)
|
||||
|
||||
state = _classify_state(actual_quarter, target, lower_is_better)
|
||||
trend = _classify_trend(actual_month, actual_quarter, lower_is_better)
|
||||
eligible = _credit_eligible(actual_quarter, target, breach_count, lower_is_better)
|
||||
return SLAResult(
|
||||
vendor=str(record["vendor"]),
|
||||
sla_metric=sla_metric,
|
||||
target=target,
|
||||
actual_last_month=actual_month,
|
||||
actual_last_quarter=actual_quarter,
|
||||
breach_count_12m=breach_count,
|
||||
compliance_month_pct=_compute_compliance_pct(
|
||||
actual_month, target, lower_is_better
|
||||
),
|
||||
compliance_quarter_pct=_compute_compliance_pct(
|
||||
actual_quarter, target, lower_is_better
|
||||
),
|
||||
state=state,
|
||||
trend=trend,
|
||||
credit_claim_eligible=eligible,
|
||||
action_items=_build_action_items(state, trend, eligible, breach_count),
|
||||
)
|
||||
|
||||
|
||||
# ---------- Markdown rendering ----------
|
||||
|
||||
|
||||
def render_markdown(results: list[SLAResult]) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append("# SLA Compliance Report")
|
||||
lines.append("")
|
||||
|
||||
# Summary
|
||||
total = len(results)
|
||||
breached = sum(1 for r in results if r.state == ComplianceState.BREACHED)
|
||||
at_risk = sum(1 for r in results if r.state == ComplianceState.AT_RISK)
|
||||
eligible = [r for r in results if r.credit_claim_eligible]
|
||||
lines.append(
|
||||
f"**Summary:** {total} SLAs tracked · {breached} breached · {at_risk} at risk · "
|
||||
f"{len(eligible)} credit-claim eligible."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Detail table
|
||||
lines.append("## Per-SLA Status")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"| Vendor | SLA Metric | Target | Last Month | Last Quarter | "
|
||||
"Compliance Q | State | Trend | Breaches 12m | Credit Eligible |"
|
||||
)
|
||||
lines.append(
|
||||
"|---|---|---|---|---|---|---|---|---|---|"
|
||||
)
|
||||
for r in results:
|
||||
lines.append(
|
||||
f"| {r.vendor} | {r.sla_metric} | {r.target} | {r.actual_last_month} | "
|
||||
f"{r.actual_last_quarter} | {r.compliance_quarter_pct}% | "
|
||||
f"{r.state.value} | {r.trend.value} | {r.breach_count_12m} | "
|
||||
f"{'YES' if r.credit_claim_eligible else 'no'} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Action items
|
||||
lines.append("## Action Items")
|
||||
lines.append("")
|
||||
for r in results:
|
||||
lines.append(f"### {r.vendor} — {r.sla_metric}")
|
||||
for item in r.action_items:
|
||||
lines.append(f"- {item}")
|
||||
lines.append("")
|
||||
|
||||
# Credit-claim shortlist
|
||||
if eligible:
|
||||
lines.append("## Credit-Claim Shortlist")
|
||||
lines.append("")
|
||||
lines.append("| Vendor | SLA | Target | Last Q | Breaches 12m |")
|
||||
lines.append("|---|---|---|---|---|")
|
||||
for r in eligible:
|
||||
lines.append(
|
||||
f"| {r.vendor} | {r.sla_metric} | {r.target} | {r.actual_last_quarter} | "
|
||||
f"{r.breach_count_12m} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------- Sample data ----------
|
||||
|
||||
SAMPLE_RECORDS: list[dict[str, Any]] = [
|
||||
{
|
||||
"vendor": "Okta",
|
||||
"sla_metric": "uptime_pct",
|
||||
"target": 99.99,
|
||||
"actual_last_month": 99.95,
|
||||
"actual_last_quarter": 99.91,
|
||||
"breach_count_12m": 3,
|
||||
},
|
||||
{
|
||||
"vendor": "Snowflake",
|
||||
"sla_metric": "uptime_pct",
|
||||
"target": 99.9,
|
||||
"actual_last_month": 99.98,
|
||||
"actual_last_quarter": 99.97,
|
||||
"breach_count_12m": 1,
|
||||
},
|
||||
{
|
||||
"vendor": "LegacyCRM",
|
||||
"sla_metric": "support_p90_response_hours",
|
||||
"target": 8.0,
|
||||
"actual_last_month": 36.0,
|
||||
"actual_last_quarter": 38.0,
|
||||
"breach_count_12m": 11,
|
||||
},
|
||||
{
|
||||
"vendor": "ChartingTool",
|
||||
"sla_metric": "uptime_pct",
|
||||
"target": 99.5,
|
||||
"actual_last_month": 99.6,
|
||||
"actual_last_quarter": 99.5,
|
||||
"breach_count_12m": 0,
|
||||
},
|
||||
{
|
||||
"vendor": "BoutiqueQA",
|
||||
"sla_metric": "ticket_resolution_hours",
|
||||
"target": 24.0,
|
||||
"actual_last_month": 30.0,
|
||||
"actual_last_quarter": 28.0,
|
||||
"breach_count_12m": 6,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------- CLI ----------
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Track per-vendor SLA compliance and flag credit-claim eligibility."
|
||||
)
|
||||
parser.add_argument("--input", type=Path, help="Path to JSON SLA records.")
|
||||
parser.add_argument("--output", type=Path, help="Path to write markdown report.")
|
||||
parser.add_argument(
|
||||
"--sample", action="store_true", help="Run against built-in sample SLA records."
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not args.sample and not args.input:
|
||||
parser.error("provide --input or --sample")
|
||||
|
||||
if args.sample:
|
||||
records = SAMPLE_RECORDS
|
||||
else:
|
||||
try:
|
||||
records = json.loads(args.input.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"error reading {args.input}: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
if not isinstance(records, list):
|
||||
print("input JSON must be a list of SLA record objects", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
results = [evaluate_sla(r) for r in records]
|
||||
md = render_markdown(results)
|
||||
|
||||
if args.output:
|
||||
args.output.write_text(md, encoding="utf-8")
|
||||
print(f"wrote {args.output}")
|
||||
else:
|
||||
print(md)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
vendor_risk_classifier.py — Classify third-party risk across 4 vectors.
|
||||
|
||||
Inspired by Shared Assessments SIG-Lite + NIST SP 800-161 supply chain risk.
|
||||
Classifies each vendor as Critical / High / Medium / Low across:
|
||||
|
||||
1. Data sensitivity — PII / PHI / cardholder / source code access
|
||||
2. Financial exposure — annual spend × tier multiplier
|
||||
3. Operational dependency — tier-1 + no break-glass = Critical
|
||||
4. Regulatory exposure — industry profile drives weighting
|
||||
|
||||
Industry profile ({saas,fintech,healthcare,enterprise}) re-weights regulatory.
|
||||
|
||||
Output: risk matrix markdown + per-vendor mitigation recommendations.
|
||||
|
||||
Stdlib only. Deterministic. No LLM calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class RiskLevel(str, Enum):
|
||||
LOW = "Low"
|
||||
MEDIUM = "Medium"
|
||||
HIGH = "High"
|
||||
CRITICAL = "Critical"
|
||||
|
||||
|
||||
_LEVEL_RANK = {
|
||||
RiskLevel.LOW: 0,
|
||||
RiskLevel.MEDIUM: 1,
|
||||
RiskLevel.HIGH: 2,
|
||||
RiskLevel.CRITICAL: 3,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskBreakdown:
|
||||
data_sensitivity: RiskLevel
|
||||
financial_exposure: RiskLevel
|
||||
operational_dependency: RiskLevel
|
||||
regulatory_exposure: RiskLevel
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskClassification:
|
||||
vendor: str
|
||||
category: str
|
||||
overall: RiskLevel
|
||||
breakdown: RiskBreakdown
|
||||
mitigations: list[str]
|
||||
|
||||
|
||||
# ---------- Per-vector classifiers ----------
|
||||
|
||||
|
||||
_DATA_SENSITIVITY_KEYS = {
|
||||
"PHI": RiskLevel.CRITICAL,
|
||||
"PII": RiskLevel.HIGH,
|
||||
"cardholder": RiskLevel.CRITICAL,
|
||||
"source-code": RiskLevel.HIGH,
|
||||
"financial-records": RiskLevel.HIGH,
|
||||
"employee-records": RiskLevel.HIGH,
|
||||
"customer-emails": RiskLevel.MEDIUM,
|
||||
"logs-only": RiskLevel.LOW,
|
||||
"no-customer-data": RiskLevel.LOW,
|
||||
}
|
||||
|
||||
|
||||
def classify_data_sensitivity(vendor: dict[str, Any]) -> RiskLevel:
|
||||
"""Choose worst of declared data_access tags. Default Medium if unspecified."""
|
||||
tags = vendor.get("data_access") or []
|
||||
if not tags:
|
||||
return RiskLevel.MEDIUM
|
||||
levels = [_DATA_SENSITIVITY_KEYS.get(t, RiskLevel.MEDIUM) for t in tags]
|
||||
return max(levels, key=lambda lv: _LEVEL_RANK[lv])
|
||||
|
||||
|
||||
def classify_financial_exposure(vendor: dict[str, Any]) -> RiskLevel:
|
||||
spend = float(vendor.get("annual_spend", 0))
|
||||
crit = str(vendor.get("criticality", "tier-3"))
|
||||
multiplier = {"tier-1": 1.5, "tier-2": 1.0, "tier-3": 0.6}.get(crit, 0.6)
|
||||
weighted = spend * multiplier
|
||||
if weighted >= 500_000:
|
||||
return RiskLevel.CRITICAL
|
||||
if weighted >= 150_000:
|
||||
return RiskLevel.HIGH
|
||||
if weighted >= 50_000:
|
||||
return RiskLevel.MEDIUM
|
||||
return RiskLevel.LOW
|
||||
|
||||
|
||||
def classify_operational_dependency(vendor: dict[str, Any]) -> RiskLevel:
|
||||
crit = str(vendor.get("criticality", "tier-3"))
|
||||
has_breakglass = bool(vendor.get("break_glass_plan", False))
|
||||
if crit == "tier-1" and not has_breakglass:
|
||||
return RiskLevel.CRITICAL
|
||||
if crit == "tier-1":
|
||||
return RiskLevel.HIGH
|
||||
if crit == "tier-2" and not has_breakglass:
|
||||
return RiskLevel.HIGH
|
||||
if crit == "tier-2":
|
||||
return RiskLevel.MEDIUM
|
||||
return RiskLevel.LOW
|
||||
|
||||
|
||||
_REGULATORY_PROFILE: dict[str, dict[str, RiskLevel]] = {
|
||||
# Per-profile, mapping of cert presence to risk reduction.
|
||||
# Worst case before mitigations:
|
||||
# healthcare requires HIPAA, fintech requires SOC2-Type-II + PCI-DSS (if cardholder).
|
||||
"saas": {},
|
||||
"fintech": {},
|
||||
"healthcare": {},
|
||||
"enterprise": {},
|
||||
}
|
||||
|
||||
|
||||
def classify_regulatory_exposure(
|
||||
vendor: dict[str, Any], profile: str
|
||||
) -> RiskLevel:
|
||||
certs = set(vendor.get("security_certs") or [])
|
||||
data_tags = set(vendor.get("data_access") or [])
|
||||
|
||||
if profile == "healthcare":
|
||||
if "PHI" in data_tags and "HIPAA" not in certs:
|
||||
return RiskLevel.CRITICAL
|
||||
if "PHI" in data_tags:
|
||||
return RiskLevel.HIGH
|
||||
if "PII" in data_tags and "SOC2-Type-II" not in certs:
|
||||
return RiskLevel.HIGH
|
||||
return RiskLevel.MEDIUM
|
||||
|
||||
if profile == "fintech":
|
||||
if "cardholder" in data_tags and "PCI-DSS" not in certs:
|
||||
return RiskLevel.CRITICAL
|
||||
if "cardholder" in data_tags:
|
||||
return RiskLevel.HIGH
|
||||
if "SOC2-Type-II" not in certs and "ISO27001" not in certs:
|
||||
return RiskLevel.HIGH
|
||||
return RiskLevel.MEDIUM
|
||||
|
||||
if profile == "enterprise":
|
||||
if "PII" in data_tags and "SOC2-Type-II" not in certs:
|
||||
return RiskLevel.HIGH
|
||||
if "SOC2" not in certs and "SOC2-Type-II" not in certs:
|
||||
return RiskLevel.MEDIUM
|
||||
return RiskLevel.LOW
|
||||
|
||||
# saas (default)
|
||||
if "PII" in data_tags and "SOC2" not in certs and "SOC2-Type-II" not in certs:
|
||||
return RiskLevel.HIGH
|
||||
if "PII" in data_tags:
|
||||
return RiskLevel.MEDIUM
|
||||
return RiskLevel.LOW
|
||||
|
||||
|
||||
def overall_risk(breakdown: RiskBreakdown) -> RiskLevel:
|
||||
# Overall = worst-of, with one nuance: two HIGH vectors -> CRITICAL.
|
||||
levels = [
|
||||
breakdown.data_sensitivity,
|
||||
breakdown.financial_exposure,
|
||||
breakdown.operational_dependency,
|
||||
breakdown.regulatory_exposure,
|
||||
]
|
||||
worst = max(levels, key=lambda lv: _LEVEL_RANK[lv])
|
||||
high_count = sum(1 for lv in levels if lv == RiskLevel.HIGH)
|
||||
if worst == RiskLevel.HIGH and high_count >= 2:
|
||||
return RiskLevel.CRITICAL
|
||||
return worst
|
||||
|
||||
|
||||
def build_mitigations(
|
||||
vendor: dict[str, Any], breakdown: RiskBreakdown, profile: str
|
||||
) -> list[str]:
|
||||
mits: list[str] = []
|
||||
certs = set(vendor.get("security_certs") or [])
|
||||
data_tags = set(vendor.get("data_access") or [])
|
||||
|
||||
if breakdown.data_sensitivity in {RiskLevel.HIGH, RiskLevel.CRITICAL}:
|
||||
mits.append(
|
||||
"Confirm data-processing addendum (DPA) is current. Require encryption at rest + in transit."
|
||||
)
|
||||
if breakdown.financial_exposure in {RiskLevel.HIGH, RiskLevel.CRITICAL}:
|
||||
mits.append(
|
||||
"Require liability cap parity (≥ 12 months of fees). Confirm insurance certificate on file."
|
||||
)
|
||||
if breakdown.operational_dependency == RiskLevel.CRITICAL:
|
||||
mits.append(
|
||||
"Document a 72-hour break-glass plan. Identify and pre-qualify a backup vendor."
|
||||
)
|
||||
if breakdown.regulatory_exposure == RiskLevel.CRITICAL:
|
||||
if profile == "healthcare" and "HIPAA" not in certs:
|
||||
mits.append("Block PHI access until HIPAA BAA is signed and certs verified.")
|
||||
if profile == "fintech" and "cardholder" in data_tags and "PCI-DSS" not in certs:
|
||||
mits.append("Block cardholder data until PCI-DSS AOC (Attestation) is on file.")
|
||||
if breakdown.regulatory_exposure in {RiskLevel.HIGH, RiskLevel.CRITICAL}:
|
||||
mits.append("Request most recent SOC2 Type II report; review exceptions section.")
|
||||
if not mits:
|
||||
mits.append("No critical mitigations required; routine annual review.")
|
||||
return mits
|
||||
|
||||
|
||||
def classify_vendor(vendor: dict[str, Any], profile: str) -> RiskClassification:
|
||||
breakdown = RiskBreakdown(
|
||||
data_sensitivity=classify_data_sensitivity(vendor),
|
||||
financial_exposure=classify_financial_exposure(vendor),
|
||||
operational_dependency=classify_operational_dependency(vendor),
|
||||
regulatory_exposure=classify_regulatory_exposure(vendor, profile),
|
||||
)
|
||||
return RiskClassification(
|
||||
vendor=str(vendor.get("name", "Unknown")),
|
||||
category=str(vendor.get("category", "uncategorized")),
|
||||
overall=overall_risk(breakdown),
|
||||
breakdown=breakdown,
|
||||
mitigations=build_mitigations(vendor, breakdown, profile),
|
||||
)
|
||||
|
||||
|
||||
# ---------- Markdown rendering ----------
|
||||
|
||||
|
||||
def render_markdown(results: list[RiskClassification], profile: str) -> str:
|
||||
by_overall = sorted(
|
||||
results, key=lambda r: _LEVEL_RANK[r.overall], reverse=True
|
||||
)
|
||||
lines: list[str] = []
|
||||
lines.append(f"# Vendor Risk Matrix — `{profile}` profile")
|
||||
lines.append("")
|
||||
|
||||
crit = [r for r in by_overall if r.overall == RiskLevel.CRITICAL]
|
||||
high = [r for r in by_overall if r.overall == RiskLevel.HIGH]
|
||||
lines.append(
|
||||
f"**Summary:** {len(crit)} Critical · {len(high)} High · "
|
||||
f"{sum(1 for r in by_overall if r.overall == RiskLevel.MEDIUM)} Medium · "
|
||||
f"{sum(1 for r in by_overall if r.overall == RiskLevel.LOW)} Low"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Risk Matrix")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"| Vendor | Category | Data | Financial | Operational | Regulatory | Overall |"
|
||||
)
|
||||
lines.append("|---|---|---|---|---|---|---|")
|
||||
for r in by_overall:
|
||||
b = r.breakdown
|
||||
lines.append(
|
||||
f"| {r.vendor} | {r.category} | {b.data_sensitivity.value} | "
|
||||
f"{b.financial_exposure.value} | {b.operational_dependency.value} | "
|
||||
f"{b.regulatory_exposure.value} | **{r.overall.value}** |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Mitigations")
|
||||
lines.append("")
|
||||
for r in by_overall:
|
||||
lines.append(f"### {r.vendor} — {r.overall.value}")
|
||||
for m in r.mitigations:
|
||||
lines.append(f"- {m}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------- Sample data ----------
|
||||
|
||||
SAMPLE_VENDORS: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": "Okta",
|
||||
"category": "identity",
|
||||
"annual_spend": 180_000,
|
||||
"criticality": "tier-1",
|
||||
"data_access": ["PII", "employee-records"],
|
||||
"security_certs": ["SOC2-Type-II", "ISO27001", "FedRAMP", "GDPR-DPA"],
|
||||
"break_glass_plan": True,
|
||||
},
|
||||
{
|
||||
"name": "Snowflake",
|
||||
"category": "data-warehouse",
|
||||
"annual_spend": 420_000,
|
||||
"criticality": "tier-1",
|
||||
"data_access": ["PII", "PHI", "financial-records"],
|
||||
"security_certs": ["SOC2-Type-II", "ISO27001", "HIPAA", "GDPR-DPA"],
|
||||
"break_glass_plan": False,
|
||||
},
|
||||
{
|
||||
"name": "LegacyCRM",
|
||||
"category": "crm",
|
||||
"annual_spend": 95_000,
|
||||
"criticality": "tier-2",
|
||||
"data_access": ["PII", "customer-emails"],
|
||||
"security_certs": ["SOC2"],
|
||||
"break_glass_plan": False,
|
||||
},
|
||||
{
|
||||
"name": "ChartingTool",
|
||||
"category": "analytics",
|
||||
"annual_spend": 8_000,
|
||||
"criticality": "tier-3",
|
||||
"data_access": ["logs-only"],
|
||||
"security_certs": ["SOC2", "GDPR-DPA"],
|
||||
"break_glass_plan": True,
|
||||
},
|
||||
{
|
||||
"name": "BoutiqueQA",
|
||||
"category": "qa-services",
|
||||
"annual_spend": 220_000,
|
||||
"criticality": "tier-3",
|
||||
"data_access": ["source-code", "PII"],
|
||||
"security_certs": [],
|
||||
"break_glass_plan": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------- CLI ----------
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Classify vendor risk across 4 vectors with industry profile tuning."
|
||||
)
|
||||
parser.add_argument("--input", type=Path, help="Path to JSON vendor catalog.")
|
||||
parser.add_argument(
|
||||
"--profile",
|
||||
choices=["saas", "fintech", "healthcare", "enterprise"],
|
||||
default="saas",
|
||||
help="Industry profile for regulatory weighting (default: saas).",
|
||||
)
|
||||
parser.add_argument("--output", type=Path, help="Path to write markdown risk matrix.")
|
||||
parser.add_argument(
|
||||
"--sample", action="store_true", help="Run against built-in 5-vendor sample."
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not args.sample and not args.input:
|
||||
parser.error("provide --input or --sample")
|
||||
|
||||
if args.sample:
|
||||
catalog = SAMPLE_VENDORS
|
||||
else:
|
||||
try:
|
||||
catalog = json.loads(args.input.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"error reading {args.input}: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
if not isinstance(catalog, list):
|
||||
print("input JSON must be a list of vendor objects", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
results = [classify_vendor(v, args.profile) for v in catalog]
|
||||
md = render_markdown(results, args.profile)
|
||||
|
||||
if args.output:
|
||||
args.output.write_text(md, encoding="utf-8")
|
||||
print(f"wrote {args.output}")
|
||||
else:
|
||||
print(md)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,431 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
vendor_scorer.py — Multi-dimensional 0-100 vendor scoring with industry profile tuning.
|
||||
|
||||
Scores each vendor across 5 weighted dimensions:
|
||||
1. Reliability — uptime % + incident count
|
||||
2. Support — P90 ticket response hours
|
||||
3. Security — security certifications coverage
|
||||
4. Commercial — renewal flexibility
|
||||
5. Strategic fit — criticality vs annual spend
|
||||
|
||||
Industry profiles ({saas,fintech,healthcare,enterprise}) re-weight the dimensions.
|
||||
Output: ranked markdown scorecard with per-dimension breakdown + verdict (KEEP/REVIEW/REPLACE).
|
||||
|
||||
Stdlib only. Deterministic. No LLM calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ---------- Industry profile weights ----------
|
||||
|
||||
PROFILES: dict[str, dict[str, float]] = {
|
||||
"saas": {
|
||||
"reliability": 0.30,
|
||||
"support": 0.15,
|
||||
"security": 0.25,
|
||||
"commercial": 0.15,
|
||||
"strategic_fit": 0.15,
|
||||
},
|
||||
"fintech": {
|
||||
"reliability": 0.25,
|
||||
"support": 0.15,
|
||||
"security": 0.30,
|
||||
"commercial": 0.15,
|
||||
"strategic_fit": 0.15,
|
||||
},
|
||||
"healthcare": {
|
||||
"reliability": 0.25,
|
||||
"support": 0.15,
|
||||
"security": 0.35,
|
||||
"commercial": 0.10,
|
||||
"strategic_fit": 0.15,
|
||||
},
|
||||
"enterprise": {
|
||||
"reliability": 0.25,
|
||||
"support": 0.20,
|
||||
"security": 0.25,
|
||||
"commercial": 0.15,
|
||||
"strategic_fit": 0.15,
|
||||
},
|
||||
}
|
||||
|
||||
CERT_VALUE: dict[str, int] = {
|
||||
"SOC2": 15,
|
||||
"SOC2-Type-II": 25,
|
||||
"ISO27001": 20,
|
||||
"HIPAA": 15,
|
||||
"PCI-DSS": 15,
|
||||
"FedRAMP": 20,
|
||||
"GDPR-DPA": 10,
|
||||
"CCPA": 5,
|
||||
}
|
||||
|
||||
RENEWAL_SCORE: dict[str, int] = {
|
||||
"manual-renew": 100,
|
||||
"fixed-term": 80,
|
||||
"evergreen": 50,
|
||||
"auto-renew": 35,
|
||||
}
|
||||
|
||||
|
||||
# ---------- Data shape ----------
|
||||
|
||||
|
||||
class Verdict(str, Enum):
|
||||
KEEP = "KEEP"
|
||||
REVIEW = "REVIEW"
|
||||
REPLACE = "REPLACE"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DimensionBreakdown:
|
||||
reliability: float
|
||||
support: float
|
||||
security: float
|
||||
commercial: float
|
||||
strategic_fit: float
|
||||
|
||||
def as_dict(self) -> dict[str, float]:
|
||||
return {
|
||||
"reliability": self.reliability,
|
||||
"support": self.support,
|
||||
"security": self.security,
|
||||
"commercial": self.commercial,
|
||||
"strategic_fit": self.strategic_fit,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScoredVendor:
|
||||
name: str
|
||||
category: str
|
||||
annual_spend: float
|
||||
criticality: str
|
||||
overall: float
|
||||
verdict: Verdict
|
||||
dimensions: DimensionBreakdown
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------- Per-dimension scoring (deterministic) ----------
|
||||
|
||||
|
||||
def score_reliability(uptime_pct: float, incident_count: int) -> float:
|
||||
"""Reliability = uptime_pct mapped to 0-100, penalized by incidents.
|
||||
|
||||
99.95 uptime -> 95 points base. Each incident over 1 in the last 12m subtracts 5.
|
||||
"""
|
||||
base = max(0.0, min(100.0, (uptime_pct - 95.0) * 20.0)) # 95.0 -> 0, 100.0 -> 100
|
||||
penalty = max(0, incident_count - 1) * 5
|
||||
return max(0.0, min(100.0, base - penalty))
|
||||
|
||||
|
||||
def score_support(p90_hours: float) -> float:
|
||||
"""Support = P90 ticket response hours mapped to 0-100.
|
||||
|
||||
< 1h -> 100. 24h -> 50. 72h -> 0.
|
||||
"""
|
||||
if p90_hours <= 1.0:
|
||||
return 100.0
|
||||
if p90_hours >= 72.0:
|
||||
return 0.0
|
||||
# Linear from (1, 100) to (72, 0)
|
||||
return max(0.0, min(100.0, 100.0 - ((p90_hours - 1.0) * 100.0 / 71.0)))
|
||||
|
||||
|
||||
def score_security(certs: list[str], profile: str) -> float:
|
||||
"""Security = sum of cert values, capped at 100. Healthcare/fintech demand more."""
|
||||
total = sum(CERT_VALUE.get(c, 0) for c in certs)
|
||||
# In healthcare and fintech, raw cert score is harder to max out
|
||||
if profile in {"healthcare", "fintech"}:
|
||||
total = total * 0.85
|
||||
return max(0.0, min(100.0, float(total)))
|
||||
|
||||
|
||||
def score_commercial(renewal_terms: str) -> float:
|
||||
"""Commercial = renewal flexibility. Manual renew > fixed-term > evergreen > auto-renew."""
|
||||
return float(RENEWAL_SCORE.get(renewal_terms, 50))
|
||||
|
||||
|
||||
def score_strategic_fit(criticality: str, annual_spend: float) -> float:
|
||||
"""Strategic fit = criticality vs spend alignment.
|
||||
|
||||
Tier-1 paying < $50k -> 100 (high value, low cost).
|
||||
Tier-3 paying > $100k -> 20 (low value, high cost — kill candidate).
|
||||
"""
|
||||
if criticality == "tier-1":
|
||||
if annual_spend < 50_000:
|
||||
return 100.0
|
||||
if annual_spend < 250_000:
|
||||
return 80.0
|
||||
return 60.0
|
||||
if criticality == "tier-2":
|
||||
if annual_spend < 25_000:
|
||||
return 90.0
|
||||
if annual_spend < 100_000:
|
||||
return 70.0
|
||||
return 50.0
|
||||
# tier-3
|
||||
if annual_spend < 10_000:
|
||||
return 70.0
|
||||
if annual_spend < 50_000:
|
||||
return 50.0
|
||||
return 20.0
|
||||
|
||||
|
||||
def verdict_for(overall: float) -> Verdict:
|
||||
if overall >= 75:
|
||||
return Verdict.KEEP
|
||||
if overall >= 50:
|
||||
return Verdict.REVIEW
|
||||
return Verdict.REPLACE
|
||||
|
||||
|
||||
def score_vendor(vendor: dict[str, Any], profile: str) -> ScoredVendor:
|
||||
weights = PROFILES[profile]
|
||||
rel = score_reliability(
|
||||
float(vendor.get("uptime_pct", 0.0)),
|
||||
int(vendor.get("incident_count_last_12m", 0)),
|
||||
)
|
||||
sup = score_support(float(vendor.get("support_response_hours_p90", 999.0)))
|
||||
sec = score_security(list(vendor.get("security_certs", [])), profile)
|
||||
com = score_commercial(str(vendor.get("renewal_terms", "auto-renew")))
|
||||
fit = score_strategic_fit(
|
||||
str(vendor.get("criticality", "tier-3")),
|
||||
float(vendor.get("annual_spend", 0.0)),
|
||||
)
|
||||
|
||||
overall = (
|
||||
rel * weights["reliability"]
|
||||
+ sup * weights["support"]
|
||||
+ sec * weights["security"]
|
||||
+ com * weights["commercial"]
|
||||
+ fit * weights["strategic_fit"]
|
||||
)
|
||||
|
||||
notes: list[str] = []
|
||||
if vendor.get("criticality") == "tier-1" and not vendor.get("security_certs"):
|
||||
notes.append("Tier-1 with no security certs — require SOC2-Type-II at renewal.")
|
||||
if vendor.get("renewal_terms") == "auto-renew" and float(vendor.get("annual_spend", 0)) > 50_000:
|
||||
notes.append("Auto-renew on $50k+ contract — renegotiate to manual-renew.")
|
||||
if int(vendor.get("incident_count_last_12m", 0)) >= 5:
|
||||
notes.append(
|
||||
f"{vendor['incident_count_last_12m']} incidents in 12m — request RCA + remediation plan."
|
||||
)
|
||||
|
||||
return ScoredVendor(
|
||||
name=str(vendor.get("name", "Unknown")),
|
||||
category=str(vendor.get("category", "uncategorized")),
|
||||
annual_spend=float(vendor.get("annual_spend", 0.0)),
|
||||
criticality=str(vendor.get("criticality", "tier-3")),
|
||||
overall=round(overall, 1),
|
||||
verdict=verdict_for(overall),
|
||||
dimensions=DimensionBreakdown(
|
||||
reliability=round(rel, 1),
|
||||
support=round(sup, 1),
|
||||
security=round(sec, 1),
|
||||
commercial=round(com, 1),
|
||||
strategic_fit=round(fit, 1),
|
||||
),
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
# ---------- Markdown rendering ----------
|
||||
|
||||
|
||||
def render_markdown(scored: list[ScoredVendor], profile: str) -> str:
|
||||
scored_sorted = sorted(scored, key=lambda s: s.overall, reverse=True)
|
||||
weights = PROFILES[profile]
|
||||
lines: list[str] = []
|
||||
lines.append(f"# Vendor Scorecard — `{profile}` profile")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"Profile weights: reliability **{int(weights['reliability'] * 100)}%** · "
|
||||
f"support **{int(weights['support'] * 100)}%** · "
|
||||
f"security **{int(weights['security'] * 100)}%** · "
|
||||
f"commercial **{int(weights['commercial'] * 100)}%** · "
|
||||
f"strategic fit **{int(weights['strategic_fit'] * 100)}%**"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("## Ranked Scorecard")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"| Rank | Vendor | Category | Tier | Annual Spend | Overall | Verdict |"
|
||||
)
|
||||
lines.append("|---|---|---|---|---|---|---|")
|
||||
for i, sv in enumerate(scored_sorted, start=1):
|
||||
lines.append(
|
||||
f"| {i} | {sv.name} | {sv.category} | {sv.criticality} | "
|
||||
f"${sv.annual_spend:,.0f} | **{sv.overall}** | {sv.verdict.value} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Per-Dimension Breakdown")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"| Vendor | Reliability | Support | Security | Commercial | Strategic Fit |"
|
||||
)
|
||||
lines.append("|---|---|---|---|---|---|")
|
||||
for sv in scored_sorted:
|
||||
d = sv.dimensions
|
||||
lines.append(
|
||||
f"| {sv.name} | {d.reliability} | {d.support} | {d.security} | "
|
||||
f"{d.commercial} | {d.strategic_fit} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Verdict Summary")
|
||||
lines.append("")
|
||||
keep = [s for s in scored_sorted if s.verdict == Verdict.KEEP]
|
||||
review = [s for s in scored_sorted if s.verdict == Verdict.REVIEW]
|
||||
replace = [s for s in scored_sorted if s.verdict == Verdict.REPLACE]
|
||||
lines.append(f"- **KEEP ({len(keep)}):** " + (", ".join(s.name for s in keep) or "_none_"))
|
||||
lines.append(
|
||||
f"- **REVIEW ({len(review)}):** " + (", ".join(s.name for s in review) or "_none_")
|
||||
)
|
||||
lines.append(
|
||||
f"- **REPLACE ({len(replace)}):** " + (", ".join(s.name for s in replace) or "_none_")
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
flagged = [s for s in scored_sorted if s.notes]
|
||||
if flagged:
|
||||
lines.append("## Action Notes")
|
||||
lines.append("")
|
||||
for sv in flagged:
|
||||
lines.append(f"### {sv.name} ({sv.verdict.value} · {sv.overall})")
|
||||
for n in sv.notes:
|
||||
lines.append(f"- {n}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------- Sample data ----------
|
||||
|
||||
|
||||
SAMPLE_CATALOG: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": "Okta",
|
||||
"category": "identity",
|
||||
"annual_spend": 180_000,
|
||||
"contract_end_date": "2026-09-30",
|
||||
"criticality": "tier-1",
|
||||
"uptime_pct": 99.91,
|
||||
"support_response_hours_p90": 4.5,
|
||||
"incident_count_last_12m": 3,
|
||||
"security_certs": ["SOC2-Type-II", "ISO27001", "FedRAMP", "GDPR-DPA"],
|
||||
"renewal_terms": "manual-renew",
|
||||
},
|
||||
{
|
||||
"name": "Snowflake",
|
||||
"category": "data-warehouse",
|
||||
"annual_spend": 420_000,
|
||||
"contract_end_date": "2027-01-15",
|
||||
"criticality": "tier-1",
|
||||
"uptime_pct": 99.97,
|
||||
"support_response_hours_p90": 2.0,
|
||||
"incident_count_last_12m": 1,
|
||||
"security_certs": ["SOC2-Type-II", "ISO27001", "HIPAA", "GDPR-DPA"],
|
||||
"renewal_terms": "fixed-term",
|
||||
},
|
||||
{
|
||||
"name": "LegacyCRM",
|
||||
"category": "crm",
|
||||
"annual_spend": 95_000,
|
||||
"contract_end_date": "2026-06-30",
|
||||
"criticality": "tier-2",
|
||||
"uptime_pct": 98.20,
|
||||
"support_response_hours_p90": 38.0,
|
||||
"incident_count_last_12m": 11,
|
||||
"security_certs": ["SOC2"],
|
||||
"renewal_terms": "auto-renew",
|
||||
},
|
||||
{
|
||||
"name": "ChartingTool",
|
||||
"category": "analytics",
|
||||
"annual_spend": 8_000,
|
||||
"contract_end_date": "2026-08-01",
|
||||
"criticality": "tier-3",
|
||||
"uptime_pct": 99.50,
|
||||
"support_response_hours_p90": 14.0,
|
||||
"incident_count_last_12m": 2,
|
||||
"security_certs": ["SOC2", "GDPR-DPA"],
|
||||
"renewal_terms": "evergreen",
|
||||
},
|
||||
{
|
||||
"name": "BoutiqueQA",
|
||||
"category": "qa-services",
|
||||
"annual_spend": 220_000,
|
||||
"contract_end_date": "2026-12-31",
|
||||
"criticality": "tier-3",
|
||||
"uptime_pct": 99.00,
|
||||
"support_response_hours_p90": 22.0,
|
||||
"incident_count_last_12m": 6,
|
||||
"security_certs": [],
|
||||
"renewal_terms": "auto-renew",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------- CLI ----------
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Score vendors 0-100 across 5 dimensions with industry profile tuning."
|
||||
)
|
||||
parser.add_argument("--input", type=Path, help="Path to JSON vendor catalog.")
|
||||
parser.add_argument(
|
||||
"--profile",
|
||||
choices=sorted(PROFILES.keys()),
|
||||
default="saas",
|
||||
help="Industry profile to use for dimension weighting (default: saas).",
|
||||
)
|
||||
parser.add_argument("--output", type=Path, help="Path to write markdown scorecard.")
|
||||
parser.add_argument(
|
||||
"--sample",
|
||||
action="store_true",
|
||||
help="Run against built-in 5-vendor sample catalog.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not args.sample and not args.input:
|
||||
parser.error("provide --input or --sample")
|
||||
|
||||
if args.sample:
|
||||
catalog = SAMPLE_CATALOG
|
||||
else:
|
||||
try:
|
||||
catalog = json.loads(args.input.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"error reading {args.input}: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
if not isinstance(catalog, list):
|
||||
print("input JSON must be a list of vendor objects", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
scored = [score_vendor(v, args.profile) for v in catalog]
|
||||
md = render_markdown(scored, args.profile)
|
||||
|
||||
if args.output:
|
||||
args.output.write_text(md, encoding="utf-8")
|
||||
print(f"wrote {args.output}")
|
||||
else:
|
||||
print(md)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user