The GTM operator mod for Claude Code: scores every go-to-market draft with its own skill pack's scorer, flags stale and inconsistent drafts across packs, pack…

<img src="./assets/lockup.png" width="880" alt="GTM operator mod for Claude Code. Scores every go-to-market draft with its own skill pack's scorer, catches drafts that drift apart across the ten packs, and runs the next pack for you.">
The GTM operator mod is a Claude Code mod that scores every go-to-market draft with its own skill pack's scorer, names the next pack to run, and keeps Claude from overwriting the brand config every pack shares.
/plugin marketplace add cmj-hub/gtm-operator-skills
/plugin install gtm-operator@gtm-operator-skills
/gtm-board
The band above your prompt now reads GTM 2/11 Next: /evp:evp — no value line yet. Press Use and the command is in your prompt.
Part of the GTM operator suite — /plugin install gtm@gtm-operator-skills installs the ten skill packs this mod scores.
/gtm:nexttells you the next step when you ask. The mod tells you before you ask, and stops the write that would have cost you your ICP.
<img src="./assets/screens/hero.png" alt="Claude Code in a terminal with the GTM operator mod: Claude answers which drafts need work from the mod's scores and cross-pack checks, the GTM board pane lists all eleven steps with each pack's score, and the band above the prompt reads GTM 10/11, 1 to fix, 4 warnings, next /sales-offer:cold-offer." width="100%">
| Where | What you see |
|---|---|
| Band above the prompt | GTM 3/11 1 to fix 2 warnings Next: /prospect-list:who-to-contact [Use] [Run] [Board] [Hide]. Use fills your prompt; Run runs the pack. Other mods' band rows stay above it. |
| Status line | GTM 3/11 · 1 draft to fix · next /prospect-list:who-to-contact |
/gtm-board, Board tab | Every step with its score from its pack's own scorer (37/100 · 11 fixes, pass, fail · 3 fixes) and a score trend. Press a step to drill in. |
/gtm-board, Detail tab | The step's score breakdown, each fix, any SOUL.md phrases it uses, the draft text, and Fix with Claude (puts the pack command and the fixes in your prompt). |
/gtm-board, Health tab | Cross-pack warnings: drafts written before the PSP, EVP or price changed; a landing page that doesn't lead with the EVP or name the price tiers; a letter or sequence with none of the buyer's words; roles from icp.role_targets the prospect list leaves out. Each has Fix with Claude. |
/gtm-board, Views tab | Six pack views: Pricing (pocket-price waterfall and tier contrast check), Prospects (call / hold / drop), Cold email (lint and subject scores, send rhythm, reply triage, deliverability), EVP (awareness ladder), GEO (kill-date countdown, citations by engine, blocked crawlers), Founder (posts per pillar, 7-day warning). |
/gtm-board, Analytics tab | Score trend per pack, how many drafts it took to pass and how long, fixes resolved; an outcome log (replies and meetings each week, tied to the cold email that was live; or import them from a CSV); Copy weekly digest. |
/gtm-score [pack], /gtm-health, /gtm-digest | The scores, the cross-pack warnings and the weekly digest as text, for the VS Code extension, claude -p and cloud sessions. |
/gtm-outcomes [import <file.csv>] | Replies and meetings per cold email version. import reads a CSV from your project (a date column plus replies and/or meetings, as a CRM export or a spreadsheet gives it); importing the same dates again replaces them. |
/gtm-sprint [to <step>] | Runs the packs in order. Each pack runs only after the one before passes its scorer; a failing draft pauses the sprint until you fix it and /gtm-sprint resume. /gtm-sprint stop ends it. The Sprint button on the board starts one too. |
| Tools Claude can call | gtm_status, gtm_score (one pack, now) and gtm_consistency, so Claude checks its own draft before saying it's done. |
gtm-operator:reviewer | A read-only subagent that reviews one draft against the PSP, the EVP, SOUL.md and its scorer, and returns at most five changes. |
| Toast | When you save a draft: GTM First touch: fail · 3 fixes · quote at least 3 words of the signal…. When a step completes: GTM: Value line (EVP) done. Next: … |
| Claude's system prompt | Each turn Claude reads what is done, what is next, the merge rules, and which drafts fail their scorer with the first fix. |
It re-checks every 30 seconds and after any write or shell command, and re-scores a draft only when it changes. Score history is kept per project across sessions. Hide is remembered across sessions; Show band in the pane brings it back.
All of these are captured from Claude Code 2.1.289 in a terminal, running this mod on a sample project built from the ten packs' own example files. Nothing is mocked up.
The score gate. With minScore at 80, Claude's edit to a founder post is refused with the axes to raise. Claude fixes the opener and the edit goes through.
<img src="./assets/screens/gate.png" alt="An Edit to a founder post refused by gtm-operator: the draft scores 79 with claude-founder-brand's scorer, below the minimum of 80, with fixes to raise the hook axis and the anti-patterns axis. Claude then removes the 'Stop. Read this.' opener." width="100%">
The band above the prompt and the status line.
<img src="./assets/screens/band.png" alt="The band reads GTM 10/11, 1 to fix, 4 warnings, Next: /sales-offer:cold-offer, no give-first offer yet, with Use, Run, Board and Hide buttons; the status line repeats it." width="100%">
<table> <tr> <td width="50%"><b>Board.</b> Every step with its own pack's score and trend.<br><img src="./assets/screens/board.png" alt="The Board tab: ten steps with scores such as Profile 100/100, Findability fail with 16 fixes, Founder posts 100/100 with a trend, and Fill next, Run next and Sprint buttons."></td> <td width="50%"><b>Detail.</b> A failing draft's fixes, its fields, and Fix with Claude.<br><img src="./assets/screens/detail-geo.png" alt="The Detail tab for Findability (GEO): fail with 16 fixes listed, the draft's fields, and Fix with Claude and Rescore buttons."></td> </tr> <tr> <td><b>Health.</b> What no single pack can see.<br><img src="./assets/screens/health.png" alt="The Health tab: four cross-pack warnings, such as price tiers missing from the landing page and a first touch with none of the buyer's words, each with Fix with Claude."></td> <td><b>Analytics.</b> Trends, drafts to pass, outcomes.<br><img src="./assets/screens/analytics.png" alt="The Analytics tab: score history per pack, the founder post going from 37 to 100 after two failing drafts, nine fixes resolved, and replies and meetings imported from a CSV."></td> </tr> <tr> <td><b>Pricing view.</b> Pocket-price waterfall and tier check.<br><img src="./assets/screens/view-pricing.png" alt="The Pricing view: pocket-price waterfall bars per customer with the biggest leaks, and the tier contrast check at 100 out of 100."></td> <td><b>Cold email view.</b> Lint, subject, rhythm, replies.<br><img src="./assets/screens/view-cold-email.png" alt="The Cold email view: first touch word count and subject, spam lint 100 out of 100, subject line 87 out of 100, the Monday, Wednesday, Friday rhythm, reply triage counts and a deliverability button."></td> </tr> <tr> <td><b>EVP view.</b> The awareness ladder.<br><img src="./assets/screens/view-evp.png" alt="The EVP view: five Schwartz awareness tiers with the chosen tier and the outreach line marked."></td> <td><b>GEO view.</b> Kill date, citations by engine, blocked crawlers.<br><img src="./assets/screens/view-geo.png" alt="The GEO view: the buyer question, kill date, indexability, blocked crawlers and citations by engine."></td> </tr> <tr> <td><b>Prospects view.</b> Call this week, hold, drop.<br><img src="./assets/screens/view-prospects.png" alt="The Prospects view: one prospect in the call-this-week column with its signal."></td> <td><b>Founder view.</b> Posts per pillar, with a 7-day warning.<br><img src="./assets/screens/view-founder.png" alt="The Founder view: the last post date, and posts per pillar: pillar, proof, process and person."></td> </tr> </table>
Each pack ships a scorer: standard-library Python that prints JSON. The mod finds the installed pack, runs its scorer on your draft (brand-config.json for the PSP and EVP, gtm/<pack>.json for most packs, the newest file in drafts/ for founder posts), and shows the result. It never re-implements a pack's rubric.
It looks for the packs in this order: the packsDir setting, the Claude Code plugin cache (~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/, newest version first), then the skills folders (.claude/skills/ in the project, ~/.claude/skills/, ~/.agents/skills/). A pack it cannot find shows unknown with its install line; nothing breaks.
Set these in /config (or pluginConfigs["gtm-operator"].options in settings):
| Setting | Default | What it does |
|---|---|---|
runScorers | on | Score drafts with the packs' scorers. Off: progress only, nothing runs. |
minScore | 0 (off) | Refuse a Write or Edit to a gtm/ or drafts/ draft that scores below this, with the fixes in the refusal. |
packsDir | empty | A folder holding the pack repos side by side; searched first. |
python | python3 | The command the scorers run with. |
The checks are heuristics, so they warn and never block. A draft counts as stale when the psp, evp or pricing block in brand-config.json (or gtm/price.json) changed after the draft was saved; the mod notices changes by hashing those blocks, so the first time it sees a project nothing is stale.
The views run the packs' own tools: pocket_price_waterfall.py on gtm/waterfall.csv, decoy_validator.py on gtm/tiers.json, spam_word_lint.py and score_subject_line.py on gtm/letter.json, score_reply.py on gtm/replies.jsonl. The deliverability check (check_deliverability.py, DNS lookups with dig) runs only when you press its button.
/gtm-sprint puts the first pack's command in your prompt; send it. After each turn the mod scores that pack's draft. A pass runs the next pack by itself; a fail pauses with the reason. Claude Code doesn't let a slash command start a turn by itself, which is why the first step (and resume) go through your prompt. The Sprint button starts straight away.
Bullets under ## Phrases I refuse in your SOUL.md are flagged in any draft, in every pack, on the board, in the save toast, and in what Claude reads.
brand-config.json and SOUL.md are shared by every pack. The mod refuses a Write or Edit that would:
brand-config.json (adding fields is fine, so /gtm:setup still works),brand-config.json as anything but one JSON object,## section from SOUL.md.The refusal names the fields and tells Claude to ask you first. The optional score gate (minScore) works the same way for drafts. If the check itself fails, the write to those two files is refused rather than let through. Once you approve a change, run /gtm-guard off; /gtm-guard on restores it.
Same order and checks as /gtm:next:
| Step | Done when | Next command |
|---|---|---|
| 0 Setup | operator.name and icp.segment filled | /gtm:setup |
| 1 Profile | psp.primary_pain and psp.signal_anchors filled | /psp:psp |
| 2 Value line | evp.primary filled | /evp:evp |
| 3 Prospect list | gtm/list.json | /prospect-list:who-to-contact |
| 4 First touch | gtm/letter.json, or tone + infrastructure | /cold-email:cold-email |
| 5 Offer | gtm/offer.json | /sales-offer:cold-offer |
| 6 Pricing | pricing.currentTiers or gtm/price.json | /pricing:pricing |
| 7 Landing page | gtm/page.json | /landing-page:page |
| 8 Email sequence | gtm/sequence.json | /email-sequence:lifecycle-email |
| 9 Findability | gtm/findability.json | /geo:geo |
| 10 Founder posts | a file in drafts/ from the last 7 days | /founder-brand:founder-brand |
From the suite marketplace (above), or from this repo's own marketplace:
/plugin marketplace add cmj-hub/gtm-operator-claude-mod
/plugin install gtm-operator@gtm-operator-claude-mod
From a clone, for one session:
claude --plugin-dir /path/to/gtm-operator-claude-mod
Claude Code v2.1.287 or later (the first release with mods). Tested with Claude Code 2.1.289. The mods API can change between releases; if something stops drawing, run claude plugin validate on this folder and check the debug log.
Panes and the band draw in the terminal and the Desktop app's Code tab. In the VS Code extension, claude -p and cloud sessions the hooks still run: the guard works and /gtm-board answers in text.
It will not pick this quarter's buyer or write a draft itself. It reads the files the packs write, runs the packs' own scorers on them, and tells you what is missing or weak; Run and Sprint start a pack, and Claude writes the draft the way that pack says. The mod sends nothing, posts nothing, and changes no file in your project.
A mod is a Claude Code plugin whose code runs inside Claude Code: it can draw a pane or a band above the prompt, add commands, and step into a tool call before it runs. A skill gives Claude instructions; a mod changes what Claude Code shows and allows. See Mods overview.
No, but the board is empty without them. The mod tracks the files the packs write. With none installed it names /gtm:setup and the install line.
/gtm:next?/gtm:next is a skill: you ask, Claude reads your files, and names one command. The mod keeps that answer on screen without a turn, updates it when a file changes, and adds the guard on brand-config.json and SOUL.md.
Only edits that drop or change a value you already have. New fields go through. When you want to change a filled value, say so, run /gtm-guard off, and Claude retries.
Mods run in Claude Code only. In the VS Code extension the guard and /gtm-board work, but nothing draws. The ten skill packs work in Cursor, Codex and the rest through the skills CLI.
What claude plugin validate . reports, so you can review it before installing:
session.start, classic.SessionStart (after /clear, /resume, /branch), command.run (its seven commands), tool.call (Write and Edit to guard and gate, every call to refresh and score, and its own three tools), turn.complete (moves a sprint on), prompt.compose, ui.render (the band and its own pane).$.fs.read, $.fs.list, $.fs.exists, $.fs.stat (your project's brand-config.json, SOUL.md, gtm/, drafts/, a CSV you name to /gtm-outcomes import, and the folders where packs install); $.process.run (only python3 <pack script> ... --json: the installed packs' own scorers and the view tools listed above, 20-second timeout; off with runScorers. The deliverability check makes DNS lookups through dig, and only when you press its button); $.env.get (HOME only, to find the plugin cache); $.store.get / $.store.set (isBandHidden, and per project the score history, the outcome log, and the hashes of the PSP, EVP and pricing blocks); $.tool.register (its three tools) and $.agent.register (the reviewer); $.command.run (only the pack commands you start with Run, Sprint or a sprint step); $.prompt.fill; $.ui.copy (the digest, when you press its button); and display calls.$.fs.write, $.http.fetch, $.env.set, $.model, $.prompt.submit.Growth Audit — where your go-to-market stack is leaking, sent to your inbox.
That one does ask for an email, and it enrols you in a short follow-up on the same topic. Unsubscribe whenever.
The Friday Signal — one free edition a week on building GTM systems that compound. No pitch in it.
claude plugin validate .
claude plugin test .
tsc -p . # after Claude Code has loaded the mod once (it writes .claude-plugin/types/)
The eval suite in evals/ runs Claude with and without the mod on a sample project built from the packs' own examples. It calls the model, so it runs on demand (the evals workflow, with an ANTHROPIC_API_KEY secret) or locally, with the ten pack repos cloned next to this one:
claude plugin eval . --scaffold --trust-plugin \
--allow-tools Write Edit mcp__gtm-operator__gtm_status mcp__gtm-operator__gtm_score
| Case | Checks | With the mod | Without |
|---|---|---|---|
guard-keeps-config | Asked to overwrite brand-config.json with a stub, the ICP and PSP survive | 1.00 | 0.00 |
which-drafts-fail | Names the drafts that fail their pack's scorer, with the score | 1.00 | 0.75 |
score-before-done | Scores a new founder post with gtm_score after writing it, before calling it ready | 1.00 (scored it every run) | not applicable |
Measured 2026-10-05, two runs per arm.
hooks/register.tsx holds the hooks; hooks/gtm.ts the step walk and merge checks; hooks/packs.ts the ten packs' scorers and drafts; hooks/score.ts turns each scorer's JSON into one shape; hooks/locate.ts says where packs install; hooks/voice.ts and hooks/history.ts the voice check and score history; hooks/drift.ts the cross-pack checks; hooks/views.ts the pack views' data; hooks/analytics.ts the stats, digest, outcomes and sprint steps; types/index.d.ts the $.state contract. Tests are in tests/, with the scorers' real output recorded in tests/fixtures.ts. docs/ROADMAP.md is the plan through v0.6. evals/ holds the claude plugin eval suite (see below). The images in assets/ are rendered from spec.json (with card.mjs), lockup.html, demo.html and logo.svg; the ones in assets/screens/ are real terminal captures (tmux capture-pane -e) turned into pages by assets/screens/ansi2html.py and screenshotted.
It saves whether you hid the band and, per project, the score history and the outcomes you log, in Claude Code's plugin store under ~/.claude/plugins/store/. Scoring runs the packs' own Python scorers on your machine; they read your draft and print JSON, with no network. Each turn it adds a short section to Claude's system prompt with the suite's state; that text comes from your own files. No network unless you press Check deliverability (DNS lookups with dig), no telemetry, no credentials. See SECURITY.md.
MIT. See LICENSE.
Built by Jay Mount Consulting.
hooks/register.tsx 1381 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Finding, GtmBoard, History, Outcome, PackId, Score, Scores, Tab, ToolRuns, ViewId } from '../types'
5import { addOutcome, allStats, digest, duration, importOutcomes, outcomesByVersion, outcomesKey, parseOutcomeCsv, sprintStep } from './analytics'
6import { allFindings, hashOf } from './drift'
7import type { DraftFile, Snapshot, Upstream } from './drift'
8import {
9 PACKS as INSTALL_NAMES,
10 applyEdit,
11 buildBoard,
12 doneCount,
13 field,
14 isFilled,
15 mergeViolations,
16 nextStep,
17 parseConfig,
18 soulViolations,
19 summary,
20} from './gtm'
21import { addSample, historyKey, sparkline, trend } from './history'
22import { newestFirst, scorerCandidates } from './locate'
23import { PACKS, packById, packForPath } from './packs'
24import type { PackDef, ScorerInput } from './packs'
25import { fixPrompt, gateLines, normalize, scoreLabel } from './score'
26import { draftExcerpt, draftText, refusedPhrases, voiceHits } from './voice'
27import { coldEmailView, evpLadder, founderView, geoView, leakLabel, money, pricingView, prospectBoard, shareBar } from './views'
28
29const PANE = 'gtm-board'
30const CONFIG = 'brand-config.json'
31const SOUL = 'SOUL.md'
32const SCORER_TIMEOUT_MS = 20_000
33
34const board = atom({ plugin: 'gtm-operator', key: 'board' } as const, null)
35const scores = atom({ plugin: 'gtm-operator', key: 'scores' } as const, {})
36const history = atom({ plugin: 'gtm-operator', key: 'history' } as const, {})
37const tab = atom({ plugin: 'gtm-operator', key: 'tab' } as const, 'board')
38const selected = atom({ plugin: 'gtm-operator', key: 'selected' } as const, 'psp')
39const isBandHidden = atom({ plugin: 'gtm-operator', key: 'isBandHidden' } as const, false)
40const isGuardOff = atom({ plugin: 'gtm-operator', key: 'isGuardOff' } as const, false)
41const findings = atom({ plugin: 'gtm-operator', key: 'findings' } as const, [])
42const view = atom({ plugin: 'gtm-operator', key: 'view' } as const, 'pricing')
43const toolRuns = atom({ plugin: 'gtm-operator', key: 'toolRuns' } as const, {})
44const sprint = atom({ plugin: 'gtm-operator', key: 'sprint' } as const, null)
45const outcomes = atom({ plugin: 'gtm-operator', key: 'outcomes' } as const, [])
46
47const TOOL_PREFIX = 'mcp__gtm-operator__'
48
49type $ = EngineInterface
50
51type Settings = { runScorers: boolean; minScore: number; packsDir: string; python: string }
52
53function settingsFrom(options: PluginOptions): Settings {
54 return {
55 runScorers: options.runScorers !== false,
56 minScore: typeof options.minScore === 'number' ? Math.max(0, Math.min(100, options.minScore)) : 0,
57 packsDir: typeof options.packsDir === 'string' ? options.packsDir.replace(/\/$/, '') : '',
58 python: typeof options.python === 'string' && options.python !== '' ? options.python : 'python3',
59 }
60}
61
62// Module caches: rebuilt after a reload, which is cheap.
63const scorerPaths = new Map<PackId, string | null>()
64const scoredKeys = new Map<PackId, string>()
65
66// The project-relative name of a path, or undefined when it lies outside the root.
67function relative(path: string, root: string): string | undefined {
68 const clean = path.replace(/\\/g, '/').replace(/^\.\//, '')
69 const base = root.replace(/\\/g, '/').replace(/\/$/, '')
70 if (clean.startsWith(`${base}/`)) return clean.slice(base.length + 1)
71 return clean.startsWith('/') ? undefined : clean
72}
73
74function isTracked(rel: string | undefined): boolean {
75 return rel === CONFIG || rel === SOUL || rel?.startsWith('gtm/') === true ||
76 rel?.startsWith('drafts/') === true
77}
78
79const cut = (text: string, room: number) =>
80 text.length <= room ? text : `${text.slice(0, Math.max(1, room - 1))}…`
81
82async function projectRoot($: $): Promise<string> {
83 return (await $.session.root()).replace(/[\\/]$/, '')
84}
85
86async function readText($: $, path: string): Promise<string | undefined> {
87 return (await $.fs.exists(path)) ? await $.fs.read(path) : undefined
88}
89
90async function scan($: $): Promise<GtmBoard> {
91 const root = await projectRoot($)
92 const configText = await readText($, `${root}/${CONFIG}`).catch(() => undefined)
93 const gtm = await $.fs.list(`${root}/gtm`).catch(() => undefined)
94 const drafts = await $.fs.list(`${root}/drafts`).catch(() => [])
95
96 return buildBoard({
97 config: parseConfig(configText),
98 hasConfigFile: configText !== undefined,
99 gtmFiles: (gtm ?? []).filter(one => one.kind === 'file').map(one => one.name),
100 hasGtmDir: gtm !== undefined,
101 newestDraftMs: Math.max(0, ...drafts.filter(one => one.kind === 'file').map(one => one.mtimeMs)),
102 now: await $.clock.now(),
103 })
104}
105
106// --- Finding and running the packs' scorers -------------------------------
107
108async function cacheVersions($: $, home: string | undefined): Promise<Record<string, string[]>> {
109 if (home === undefined) return {}
110 const cache = `${home}/.claude/plugins/cache`
111 const marketplaces = await $.fs.list(cache).catch(() => [])
112 const found: Record<string, string[]> = {}
113 for (const marketplace of marketplaces.filter(one => one.kind === 'dir')) {
114 for (const pack of PACKS) {
115 const dir = `${cache}/${marketplace.name}/${pack.plugin}`
116 const versions = await $.fs.list(dir).catch(() => [])
117 const names = newestFirst(versions.filter(one => one.kind === 'dir').map(one => one.name))
118 found[pack.plugin] = [...(found[pack.plugin] ?? []), ...names.map(name => `${dir}/${name}`)]
119 }
120 }
121 return found
122}
123
124async function locateScorers($: $, settings: Settings): Promise<void> {
125 const home = await $.env.get('HOME')
126 const root = await projectRoot($)
127 const versions = await cacheVersions($, home)
128 for (const pack of PACKS) {
129 let found: string | null = null
130 for (const path of scorerCandidates(pack, { home, root, packsDir: settings.packsDir, cacheVersions: versions })) {
131 if (await $.fs.exists(path).catch(() => false)) {
132 found = path
133 break
134 }
135 }
136 scorerPaths.set(pack.id, found)
137 }
138}
139
140type Draft = { input: ScorerInput; text: string; key: string }
141
142/** What the pack's scorer should read, or undefined when there is no draft yet. */
143async function readDraft($: $, root: string, pack: PackDef): Promise<Draft | undefined> {
144 const source = pack.draft
145 if (source.kind === 'file') {
146 const path = `${root}/${source.path}`
147 const stat = await $.fs.stat(path).catch(() => undefined)
148 if (stat === undefined || stat.kind !== 'file') return undefined
149 const text = (await $.fs.read(path).catch(() => '')) as string
150 return { input: { file: path }, text, key: `${stat.mtimeMs}:${stat.size}` }
151 }
152 if (source.kind === 'latest') {
153 const dir = `${root}/${source.dir}`
154 const entries = await $.fs.list(dir).catch(() => [])
155 const newest = entries
156 .filter(one => one.kind === 'file' && source.ext.some(ext => one.name.endsWith(ext)))
157 .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
158 if (newest === undefined) return undefined
159 const path = `${dir}/${newest.name}`
160 const text = (await $.fs.read(path).catch(() => '')) as string
161 return { input: { file: path }, text, key: `${newest.name}:${newest.mtimeMs}:${newest.size}` }
162 }
163 const configText = await readText($, `${root}/${CONFIG}`).catch(() => undefined)
164 const value = field(parseConfig(configText) ?? {}, source.field)
165 if (!isFilled(value)) return undefined
166 const serialized = JSON.stringify(value)
167 if (source.kind === 'config-text') {
168 return typeof value === 'string' ? { input: { text: value }, text: value, key: serialized } : undefined
169 }
170 return { input: { file: `${root}/${CONFIG}` }, text: serialized, key: serialized }
171}
172
173const installHint = (pack: PackDef) =>
174 `${pack.repo} is not installed where the mod looks. Install it with /plugin install ${pack.plugin}@gtm-operator-skills, ` +
175 'or set packsDir to the folder holding the pack repos.'
176
177/** Runs one scorer on one input. Never rejects: failures come back as unknown. */
178async function runScorer($: $, settings: Settings, pack: PackDef, input: ScorerInput): Promise<Score> {
179 const now = await $.clock.now()
180 const scorer = scorerPaths.get(pack.id)
181 if (scorer === undefined || scorer === null) {
182 return { pack: pack.id, status: 'unknown', reasons: [], fixes: [], axes: [], detail: installHint(pack), at: now }
183 }
184 const argv = [settings.python, scorer, ...pack.args(input), '--json']
185 const stdin = 'stdin' in input ? input.stdin : undefined
186 const ran = await $.process
187 .run(argv, { timeoutMs: SCORER_TIMEOUT_MS, ...(stdin !== undefined ? { stdin } : {}) })
188 .catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: String(error) }))
189 const score = normalize(pack.id, ran.stdout, ran.exitCode, now)
190 if (score.status === 'unknown' && ran.exitCode === -1) {
191 return { ...score, detail: `could not run ${settings.python}: ${ran.stderr.slice(0, 120)}` }
192 }
193 return score
194}
195
196async function soulPhrases($: $, root: string): Promise<string[]> {
197 const soul = await readText($, `${root}/${SOUL}`).catch(() => undefined)
198 return soul === undefined ? [] : refusedPhrases(soul)
199}
200
201/** Scores one pack's current draft; undefined when nothing changed since the last run. */
202async function scorePack($: $, settings: Settings, pack: PackDef, isForced: boolean): Promise<Score | undefined> {
203 const root = await projectRoot($)
204 const draft = await readDraft($, root, pack)
205 if (draft === undefined) {
206 scoredKeys.delete(pack.id)
207 return { pack: pack.id, status: 'missing', reasons: [], fixes: [], axes: [], at: await $.clock.now() }
208 }
209 const phrases = await soulPhrases($, root)
210 const key = `${draft.key}|${phrases.join('|')}|${scorerPaths.get(pack.id) ?? ''}`
211 if (!isForced && scoredKeys.get(pack.id) === key) return undefined
212 scoredKeys.set(pack.id, key)
213
214 const scored = settings.runScorers
215 ? await runScorer($, settings, pack, draft.input)
216 : { pack: pack.id, status: 'unknown' as const, reasons: [], fixes: [], axes: [], detail: 'runScorers is off', at: await $.clock.now() }
217 const voice = voiceHits(draftText(draft.text), phrases)
218 return { ...scored, ...(voice.length > 0 ? { voice } : {}), excerpt: draftExcerpt(draft.text).slice(0, 600) }
219}
220
221async function saveHistory($: $, root: string, next: History): Promise<void> {
222 await update($, history, () => next)
223 await $.store.set(historyKey(root), next)
224}
225
226/** Scores the given packs (all by default) and records what changed. */
227async function scoreAll($: $, settings: Settings, only?: readonly PackDef[], isForced = false): Promise<Score[]> {
228 if (scorerPaths.size === 0) await locateScorers($, settings)
229 const root = await projectRoot($)
230 const changed: Score[] = []
231 for (const pack of only ?? PACKS) {
232 const score = await scorePack($, settings, pack, isForced)
233 if (score !== undefined) changed.push(score)
234 }
235 if (changed.length === 0) return changed
236
237 await update($, scores, all => {
238 const next: Scores = { ...all }
239 for (const score of changed) next[score.pack] = score
240 return next
241 })
242 let next = await read($, history)
243 for (const score of changed) {
244 if (score.status === 'pass' || score.status === 'fail') {
245 next = addSample(next, score.pack, {
246 at: score.at,
247 ...(score.score !== undefined ? { score: score.score } : {}),
248 status: score.status,
249 fixes: score.fixes.length,
250 })
251 }
252 }
253 await saveHistory($, root, next)
254 return changed
255}
256
257// --- Cross-pack checks ------------------------------------------------------
258
259type BlockTimes = Partial<Record<Upstream, { hash: string; at: number }>>
260
261const UPSTREAM_FIELDS: Record<Upstream, string> = { psp: 'psp', evp: 'evp', price: 'pricing' }
262
263/** Notes when the PSP, EVP and pricing blocks change; the first sighting counts as unknown (0). */
264async function blockTimes($: $, root: string, config: Record<string, unknown>): Promise<Partial<Record<Upstream, number>>> {
265 const key = `blocks:${root}`
266 const saved = ((await $.store.get(key)) ?? {}) as BlockTimes
267 const now = await $.clock.now()
268 const next: BlockTimes = {}
269 const times: Partial<Record<Upstream, number>> = {}
270 for (const up of Object.keys(UPSTREAM_FIELDS) as Upstream[]) {
271 const hash = hashOf(field(config as never, UPSTREAM_FIELDS[up]))
272 const was = saved[up]
273 const at = was === undefined ? 0 : was.hash === hash ? was.at : now
274 next[up] = { hash, at }
275 times[up] = at
276 }
277 if (JSON.stringify(next) !== JSON.stringify(saved)) await $.store.set(key, next)
278 return times
279}
280
281async function snapshot($: $): Promise<Snapshot> {
282 const root = await projectRoot($)
283 const config = parseConfig(await readText($, `${root}/${CONFIG}`).catch(() => undefined)) ?? {}
284 const drafts: Partial<Record<PackId, DraftFile>> = {}
285 for (const pack of PACKS) {
286 if (pack.draft.kind !== 'file') continue
287 const path = `${root}/${pack.draft.path}`
288 const stat = await $.fs.stat(path).catch(() => undefined)
289 if (stat?.kind !== 'file') continue
290 drafts[pack.id] = { text: (await $.fs.read(path).catch(() => '')) as string, mtimeMs: stat.mtimeMs }
291 }
292 const changedAt = await blockTimes($, root, config)
293 const priceFile = drafts.price?.mtimeMs ?? 0
294 return { config, drafts, changedAt: { ...changedAt, price: Math.max(changedAt.price ?? 0, priceFile) } }
295}
296
297async function checkDrift($: $): Promise<Finding[]> {
298 const found = allFindings(await snapshot($))
299 await update($, findings, () => found)
300 return found
301}
302
303// --- Extra pack tools behind the views -------------------------------------
304
305/** A tool script next to a pack's scorer (same install), or undefined. */
306async function toolPath($: $, settings: Settings, pack: PackDef, rel: string): Promise<string | undefined> {
307 if (scorerPaths.size === 0) await locateScorers($, settings)
308 const scorer = scorerPaths.get(pack.id)
309 if (!scorer) return undefined
310 const fileName = rel.slice(rel.lastIndexOf('/') + 1)
311 const candidates = scorer.endsWith(pack.scorer)
312 ? [`${scorer.slice(0, -pack.scorer.length)}${rel}`]
313 : [`${scorer.slice(0, scorer.lastIndexOf('/'))}/${fileName}`]
314 for (const path of candidates) if (await $.fs.exists(path).catch(() => false)) return path
315 return undefined
316}
317
318async function runTool($: $, settings: Settings, name: string, packId: PackId, rel: string, args: string[]): Promise<void> {
319 const pack = packById(packId)
320 if (pack === undefined || !settings.runScorers) return
321 const path = await toolPath($, settings, pack, rel)
322 const at = await $.clock.now()
323 const ran = path === undefined
324 ? { exitCode: -1, stdout: '', stderr: `${rel} not found next to ${pack.repo}'s scorer` }
325 : await $.process.run([settings.python, path, ...args, '--json'], { timeoutMs: SCORER_TIMEOUT_MS })
326 .catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: String(error) }))
327 await update($, toolRuns, all => ({ ...all, [name]: { at, exitCode: ran.exitCode, stdout: ran.stdout, stderr: ran.stderr } }))
328}
329
330/** Runs what a view needs, then shows it. The deliverability check only runs when asked. */
331async function loadView($: $, settings: Settings, id: ViewId, isDeliverability = false): Promise<void> {
332 const root = await projectRoot($)
333 const has = (rel: string) => $.fs.exists(`${root}/${rel}`).catch(() => false)
334 if (id === 'pricing') {
335 if (await has('gtm/waterfall.csv')) await runTool($, settings, 'waterfall', 'price', 'scripts/pocket_price_waterfall.py', ['--file', `${root}/gtm/waterfall.csv`])
336 if (await has('gtm/tiers.json')) await runTool($, settings, 'decoy', 'price', 'scripts/decoy_validator.py', ['--file', `${root}/gtm/tiers.json`])
337 }
338 if (id === 'cold-email') {
339 if (await has('gtm/letter.json')) {
340 await runTool($, settings, 'spam', 'letter', 'scripts/spam_word_lint.py', ['--file', `${root}/gtm/letter.json`])
341 await runTool($, settings, 'subject', 'letter', 'scripts/score_subject_line.py', ['--file', `${root}/gtm/letter.json`])
342 }
343 if (await has('gtm/replies.jsonl')) await runTool($, settings, 'replies', 'letter', 'scripts/score_reply.py', ['--file', `${root}/gtm/replies.jsonl`])
344 if (isDeliverability) {
345 const config = parseConfig(await readText($, `${root}/${CONFIG}`).catch(() => undefined)) ?? {}
346 const domain = coldEmailView({ letter: undefined, config, now: 0 }).domain
347 if (domain !== '') await runTool($, settings, 'deliverability', 'letter', 'scripts/check_deliverability.py', ['--domain', domain])
348 }
349 }
350 await update($, view, () => id)
351 await update($, tab, () => 'views')
352}
353
354function failingCount(all: Scores): number {
355 return Object.values(all).filter(one => one?.status === 'fail' || (one?.voice?.length ?? 0) > 0).length
356}
357
358function statusLine(found: GtmBoard, all: Scores): string | undefined {
359 if (!found.hasProject) return undefined
360 const next = nextStep(found)
361 const tail = next === undefined ? 'all steps done' : `next ${next.command}`
362 const failing = failingCount(all)
363 const fixes = failing > 0 ? ` · ${failing} draft${failing === 1 ? '' : 's'} to fix` : ''
364 const warnings = found.hasProject && lastFindings > 0 ? ` · ${lastFindings} warning${lastFindings === 1 ? '' : 's'}` : ''
365 return `GTM ${doneCount(found)}/${found.steps.length}${fixes}${warnings} · ${tail}`
366}
367
368let lastFindings = 0
369
370async function refresh($: $, settings: Settings, isAnnounced = false): Promise<GtmBoard> {
371 const found = await scan($)
372 const before = await read($, board)
373 await update($, board, () => found)
374 if (found.hasProject) {
375 await scoreAll($, settings)
376 lastFindings = (await checkDrift($)).length
377 }
378 $.ui.status(statusLine(found, await read($, scores)))
379
380 if (isAnnounced && before !== null) {
381 const fresh = found.steps.filter(one => one.isDone && !before.steps[one.n]?.isDone)
382 if (fresh.length > 0) {
383 const next = nextStep(found)
384 const names = fresh.map(one => one.name).join(', ')
385 $.ui.toast(next === undefined
386 ? `GTM: ${names} done. Every step is in place.`
387 : `GTM: ${names} done. Next: ${next.command}`)
388 }
389 }
390 return found
391}
392
393// The band preference and score history last across sessions; the board is rebuilt from the files.
394async function restore($: $, settings: Settings): Promise<void> {
395 const isHidden = (await $.store.get('isBandHidden')) === true
396 await update($, isBandHidden, () => isHidden)
397 const root = await projectRoot($)
398 const saved = await $.store.get(historyKey(root))
399 await update($, history, () => (saved !== null && typeof saved === 'object' ? (saved as History) : {}))
400 const log = await $.store.get(outcomesKey(root))
401 await update($, outcomes, () => (Array.isArray(log) ? (log as Outcome[]) : []))
402 scoredKeys.clear()
403 scorerPaths.clear()
404 await refresh($, settings)
405}
406
407async function setBandHidden($: $, isHidden: boolean): Promise<void> {
408 await $.store.set('isBandHidden', isHidden)
409 await update($, isBandHidden, () => isHidden)
410}
411
412async function openDetail($: $, id: PackId): Promise<void> {
413 await update($, selected, () => id)
414 await update($, tab, () => 'detail')
415}
416
417/** The toast after a draft is saved: its score, fixes and voice hits. */
418function savedToast(pack: PackDef, score: Score): string {
419 const voice = score.voice?.length ? ` · voice: ${score.voice.slice(0, 2).map(one => `"${one}"`).join(', ')}` : ''
420 if (score.status === 'unknown') return `GTM ${pack.name}: not scored (${cut(score.detail ?? 'unknown', 60)})`
421 const head = scoreLabel(score)
422 const first = score.fixes[0] ?? score.reasons[0]
423 return `GTM ${pack.name}: ${head}${voice}${score.status === 'fail' && first ? ` · ${cut(first, 70)}` : ''}`
424}
425
426// --- The guard on brand-config.json and SOUL.md -----------------------------
427
428function guardMessage(file: string, lost: string[]): string {
429 const shown = lost.slice(0, 6).join(', ') + (lost.length > 6 ? `, +${lost.length - 6} more` : '')
430 return `gtm-operator: this write to ${file} would drop or change values that already exist (${shown}). ` +
431 'The GTM suite merges, never overwrites: keep every existing key and value, change only the fields ' +
432 'this pack owns, and ask the person before changing a field that already has a value. ' +
433 'If they confirmed the change, they can run /gtm-guard off and you can retry.'
434}
435
436function failedGuard(file: string, kind: string): string {
437 return `gtm-operator: the merge check on ${file} failed (${kind}), so this write was not made. ` +
438 'Read the file again and retry with a change that keeps every existing value.'
439}
440
441async function guard($: $, path: string, after: string): Promise<string | undefined> {
442 if (await read($, isGuardOff)) return undefined
443 const root = await $.session.root()
444 const rel = relative(path, root)
445 if (rel !== CONFIG && rel !== SOUL) return undefined
446
447 const before = await readText($, `${root.replace(/[\\/]$/, '')}/${rel}`).catch(() => undefined)
448 if (rel === CONFIG) {
449 const next = parseConfig(after)
450 if (next === undefined) {
451 return `gtm-operator: ${CONFIG} must stay one JSON object; this content does not parse as one.`
452 }
453 const old = parseConfig(before)
454 const lost = old === undefined ? [] : mergeViolations(old, next)
455 return lost.length > 0 ? guardMessage(CONFIG, lost) : undefined
456 }
457
458 const lost = before === undefined ? [] : soulViolations(before, after)
459 return lost.length > 0 ? guardMessage(SOUL, lost.map(name => `## ${name}`)) : undefined
460}
461
462/** The score gate: refuses a draft that scores below minScore. */
463async function gate($: $, settings: Settings, path: string, content: string): Promise<string | undefined> {
464 if (settings.minScore <= 0 || !settings.runScorers) return undefined
465 const rel = relative(path, await projectRoot($))
466 const pack = rel === undefined ? undefined : packForPath(rel)
467 if (pack === undefined) return undefined
468 if (scorerPaths.size === 0) await locateScorers($, settings)
469 if (!scorerPaths.get(pack.id)) return undefined
470
471 const score = await runScorer($, settings, pack, { stdin: content })
472 if (score.status === 'unknown') return undefined
473 const value = score.score ?? (score.status === 'pass' ? 100 : 0)
474 if (value >= settings.minScore) return undefined
475 const fixes = gateLines(score).map(one => `- ${one}`).join('\n')
476 return `gtm-operator: this ${pack.name} draft scores ${score.score ?? score.status} with ${pack.repo}'s scorer, ` +
477 `below the minimum of ${settings.minScore} set for this project. Fix these, then write it again:\n${fixes}`
478}
479
480// --- Actions, the sprint, analytics ----------------------------------------
481
482/**
483 * Runs a pack's slash command as if typed. Deferred to a timer: a command.run
484 * hook cannot start a turn while it holds one, and a slash command can only
485 * go through $.command.run, never $.prompt.submit.
486 */
487function runNext($: $, command: string): void {
488 const [name = '', ...rest] = command.replace(/^\//, '').split(' ')
489 $.clock.after(1, () => {
490 $.command.run({ command: name, args: rest.join(' ') }).catch(() => undefined)
491 })
492}
493
494/**
495 * How a sprint step starts: 'run' from a press or turn.complete, 'fill' (the
496 * prompt, for the person to send) from a slash command, which may not start a turn.
497 */
498type Launch = 'run' | 'fill'
499
500// False in a -p run or the SDK: no prompt to fill, so the command is named instead.
501let isInteractive = true
502
503async function launch($: $, how: Launch, command: string): Promise<void> {
504 if (how === 'run') runNext($, command)
505 else if (isInteractive) await $.prompt.fill({ text: command })
506}
507
508/** What a 'fill' launch tells the person to do with the command. */
509function handoff(command: string, verb: string): string {
510 return isInteractive ? `${command} is in your prompt; send it to ${verb}` : `run ${command} to ${verb}`
511}
512
513/** Starts a sprint at the next open step, running through `target`. */
514async function startSprint($: $, settings: Settings, target: number, how: Launch): Promise<string> {
515 const found = await refresh($, settings)
516 const step = nextStep(found)
517 if (step === undefined) return 'Every step is in place; nothing to sprint through.'
518 if (step.n === 0) return 'Run /gtm:setup first; the sprint starts at the PSP.'
519 const pack = PACKS.find(one => one.step === step.n)
520 if (pack === undefined || step.n > target) return `The next step (${step.n}) is past the sprint's end (${target}).`
521 const startedAt = await $.clock.now()
522 await update($, sprint, () => ({ current: pack.id, target, startedAt }))
523 await launch($, how, pack.command)
524 const first = how === 'fill' ? handoff(pack.command, 'start') : `${pack.name} now`
525 return `Sprint started: ${first}, then each pack through step ${target}. A pack only runs once the one before it passes its scorer. /gtm-sprint stop ends it.`
526}
527
528/** After each main-thread turn of a sprint: score the step and go on, finish, or pause. */
529async function afterSprintTurn($: $, settings: Settings, how: Launch = 'run'): Promise<void> {
530 const active = await read($, sprint)
531 if (active === null || active.paused !== undefined) return
532 const current = packById(active.current)
533 if (current === undefined) return
534 await scoreAll($, settings, [current], true)
535 const verdict = sprintStep(active.current, active.target, await read($, scores))
536 if (verdict.kind === 'advance') {
537 const next = packById(verdict.next)
538 if (next === undefined) return
539 await update($, sprint, () => ({ ...active, current: next.id }))
540 $.ui.toast(`GTM sprint: ${current.name} passes. Next: ${next.command}`)
541 await launch($, how, next.command)
542 } else if (verdict.kind === 'done') {
543 await update($, sprint, () => null)
544 $.ui.toast(`GTM sprint done through step ${active.target}.`)
545 } else {
546 await update($, sprint, () => ({ ...active, paused: verdict.reason }))
547 $.ui.toast(`GTM sprint paused: ${verdict.reason}. Fix it, then /gtm-sprint resume.`)
548 }
549}
550
551/** What gtm_score answers: the label, then the scorer's reasons and fixes. */
552function scoreReport(head: string, score: Score | undefined): string {
553 return [
554 `${head}: ${score ? scoreLabel(score) || score.status : 'not scored'}`,
555 ...(score?.detail ? [score.detail] : []),
556 ...(score?.reasons ?? []).map(one => `reason: ${one}`),
557 ...(score?.fixes ?? []).map(one => `fix: ${one}`),
558 ...(score?.voice ?? []).map(one => `refused phrase from SOUL.md: ${one}`),
559 ].join('\n')
560}
561
562const countOf = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
563
564async function logOutcome($: $, kind: Outcome['kind'], value: string): Promise<void> {
565 const root = await projectRoot($)
566 const letter = await readText($, `${root}/gtm/letter.json`).catch(() => undefined)
567 const letterScore = (await read($, scores)).letter
568 const entry: Outcome = {
569 at: await $.clock.now(),
570 kind,
571 count: Number(value.trim()),
572 version: letter === undefined ? 'no letter' : hashOf(letter).slice(0, 6),
573 ...(letterScore?.score !== undefined ? { score: letterScore.score } : {}),
574 }
575 const next = addOutcome(await read($, outcomes), entry)
576 if (next === undefined) {
577 $.ui.toast('GTM: enter a whole number, like 3.')
578 return
579 }
580 await update($, outcomes, () => next)
581 await $.store.set(outcomesKey(root), next)
582}
583
584async function weeklyDigest($: $): Promise<string> {
585 const found = await read($, board)
586 return digest({
587 now: await $.clock.now(),
588 history: await read($, history),
589 scores: await read($, scores),
590 findings: await read($, findings),
591 outcomes: await read($, outcomes),
592 ...(found && nextStep(found) ? { nextCommand: nextStep(found)?.command } : {}),
593 })
594}
595
596/** What the gtm_status tool and /gtm-board report, as text. */
597async function statusText($: $): Promise<string> {
598 const found = await read($, board)
599 if (found === null || !found.hasProject) return 'No GTM project here: no brand-config.json and no gtm/ folder. Run /gtm:setup.'
600 const all = await read($, scores)
601 const lines = PACKS.map(pack => {
602 const score = all[pack.id]
603 return `${pack.step}. ${pack.name} (${pack.command}): ${score ? scoreLabel(score) || score.status : 'not scored'}`
604 })
605 return `${summary(found)}\n${lines.join('\n')}`
606}
607
608// --- Drawing ---------------------------------------------------------------
609
610function bar(value: number, max: number, width: number): string {
611 const filled = max > 0 ? Math.round((value / max) * width) : 0
612 return `${'█'.repeat(filled)}${'░'.repeat(Math.max(0, width - filled))}`
613}
614
615type RenderEvent = Parameters<EngineInterface['ui']['resolve']>[0]
616
617async function draftFile($: $, rel: string): Promise<DraftFile | undefined> {
618 const path = `${await projectRoot($)}/${rel}`
619 const stat = await $.fs.stat(path).catch(() => undefined)
620 if (stat?.kind !== 'file') return undefined
621 return { text: (await $.fs.read(path).catch(() => '')) as string, mtimeMs: stat.mtimeMs }
622}
623
624/** One pack view, drawn from the pack's files and its tools' last output. */
625async function drawView($: $, e: RenderEvent, settings: Settings, id: ViewId, width: number) {
626 const { Box, Button, Text } = $.ui.resolve(e)
627 const runs = await read($, toolRuns)
628 const root = await projectRoot($)
629 const config = parseConfig(await readText($, `${root}/${CONFIG}`).catch(() => undefined)) ?? {}
630 const now = await $.clock.now()
631 const line = (text: string, props: { bold?: boolean; dimColor?: boolean; color?: string } = {}) =>
632 <Text {...props}>{cut(text, width)}</Text>
633 const empty = (text: string) => <Box flexDirection="column">{line(text, { dimColor: true })}</Box>
634
635 if (id === 'pricing') {
636 const priceView = pricingView(runs.waterfall?.stdout, runs.decoy?.stdout, await draftFile($, 'gtm/price.json'))
637 if (priceView.customers.length === 0 && priceView.checks.length === 0 && priceView.contrastSet.length === 0) {
638 return empty('No pricing data yet. /pricing:pricing writes gtm/price.json; the waterfall reads gtm/waterfall.csv, the decoy check gtm/tiers.json.')
639 }
640 const barWidth = Math.max(8, Math.min(30, width - 40))
641 return (
642 <Box flexDirection="column">
643 {priceView.valueMetric !== '' && line(`Value metric: ${priceView.valueMetric}`)}
644 {priceView.contrastSet.length > 0 && line(`Contrast set: ${priceView.contrastSet.join(' · ')}`)}
645 {priceView.customers.length > 0 && <Text> </Text>}
646 {priceView.customers.length > 0 && line(`Pocket-price waterfall: ${money(priceView.totalPocket)} kept of ${money(priceView.totalList)} list`, { bold: true })}
647 {priceView.customers.map(one => line(`${one.id.padEnd(10)} ${shareBar(one.pocket, Math.max(one.list, one.pocket), barWidth)} ${money(one.pocket)} of ${money(one.list)} (${leakLabel(one.leakPct)})`))}
648 {priceView.byStep.length > 0 && line(`Biggest leaks: ${priceView.byStep.slice(0, 3).map(step => `${step.name} ${money(step.leak)}`).join(', ')}`, { color: 'yellow' })}
649 {priceView.checks.length > 0 && <Text> </Text>}
650 {priceView.checks.length > 0 && line(`Tier contrast check${priceView.tiersScore !== undefined ? `: ${priceView.tiersScore}/100` : ''}`, { bold: true })}
651 {priceView.checks.map(check => line(`${check.passed ? '✓' : '✗'} ${check.rule}: ${check.detail}`, check.passed ? { dimColor: true } : { color: 'yellow' }))}
652 </Box>
653 )
654 }
655
656 if (id === 'prospects') {
657 const board = prospectBoard(await draftFile($, 'gtm/list.json'))
658 const total = board.call.length + board.hold.length + board.drop.length + board.unscored.length
659 if (total === 0) return empty('No prospects yet. Run /prospect-list:who-to-contact.')
660 const column = (title: string, items: { title: string; signal: string }[], color?: string) => (
661 <Box flexDirection="column">
662 {line(`${title} (${items.length})`, { bold: true, ...(color ? { color } : {}) })}
663 {items.slice(0, 8).map(one => line(` ${one.title}${one.signal ? ` — ${one.signal}` : ''}`))}
664 {items.length > 8 && line(` +${items.length - 8} more`, { dimColor: true })}
665 </Box>
666 )
667 return (
668 <Box flexDirection="column">
669 {column('Call this week', board.call, 'green')}
670 {column('Hold', board.hold)}
671 {column('Drop', board.drop, 'gray')}
672 {board.unscored.length > 0 && column('Not scored', board.unscored, 'yellow')}
673 </Box>
674 )
675 }
676
677 if (id === 'cold-email') {
678 const mail = coldEmailView({
679 letter: await draftFile($, 'gtm/letter.json'),
680 config,
681 ...(runs.spam ? { spam: runs.spam.stdout } : {}),
682 ...(runs.subject ? { subject: runs.subject.stdout } : {}),
683 ...(runs.replies ? { replies: runs.replies.stdout } : {}),
684 ...(runs.deliverability ? { deliverability: runs.deliverability } : {}),
685 now,
686 })
687 const replyKinds = Object.entries(mail.replies)
688 return (
689 <Box flexDirection="column">
690 {mail.words === 0 ? line('No first touch yet. Run /cold-email:cold-email.', { dimColor: true }) : line(`First touch: ${mail.words} words${mail.subject ? ` · subject "${mail.subject}"` : ''}`, { bold: true })}
691 {mail.signal !== '' && line(`Signal: ${mail.signal}`)}
692 {mail.spamScore !== undefined && line(`Spam lint: ${mail.spamScore}/100 ${mail.spamVerdict}`, mail.spamScore >= 75 ? {} : { color: 'yellow' })}
693 {mail.subjectScore !== undefined && line(`Subject line: ${mail.subjectScore}/100`, mail.subjectScore >= 70 ? {} : { color: 'yellow' })}
694 <Text> </Text>
695 {line(`Rhythm: ${mail.rhythm.map(day => (day.isSendDay ? (day.isToday ? `[${day.day}]` : day.day) : day.isToday ? `(${day.day.toLowerCase()})` : '·')).join(' ')}`)}
696 {replyKinds.length > 0 && line(`Replies: ${replyKinds.map(([kind, count]) => `${kind} ${count}`).join(' · ')}`)}
697 <Text> </Text>
698 {line(`Deliverability${mail.domain ? ` for ${mail.domain}` : ''}`, { bold: true })}
699 {mail.domain === '' && line('No sending domain in brand-config.infrastructure. /cold-email:cold-email setup asks for it.', { dimColor: true })}
700 {mail.deliverability?.lines.slice(0, 15).map(one => line(one, one.startsWith('✗') ? { color: 'yellow' } : {}))}
701 {mail.domain !== '' && (
702 <Button key="deliverability" label="Check deliverability (DNS, needs dig)" onPress={() => loadView($, settings, 'cold-email', true)} />
703 )}
704 </Box>
705 )
706 }
707
708 if (id === 'evp') {
709 const ladder = evpLadder(config)
710 if (ladder.primary === '' && ladder.rungs.every(rung => rung.lines.length === 0)) return empty('No value line yet. Run /evp:evp.')
711 return (
712 <Box flexDirection="column">
713 {line('Awareness ladder (Schwartz): one line per reader', { bold: true })}
714 {ladder.rungs.map(rung => (
715 <Box flexDirection="column">
716 {line(`${rung.isChosen ? '▸' : ' '} ${rung.tier}. ${rung.name}${rung.isChosen ? ' (outreach line)' : ''}`, rung.isChosen ? { bold: true, color: 'cyan' } : { dimColor: rung.lines.length === 0 })}
717 {rung.lines.slice(0, 2).map(text => line(` ${text}`))}
718 </Box>
719 ))}
720 </Box>
721 )
722 }
723
724 if (id === 'geo') {
725 const geo = geoView(await draftFile($, 'gtm/findability.json'), now)
726 if (geo === undefined) return empty('No findability record yet. Run /geo:geo.')
727 return (
728 <Box flexDirection="column">
729 {geo.question !== '' && line(`Buyer question: ${geo.question}`, { bold: true })}
730 {geo.killDate !== '' && line(`Kill date ${geo.killDate}${geo.daysLeft !== undefined ? ` · ${geo.daysLeft >= 0 ? `${geo.daysLeft} days left` : `${-geo.daysLeft} days past: review now (/geo:geo review)`}` : ''}`, geo.daysLeft !== undefined && geo.daysLeft < 0 ? { color: 'yellow' } : {})}
731 {geo.isIndexable !== undefined && line(`Indexable: ${geo.isIndexable ? 'yes' : 'no'}`, geo.isIndexable ? {} : { color: 'yellow' })}
732 {geo.blockedBots.length > 0 && line(`Blocked crawlers: ${geo.blockedBots.join(', ')}`, { color: 'yellow' })}
733 {geo.engines.length > 0 && <Text> </Text>}
734 {geo.engines.length > 0 && line('Citations by engine', { bold: true })}
735 {geo.engines.map(one => line(`${one.engine.padEnd(22)} brand ${one.status || 'unknown'} · ${one.cited} cited URL${one.cited === 1 ? '' : 's'}`))}
736 </Box>
737 )
738 }
739
740 const entries = await $.fs.list(`${root}/drafts`).catch(() => [])
741 const founder = founderView(entries.filter(one => one.kind === 'file'), now)
742 return (
743 <Box flexDirection="column">
744 {line(founder.daysSinceLast === undefined ? 'No posts in drafts/ yet. Run /founder-brand:founder-brand.' : `Last post ${founder.daysSinceLast} day${founder.daysSinceLast === 1 ? '' : 's'} ago · ${founder.postsLast28} in the last 28 days`, founder.isLate ? { color: 'yellow', bold: true } : { bold: true })}
745 {founder.isLate && founder.daysSinceLast !== undefined && line('Over 7 days since the last post: the rotation has stalled.', { color: 'yellow' })}
746 <Text> </Text>
747 {founder.pillars.map(one => line(`${one.pillar.padEnd(8)} ${one.count} post${one.count === 1 ? '' : 's'}${one.lastDaysAgo !== undefined ? ` · last ${one.lastDaysAgo}d ago` : ' · none yet'}`, one.count === 0 ? { dimColor: true } : {}))}
748 </Box>
749 )
750}
751
752export const register: Register = (on, options) => {
753 const settings = settingsFrom(options)
754
755 on('session.start', async ($, e, next) => {
756 isInteractive = e.isInteractive
757 await $.command.register({
758 name: 'gtm-board',
759 description: 'GTM suite board: what is done, how each draft scores, and the next pack to run',
760 argumentHint: '[refresh]',
761 immediate: true,
762 })
763 await $.command.register({
764 name: 'gtm-score',
765 description: "Score the GTM drafts with each pack's own scorer",
766 argumentHint: '[pack]',
767 immediate: true,
768 })
769 await $.command.register({
770 name: 'gtm-health',
771 description: 'Cross-pack checks: stale drafts, drafts that disagree, buyers the list leaves out',
772 immediate: true,
773 })
774 await $.command.register({
775 name: 'gtm-guard',
776 description: 'Turn the brand-config.json / SOUL.md overwrite guard on or off',
777 argumentHint: '[on|off]',
778 immediate: true,
779 })
780 await $.command.register({
781 name: 'gtm-sprint',
782 description: 'Run the GTM packs in order, each only after the one before passes its scorer',
783 argumentHint: '[to <step>|stop|resume]',
784 })
785 await $.command.register({
786 name: 'gtm-digest',
787 description: 'The GTM weekly digest: what moved, what needs work, outcomes',
788 immediate: true,
789 })
790 await $.command.register({
791 name: 'gtm-outcomes',
792 description: 'GTM outcome log: replies and meetings per cold email version; import <file.csv> reads date, replies, meetings',
793 argumentHint: '[import <file.csv>]',
794 immediate: true,
795 })
796 await $.tool.register({
797 name: 'gtm_status',
798 description: 'GTM operator suite status for this project: which steps are done, the next pack command, and each draft\'s score from its pack\'s own scorer. Call before choosing which GTM pack to run.',
799 })
800 await $.tool.register({
801 name: 'gtm_score',
802 description: 'Score one GTM draft now with its pack\'s own scorer and return the score, reasons and fixes. Give pack, or file to score a given draft. Call after writing or editing a draft in gtm/, drafts/ or the psp/evp blocks of brand-config.json, before saying it is done.',
803 inputSchema: {
804 type: 'object',
805 properties: {
806 pack: { type: 'string', description: `One of: ${PACKS.map(pack => pack.plugin).join(', ')}` },
807 file: { type: 'string', description: 'A draft path from the project root (gtm/letter.json, drafts/2026-10-05-post.md). Scores that file; the pack follows from the path.' },
808 },
809 },
810 })
811 await $.tool.register({
812 name: 'gtm_consistency',
813 description: 'Cross-pack checks for the GTM suite: drafts gone stale after a PSP/EVP/price change, a page that does not lead with the EVP or name the tiers, drafts missing the buyer\'s words, roles the prospect list leaves out. Heuristics; confirm before acting.',
814 })
815 await $.agent.register({
816 name: 'reviewer',
817 description: 'Reviews one GTM draft (gtm/*.json, drafts/*.md, or the psp/evp blocks) against the PSP, the EVP, SOUL.md and its pack\'s scorer, and returns what to change. Read-only.',
818 prompt: [
819 'You review one go-to-market draft for an operator using the GTM operator skill packs. You do not edit files.',
820 'Read brand-config.json (the psp and evp blocks, icp, tone) and SOUL.md (voice; "Phrases I refuse" are banned).',
821 `Call ${TOOL_PREFIX}gtm_score for the draft's pack and ${TOOL_PREFIX}gtm_consistency.`,
822 'Then check what a scorer cannot: is it in the buyer\'s words from psp.vocabulary, does it lead with the EVP line, is every claim backed by a receipt the operator gave, would the buyer named in the PSP stop on the first line?',
823 'Answer with: the score line, then at most five changes, most important first, each as "what is wrong -> what to write instead". Quote the draft. No praise, no summary.',
824 ].join('\n'),
825 tools: ['Read', 'Glob', 'Grep', `${TOOL_PREFIX}gtm_score`, `${TOOL_PREFIX}gtm_status`, `${TOOL_PREFIX}gtm_consistency`],
826 model: 'inherit',
827 })
828 await restore($, settings)
829 $.clock.every(30_000, () => void refresh($, settings, true))
830
831 return next(e)
832 })
833
834 // /clear, /resume and /branch reset $.state without a new session.start.
835 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
836 await restore($, settings)
837 return next(e)
838 })
839
840 on('command.run', { command: 'gtm-board' }, async ($, e) => {
841 if (e.args.trim() === 'refresh') scoredKeys.clear()
842 const found = await refresh($, settings)
843 if (!found.hasProject) {
844 return {
845 text: 'No GTM project here yet: no brand-config.json and no gtm/ folder.\n' +
846 'Next: /gtm:setup — asks the shared questions once. ' +
847 'Install the suite with /plugin install gtm@gtm-operator-skills.',
848 }
849 }
850 await update($, tab, () => 'board')
851 await $.ui.open({ id: PANE, title: 'GTM board' })
852
853 return { text: summary(found) }
854 })
855
856 on('command.run', { command: 'gtm-score' }, async ($, e) => {
857 const wanted = e.args.trim()
858 const pack = wanted === '' ? undefined : PACKS.find(one => one.id === wanted || one.plugin === wanted)
859 if (wanted !== '' && pack === undefined) {
860 return { text: `Unknown pack "${wanted}". Packs: ${PACKS.map(one => one.plugin).join(', ')}.` }
861 }
862 await scoreAll($, settings, pack ? [pack] : undefined, true)
863 const all = await read($, scores)
864 const lines = (pack ? [pack] : PACKS).map(one => {
865 const score = all[one.id]
866 const label = score === undefined ? 'not scored' : scoreLabel(score)
867 const first = score?.status === 'fail' ? ` — ${score.fixes[0] ?? score.reasons[0] ?? ''}` : ''
868 const why = score?.status === 'unknown' ? ` — ${score.detail ?? ''}` : ''
869 return `${one.step}. ${one.name}: ${label}${first}${why}`
870 })
871 return { text: lines.join('\n') }
872 })
873
874 on('command.run', { command: 'gtm-health' }, async $ => {
875 await refresh($, settings)
876 const found = await read($, findings)
877 if (found.length === 0) return { text: 'No cross-pack issues: no stale drafts, no disagreements, no missing buyers.' }
878 await update($, tab, () => 'health')
879 return { text: found.map(one => `- [${one.kind}] ${one.message}`).join('\n') }
880 })
881
882 on('command.run', { command: 'gtm-sprint' }, async ($, e) => {
883 const arg = e.args.trim().toLowerCase()
884 const active = await read($, sprint)
885 if (arg === 'stop') {
886 await update($, sprint, () => null)
887 return { text: active ? 'GTM sprint stopped.' : 'No GTM sprint is running.' }
888 }
889 if (arg === 'resume') {
890 if (active === null) return { text: 'No GTM sprint to resume. Start one with /gtm-sprint.' }
891 await update($, sprint, () => ({ current: active.current, target: active.target, startedAt: active.startedAt }))
892 await afterSprintTurn($, settings, 'fill')
893 const now = await read($, sprint)
894 if (now === null) return { text: 'GTM sprint done.' }
895 return { text: now.paused ? `Still paused: ${now.paused}.` : `GTM sprint resumed: ${handoff(packById(now.current)?.command ?? '', 'go on')}.` }
896 }
897 const match = /^(?:to\s+)?(\d+)$/.exec(arg)
898 const target = match ? Math.min(10, Math.max(1, Number(match[1]))) : 10
899 return { text: await startSprint($, settings, target, 'fill') }
900 })
901
902 on('command.run', { command: 'gtm-digest' }, async ($, e) => {
903 await refresh($, settings)
904 return { text: await weeklyDigest($) }
905 })
906
907 on('command.run', { command: 'gtm-outcomes' }, async ($, e) => {
908 const root = await projectRoot($)
909 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
910 if (verb === 'import') {
911 const rel = rest.join(' ').replace(/^\.\//, '')
912 if (rel === '' || rel.startsWith('/') || rel.split(/[\\/]/).includes('..')) {
913 return { text: 'Give a path inside the project, like crm/outcomes.csv.' }
914 }
915 const text = await readText($, `${root}/${rel}`).catch(() => undefined)
916 if (text === undefined) return { text: `No file at ${rel} (paths are from the project root).` }
917 const parsed = parseOutcomeCsv(text)
918 const next = importOutcomes(await read($, outcomes), parsed.rows)
919 await update($, outcomes, () => next)
920 await $.store.set(outcomesKey(root), next)
921 const skipped = parsed.errors.length > 0 ? ` Skipped: ${parsed.errors.join('; ')}.` : ''
922 return { text: `Imported ${parsed.rows.length} outcome row${parsed.rows.length === 1 ? '' : 's'} from ${rel}.${skipped}` }
923 }
924 const rows = outcomesByVersion(await read($, outcomes))
925 if (rows.length === 0) return { text: 'No outcomes logged. Log them on the board\'s Analytics tab, or /gtm-outcomes import <file.csv> (columns: date, replies, meetings).' }
926 return {
927 text: rows.map(row => `${row.version}: ${countOf(row.replies, 'reply', 'replies')} · ${countOf(row.meetings, 'meeting', 'meetings')}${row.score !== undefined ? ` · letter scored ${row.score}` : ''}`).join('\n'),
928 }
929 })
930
931 on('command.run', { command: 'gtm-guard' }, async ($, e) => {
932 const arg = e.args.trim().toLowerCase()
933 if (arg === 'on' || arg === 'off') await update($, isGuardOff, () => arg === 'off')
934 const isOff = await read($, isGuardOff)
935
936 return {
937 text: isOff
938 ? 'GTM guard is off: writes to brand-config.json and SOUL.md are not checked. /gtm-guard on to restore.'
939 : 'GTM guard is on: a write that drops or changes an existing brand-config.json value or SOUL.md section is refused.',
940 }
941 })
942
943 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
944 const reason = (await guard($, e.file_path, e.content)) ?? (await gate($, settings, e.file_path, e.content))
945 return reason === undefined ? next(e) : { deny: reason }
946 }).catch(async ($, e, next) => {
947 const rel = relative(e.file_path, await $.session.root())
948 return rel === CONFIG || rel === SOUL ? { deny: failedGuard(rel, next.error.kind) } : next(e)
949 })
950
951 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
952 const root = await $.session.root()
953 const rel = relative(e.file_path, root)
954 const isShared = rel === CONFIG || rel === SOUL
955 const pack = rel === undefined ? undefined : packForPath(rel)
956 if (!isShared && (pack === undefined || settings.minScore <= 0)) return next(e)
957 const before = await readText($, `${root.replace(/[\\/]$/, '')}/${rel}`).catch(() => undefined)
958 const after = before === undefined
959 ? undefined
960 : applyEdit(before, e.old_string, e.new_string, e.replace_all === true)
961 const reason = after === undefined
962 ? undefined
963 : isShared ? await guard($, e.file_path, after) : await gate($, settings, e.file_path, after)
964 return reason === undefined ? next(e) : { deny: reason }
965 }).catch(async ($, e, next) => {
966 const rel = relative(e.file_path, await $.session.root())
967 return rel === CONFIG || rel === SOUL ? { deny: failedGuard(rel, next.error.kind) } : next(e)
968 })
969
970 // Anything that may have written the suite's files redraws the board, and a
971 // saved draft is scored at once.
972 on('tool.call', async ($, e, next) => {
973 const ran = await next(e)
974 const tool = String(e.tool)
975 if (ran.deny !== undefined || !['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash'].includes(tool)) {
976 return ran
977 }
978 const path = 'file_path' in e && typeof e.file_path === 'string' ? e.file_path : undefined
979 const rel = relative(path ?? '', await $.session.root())
980 if (tool !== 'Bash' && !isTracked(rel)) return ran
981
982 const pack = rel === undefined ? undefined : packForPath(rel)
983 const touched = pack ? [pack] : rel === CONFIG ? PACKS.filter(one => one.draft.kind !== 'file' && one.draft.kind !== 'latest') : undefined
984 const before = await read($, scores)
985 const changed = touched ? await scoreAll($, settings, touched) : []
986 await refresh($, settings, true)
987 for (const score of changed) {
988 const def = packById(score.pack)
989 const was = before[score.pack]
990 const isNews = was === undefined || was.status !== score.status || was.score !== score.score ||
991 (was.voice?.length ?? 0) !== (score.voice?.length ?? 0)
992 if (def && score.status !== 'missing' && isNews) $.ui.toast(savedToast(def, score))
993 }
994 return ran
995 })
996
997 on('tool.call', { tool: 'mcp__gtm-operator__gtm_status' }, async $ => {
998 await refresh($, settings)
999 const text = await statusText($)
1000 return { result: text, text }
1001 })
1002
1003 on('tool.call', { tool: 'mcp__gtm-operator__gtm_score' }, async ($, e) => {
1004 const file = 'file' in e && typeof e.file === 'string' ? e.file.trim() : ''
1005 if (file !== '') {
1006 const root = await projectRoot($)
1007 const rel = relative(file, root)
1008 const filePack = rel === undefined ? undefined : packForPath(rel)
1009 if (rel === undefined || filePack === undefined) {
1010 const text = `${file} is not a GTM draft: give a gtm/*.json or drafts/*.md path, or a pack (${PACKS.map(one => one.plugin).join(', ')}).`
1011 return { result: text, text, isError: true }
1012 }
1013 const content = await readText($, `${root}/${rel}`).catch(() => undefined)
1014 if (content === undefined) {
1015 const text = `No file at ${rel}.`
1016 return { result: text, text, isError: true }
1017 }
1018 if (scorerPaths.size === 0) await locateScorers($, settings)
1019 const score = await runScorer($, settings, filePack, { stdin: content })
1020 const text = scoreReport(`${filePack.name} (${filePack.command}), ${rel}`, score)
1021 return { result: text, text }
1022 }
1023 const wanted = 'pack' in e && typeof e.pack === 'string' ? e.pack : ''
1024 const pack = PACKS.find(one => one.plugin === wanted || one.id === wanted || one.repo === wanted)
1025 if (pack === undefined) {
1026 const text = `Unknown pack "${wanted}". Use one of: ${PACKS.map(one => one.plugin).join(', ')}.`
1027 return { result: text, text, isError: true }
1028 }
1029 await scoreAll($, settings, [pack], true)
1030 const score = (await read($, scores))[pack.id]
1031 const text = scoreReport(`${pack.name} (${pack.command})`, score)
1032 return { result: text, text }
1033 })
1034
1035 on('tool.call', { tool: 'mcp__gtm-operator__gtm_consistency' }, async $ => {
1036 await refresh($, settings)
1037 const found = await read($, findings)
1038 const text = found.length === 0 ? 'No cross-pack issues found.' : found.map(one => `- [${one.kind}] ${one.message}`).join('\n')
1039 return { result: text, text }
1040 })
1041
1042 // The guided sprint moves on after each main-thread turn.
1043 on('turn.complete', async ($, e, next) => {
1044 const done = await next(e)
1045 if (e.agentId === undefined && !e.isAborted && (await read($, sprint)) !== null) await afterSprintTurn($, settings)
1046 return done
1047 })
1048
1049 on('prompt.compose', async ($, e, next) => {
1050 const composed = await next(e)
1051 const found = await read($, board)
1052 if (found === null || !found.hasProject) return composed
1053
1054 const all = await read($, scores)
1055 const failing = PACKS.flatMap(pack => {
1056 const score = all[pack.id]
1057 if (score === undefined) return []
1058 const fix = score.fixes[0] ?? score.reasons[0]
1059 const voice = score.voice?.length ? `; uses refused phrases: ${score.voice.join(', ')}` : ''
1060 if (score.status === 'fail') return [`- ${pack.name} (${pack.draft.kind === 'file' ? pack.draft.path : pack.command}): ${scoreLabel(score)}${fix ? `; first fix: ${fix}` : ''}${voice}`]
1061 return voice ? [`- ${pack.name}: ${scoreLabel(score)}${voice}`] : []
1062 })
1063 const text = '# GTM operator suite state (from the gtm-operator mod)\n' +
1064 'This project uses the GTM operator skill packs. brand-config.json and SOUL.md are shared: ' +
1065 'merge field by field, never rewrite them, and ask before changing a filled value. ' +
1066 'Drafts live in gtm/, one file per pack.\n' + summary(found) +
1067 (failing.length > 0 ? `\nDrafts that fail their pack's scorer:\n${failing.join('\n')}` : '') +
1068 (await read($, findings)).reduce((out, one, i) => `${out}${i === 0 ? '\nCross-pack warnings (heuristics; confirm before acting):' : ''}\n- ${one.message}`, '')
1069
1070 return {
1071 sections: [...composed.sections, { id: 'gtm-operator:state', text, scope: 'session' }],
1072 }
1073 })
1074
1075 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1076 const found = await read($, board)
1077 if (e.props.hasSurvey || found === null || !found.hasProject || (await read($, isBandHidden))) {
1078 return next(e)
1079 }
1080
1081 const { Box, Button, Text } = $.ui.resolve(e)
1082 const theirs = await next(e)
1083 const step = nextStep(found)
1084 const count = `GTM ${doneCount(found)}/${found.steps.length}`
1085 const failing = failingCount(await read($, scores))
1086 const warnings = (await read($, findings)).length
1087 const fixText = failing > 0 ? `${failing} to fix` : ''
1088 const warnText = warnings > 0 ? `${warnings} warning${warnings === 1 ? '' : 's'}` : ''
1089 // The reason for the next step goes first when the row is short of room,
1090 // as it is beside a docked pane: the labels and buttons never wrap.
1091 const BUTTONS = '[ Use ] [ Run ] [ Board ] [ Hide ]'.length
1092 const fixed = [count, fixText, warnText].filter(one => one !== '').reduce((n, one) => n + one.length + 1, 0) + BUTTONS + 1
1093 const showWhy = step !== undefined &&
1094 fixed + `Next: ${step.command} — ${step.why}`.length <= e.props.bodyColumns
1095
1096 return (
1097 <Box flexDirection="column">
1098 {theirs}
1099 <Box flexDirection="row" gap={1}>
1100 <Box flexShrink={0}><Text bold>{count}</Text></Box>
1101 {fixText !== '' && <Box flexShrink={0}><Text color="yellow">{fixText}</Text></Box>}
1102 {warnText !== '' && <Box flexShrink={0}><Text color="yellow">{warnText}</Text></Box>}
1103 {step === undefined
1104 ? <Text dimColor>every step in place</Text>
1105 : (
1106 <Box flexShrink={1}>
1107 <Text wrap="truncate-end">Next: <Text bold>{step.command}</Text>{showWhy && <Text dimColor> — {step.why}</Text>}</Text>
1108 </Box>
1109 )}
1110 {step !== undefined && (
1111 <Button
1112 key="use"
1113 label="Use"
1114 variant="primary"
1115 onPress={() => $.prompt.fill({ text: step.command })}
1116 />
1117 )}
1118 {step !== undefined && <Button key="run" label="Run" onPress={() => runNext($, step.command)} />}
1119 <Button key="board" label="Board" onPress={() => $.ui.open({ id: PANE, title: 'GTM board' })} />
1120 <Button key="hide" label="Hide" onPress={() => setBandHidden($, true)} />
1121 </Box>
1122 </Box>
1123 )
1124 })
1125
1126 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1127 const { Box, Button, Text } = $.ui.resolve(e)
1128 const found = await read($, board)
1129 const width = Math.max(24, e.props.bodyColumns ?? e.viewport?.columns ?? 60)
1130
1131 if (found === null || !found.hasProject) {
1132 return (
1133 <Box flexDirection="column">
1134 <Text>No GTM project here yet.</Text>
1135 <Text dimColor>Run /gtm:setup to answer the shared questions once.</Text>
1136 </Box>
1137 )
1138 }
1139
1140 const all = await read($, scores)
1141 const past = await read($, history)
1142 const current = await read($, tab)
1143 const health = await read($, findings)
1144 const current_view = await read($, view)
1145 const tabs = (
1146 <Box flexDirection="row" gap={2}>
1147 <Button key="tab-board" label="Board" hotkey="1" plain onPress={() => update($, tab, () => 'board' as Tab)} />
1148 <Button key="tab-detail" label="Detail" hotkey="2" plain onPress={() => update($, tab, () => 'detail' as Tab)} />
1149 <Button key="tab-health" label={`Health${health.length > 0 ? ` (${health.length})` : ''}`} hotkey="3" plain onPress={() => update($, tab, () => 'health' as Tab)} />
1150 <Button key="tab-views" label="Views" hotkey="4" plain onPress={() => loadView($, settings, current_view)} />
1151 <Button key="tab-analytics" label="Analytics" hotkey="5" plain onPress={() => update($, tab, () => 'analytics' as Tab)} />
1152 </Box>
1153 )
1154
1155 if (current === 'analytics') {
1156 const elements = $.ui.resolve(e)
1157 const Input = 'Input' in elements ? elements.Input : undefined
1158 const stats = allStats(past)
1159 const log = await read($, outcomes)
1160 const versions = outcomesByVersion(log)
1161 const resolved = stats.reduce((sum, one) => sum + one.fixesResolved, 0)
1162 return (
1163 <Box flexDirection="column">
1164 {tabs}
1165 <Text> </Text>
1166 <Text bold>{`Score history · ${resolved} fix${resolved === 1 ? '' : 'es'} resolved`}</Text>
1167 {stats.length === 0 && <Text dimColor>No drafts scored yet.</Text>}
1168 {stats.map(one => {
1169 const pack = packById(one.pack)
1170 const range = one.first !== undefined && one.last !== undefined ? `${one.first}→${one.last}` : `${one.samples} run${one.samples === 1 ? '' : 's'}`
1171 const pass = one.draftsToPass === undefined ? 'not passing yet'
1172 : one.draftsToPass === 0 ? 'passed first time'
1173 : `passed after ${one.draftsToPass} failing draft${one.draftsToPass === 1 ? '' : 's'} (${duration(one.timeToPass ?? 0)})`
1174 return <Text>{cut(`${(pack?.name ?? one.pack).padEnd(20)} ${sparkline(past[one.pack] ?? [], 10).padEnd(10)} ${range} · ${pass}`, width)}</Text>
1175 })}
1176 <Text> </Text>
1177 <Text bold>Outcomes (log weekly; tied to the live cold email)</Text>
1178 {versions.length === 0 && <Text dimColor>{cut('Nothing logged. The packs never read your CRM: log replies and meetings here, or /gtm-outcomes import <file.csv>.', width)}</Text>}
1179 {versions.map(row => (
1180 <Text>{cut(`${row.version === 'imported' ? 'imported from CSV' : `letter ${row.version}`}${row.score !== undefined ? ` (scored ${row.score})` : ''}: ${countOf(row.replies, 'reply', 'replies')} · ${countOf(row.meetings, 'meeting', 'meetings')}`, width)}</Text>
1181 ))}
1182 {Input !== undefined && (
1183 <Input key="outcome-replies" label="Replies this week" placeholder="a number" value="" submitLabel="log" onSubmit={(value: string) => logOutcome($, 'replies', value)} />
1184 )}
1185 {Input !== undefined && (
1186 <Input key="outcome-meetings" label="Meetings this week" placeholder="a number" value="" submitLabel="log" onSubmit={(value: string) => logOutcome($, 'meetings', value)} />
1187 )}
1188 {Input === undefined && <Text dimColor>Log outcomes from the terminal or the Desktop app.</Text>}
1189 <Text> </Text>
1190 <Button key="digest" label="Copy weekly digest" onPress={async press => {
1191 const copied = await $.ui.copy({ text: await weeklyDigest($), surface: press.surface })
1192 $.ui.toast(copied.isCopied ? 'GTM: weekly digest copied.' : 'GTM: could not copy here; run /gtm-digest instead.')
1193 }} />
1194 </Box>
1195 )
1196 }
1197
1198 if (current === 'health') {
1199 const kindLabel = { stale: 'Stale', consistency: 'Mismatch', coverage: 'Gap' } as const
1200 return (hooks/analytics.ts 206 lines1// Analytics over the score history, the outcome log and the weekly digest.
2// Pure: register.tsx reads the store and hands the data in.
3import type { Finding, History, Outcome, PackId, Sample, Scores } from '../types'
4import { PACKS } from './packs'
5import { scoreLabel } from './score'
6
7export type PackStats = {
8 pack: PackId
9 samples: number
10 first?: number
11 last?: number
12 /** Failing runs before the first pass; undefined if it never passed. */
13 draftsToPass?: number
14 /** ms from the first scored run to the first pass. */
15 timeToPass?: number
16 /** Fix lines that went away between runs. */
17 fixesResolved: number
18}
19
20const scored = (samples: readonly Sample[]) => samples.filter(one => one.status === 'pass' || one.status === 'fail')
21
22export function packStats(pack: PackId, samples: readonly Sample[]): PackStats {
23 const runs = scored(samples)
24 const firstPass = runs.findIndex(one => one.status === 'pass')
25 const numbers = runs.filter(one => one.score !== undefined)
26 let fixesResolved = 0
27 for (let i = 1; i < runs.length; i += 1) {
28 const drop = (runs[i - 1]?.fixes ?? 0) - (runs[i]?.fixes ?? 0)
29 if (drop > 0) fixesResolved += drop
30 }
31 return {
32 pack,
33 samples: runs.length,
34 ...(numbers[0]?.score !== undefined ? { first: numbers[0].score } : {}),
35 ...(numbers.at(-1)?.score !== undefined ? { last: numbers.at(-1)?.score } : {}),
36 ...(firstPass >= 0 ? { draftsToPass: firstPass, timeToPass: (runs[firstPass]?.at ?? 0) - (runs[0]?.at ?? 0) } : {}),
37 fixesResolved,
38 }
39}
40
41export function allStats(history: History): PackStats[] {
42 return PACKS.map(pack => packStats(pack.id, history[pack.id] ?? [])).filter(one => one.samples > 0)
43}
44
45export function duration(ms: number): string {
46 const minutes = Math.round(ms / 60_000)
47 if (minutes < 60) return `${minutes} min`
48 const hours = Math.round(minutes / 60)
49 return hours < 48 ? `${hours} h` : `${Math.round(hours / 24)} days`
50}
51
52/** The $.store key for one project's outcome log. */
53export function outcomesKey(root: string): string {
54 return `outcomes:${root}`
55}
56
57/** Adds an outcome entry; a count that is not a whole number of 0 or more is refused. */
58export function addOutcome(log: readonly Outcome[], entry: Outcome): Outcome[] | undefined {
59 if (!Number.isInteger(entry.count) || entry.count < 0) return undefined
60 return [...log, entry].slice(-200)
61}
62
63/** Totals per letter version: replies and meetings logged while that version was live. */
64export function outcomesByVersion(log: readonly Outcome[]): { version: string; score?: number; replies: number; meetings: number }[] {
65 const rows = new Map<string, { version: string; score?: number; replies: number; meetings: number }>()
66 for (const entry of log) {
67 const row = rows.get(entry.version) ?? { version: entry.version, replies: 0, meetings: 0 }
68 if (entry.kind === 'replies') row.replies += entry.count
69 else row.meetings += entry.count
70 if (entry.score !== undefined) row.score = entry.score
71 rows.set(entry.version, row)
72 }
73 return [...rows.values()]
74}
75
76export type OutcomeRow = { at: number; kind: Outcome['kind']; count: number }
77
78/** Splits one CSV line, honouring double quotes. */
79function csvCells(line: string): string[] {
80 const cells: string[] = []
81 let cell = ''
82 let quoted = false
83 for (let i = 0; i < line.length; i += 1) {
84 const ch = line[i]
85 if (quoted && ch === '"' && line[i + 1] === '"') { cell += '"'; i += 1 }
86 else if (ch === '"') quoted = !quoted
87 else if (ch === ',' && !quoted) { cells.push(cell.trim()); cell = '' }
88 else cell += ch
89 }
90 cells.push(cell.trim())
91 return cells
92}
93
94/**
95 * Reads an outcome CSV: a date column (`date`, `week` or `day`) and a
96 * `replies` and/or `meetings` column of whole numbers. Blank cells are skipped;
97 * a bad row is named in `errors` and left out.
98 */
99export function parseOutcomeCsv(text: string): { rows: OutcomeRow[]; errors: string[] } {
100 const lines = text.split(/\r?\n/)
101 const header = csvCells(lines[0] ?? '').map(one => one.toLowerCase())
102 const dateAt = header.findIndex(one => one === 'date' || one === 'week' || one === 'day')
103 const kinds = (['replies', 'meetings'] as const).map(kind => ({ kind, at: header.indexOf(kind) })).filter(one => one.at >= 0)
104 if (dateAt < 0 || kinds.length === 0) return { rows: [], errors: ['no date column, or no replies or meetings column'] }
105 const rows: OutcomeRow[] = []
106 const errors: string[] = []
107 lines.slice(1).forEach((line, i) => {
108 if (line.trim() === '') return
109 const cells = csvCells(line)
110 const raw = cells[dateAt] ?? ''
111 const at = Date.parse(raw)
112 if (Number.isNaN(at)) { errors.push(`line ${i + 2}: "${raw}" is not a date`); return }
113 const found: OutcomeRow[] = []
114 for (const { kind, at: column } of kinds) {
115 const cell = cells[column] ?? ''
116 if (cell === '') continue
117 if (!/^\d+$/.test(cell)) { errors.push(`line ${i + 2}: ${kind} "${cell}" is not a whole number`); continue }
118 found.push({ at, kind, count: Number(cell) })
119 }
120 rows.push(...found)
121 })
122 return { rows, errors }
123}
124
125/** Adds imported rows to the log; an imported row for the same day and kind replaces the old one. */
126export function importOutcomes(log: readonly Outcome[], rows: readonly OutcomeRow[]): Outcome[] {
127 const same = (a: { at: number; kind: string }, b: { at: number; kind: string }) => a.at === b.at && a.kind === b.kind
128 const kept = log.filter(entry => entry.version !== 'imported' || !rows.some(row => same(row, entry)))
129 return [...kept, ...rows.map(row => ({ ...row, version: 'imported' }))].slice(-200)
130}
131
132const WEEK = 7 * 86_400_000
133
134const date = (ms: number) => new Date(ms).toISOString().slice(0, 10)
135
136/** The weekly digest, as Markdown. */
137export function digest(input: {
138 now: number
139 history: History
140 scores: Scores
141 findings: readonly Finding[]
142 outcomes: readonly Outcome[]
143 nextCommand?: string
144}): string {
145 const since = input.now - WEEK
146 const lines = [`# GTM weekly digest, ${date(since)} to ${date(input.now)}`, '']
147
148 const moved = PACKS.flatMap(pack => {
149 const week = scored(input.history[pack.id] ?? []).filter(one => one.at >= since)
150 const before = scored(input.history[pack.id] ?? []).filter(one => one.at < since).at(-1)
151 const start = before ?? week[0]
152 const end = week.at(-1)
153 if (start === undefined || end === undefined || week.length === 0) return []
154 const from = start.score ?? start.status
155 const to = end.score ?? end.status
156 return from === to ? [`- ${pack.name}: ${to} (no change)`] : [`- ${pack.name}: ${from} → ${to}`]
157 })
158 lines.push('## What moved', ...(moved.length > 0 ? moved : ['- No drafts were scored this week.']), '')
159
160 const failing = PACKS.flatMap(pack => {
161 const score = input.scores[pack.id]
162 if (score?.status !== 'fail') return []
163 return [`- ${pack.name}: ${scoreLabel(score)}${score.fixes[0] ? `. First fix: ${score.fixes[0]}` : ''}`]
164 })
165 lines.push('## What needs work', ...(failing.length > 0 ? failing : ['- Every scored draft passes.']), '')
166
167 const resolved = allStats(input.history).reduce((sum, one) => sum + one.fixesResolved, 0)
168 lines.push(`Fixes resolved so far: ${resolved}.`, '')
169
170 if (input.findings.length > 0) {
171 lines.push('## Cross-pack warnings', ...input.findings.map(one => `- ${one.message}`), '')
172 }
173
174 const week = input.outcomes.filter(one => one.at >= since)
175 if (week.length > 0) {
176 const replies = week.filter(one => one.kind === 'replies').reduce((sum, one) => sum + one.count, 0)
177 const meetings = week.filter(one => one.kind === 'meetings').reduce((sum, one) => sum + one.count, 0)
178 lines.push('## Outcomes you logged', `- Replies: ${replies}`, `- Meetings: ${meetings}`, '')
179 }
180
181 if (input.nextCommand) lines.push(`Next: ${input.nextCommand}`)
182 return `${lines.join('\n').trimEnd()}\n`
183}
184
185// --- Guided sprint ---------------------------------------------------------
186
187export type SprintVerdict =
188 | { kind: 'advance'; next: PackId }
189 | { kind: 'done' }
190 | { kind: 'pause'; reason: string }
191
192/** After a sprint step's turn: go on to the next pack, finish, or pause on a failing draft. */
193export function sprintStep(current: PackId, target: number, scores: Scores): SprintVerdict {
194 const pack = PACKS.find(one => one.id === current)
195 if (pack === undefined) return { kind: 'pause', reason: `unknown step ${current}` }
196 const score = scores[pack.id]
197 if (score === undefined || score.status === 'missing') {
198 return { kind: 'pause', reason: `${pack.name} has no draft yet` }
199 }
200 if (score.status === 'fail') {
201 return { kind: 'pause', reason: `${pack.name} fails its scorer (${scoreLabel(score)})` }
202 }
203 const next = PACKS.find(one => one.step > pack.step && one.step <= target)
204 return next === undefined ? { kind: 'done' } : { kind: 'advance', next: next.id }
205}
206hooks/drift.ts 199 lines1// What no single pack can see: drafts built on an older PSP, EVP or price,
2// drafts that disagree with each other, and buyers the list leaves out.
3// Every check is a heuristic, so every finding is a warning.
4import type { Finding, PackId } from '../types'
5import { field } from './gtm'
6import type { Config } from './gtm'
7import { packById } from './packs'
8import { draftText } from './voice'
9
10/** Shared upstream inputs a draft is built from. */
11export type Upstream = 'psp' | 'evp' | 'price'
12
13/** Which upstream inputs each draft reads (the suite README's reads table). */
14export const READS: Partial<Record<PackId, readonly Upstream[]>> = {
15 list: ['psp'],
16 letter: ['psp', 'evp'],
17 offer: ['psp', 'evp'],
18 price: ['psp'],
19 page: ['evp', 'price'],
20 sequence: ['psp', 'evp'],
21 findability: ['psp'],
22}
23
24const UPSTREAM_NAME: Record<Upstream, string> = { psp: 'PSP', evp: 'EVP', price: 'price' }
25
26export type DraftFile = { text: string; mtimeMs: number }
27
28export type Snapshot = {
29 config: Config
30 /** Drafts by pack, when present. */
31 drafts: Partial<Record<PackId, DraftFile>>
32 /** When each upstream input last changed, ms; 0 when unknown. */
33 changedAt: Partial<Record<Upstream, number>>
34}
35
36const ago = (ms: number) => {
37 const minutes = Math.round(ms / 60_000)
38 if (minutes < 60) return `${minutes} min`
39 const hours = Math.round(minutes / 60)
40 return hours < 48 ? `${hours} h` : `${Math.round(hours / 24)} days`
41}
42
43export function staleFindings(snapshot: Snapshot): Finding[] {
44 const findings: Finding[] = []
45 for (const [id, reads] of Object.entries(READS) as [PackId, readonly Upstream[]][]) {
46 const draft = snapshot.drafts[id]
47 const pack = packById(id)
48 if (draft === undefined || pack === undefined) continue
49 const newer = reads.filter(up => (snapshot.changedAt[up] ?? 0) > draft.mtimeMs)
50 if (newer.length === 0) continue
51 const latest = Math.max(...newer.map(up => snapshot.changedAt[up] ?? 0))
52 const names = newer.map(up => UPSTREAM_NAME[up]).join(' and ')
53 findings.push({
54 id: `stale-${id}`,
55 kind: 'stale',
56 packs: [id],
57 message: `${pack.name} was written before your ${names} changed (${ago(latest - draft.mtimeMs)} later).`,
58 fix: `${pack.command} Update the draft to match the current ${names} in brand-config.json.`,
59 })
60 }
61 return findings
62}
63
64const STOP = new Set(['about', 'after', 'again', 'their', 'there', 'these', 'those', 'which', 'while', 'with', 'without',
65 'from', 'that', 'this', 'what', 'when', 'where', 'your', 'have', 'into', 'than', 'then', 'they', 'them', 'were',
66 'will', 'would', 'could', 'should', 'does', 'only', 'just', 'more', 'most', 'over', 'such', 'very', 'also'])
67
68export function contentWords(text: string): Set<string> {
69 return new Set((text.toLowerCase().match(/[a-z][a-z0-9-]{3,}/g) ?? []).filter(word => !STOP.has(word)))
70}
71
72function overlap(a: string, b: string): number {
73 const wordsA = contentWords(a)
74 const wordsB = contentWords(b)
75 if (wordsA.size === 0) return 1
76 let shared = 0
77 for (const word of wordsA) if (wordsB.has(word)) shared += 1
78 return shared / wordsA.size
79}
80
81const text = (value: unknown) => (typeof value === 'string' ? value : '')
82
83function parse(draft: DraftFile | undefined): unknown {
84 if (draft === undefined) return undefined
85 try {
86 return JSON.parse(draft.text) as unknown
87 } catch {
88 return undefined
89 }
90}
91
92/** Tier names from gtm/price.json (contrast_set) or gtm/tiers.json shapes. */
93function tierNames(price: unknown): string[] {
94 if (price === null || typeof price !== 'object') return []
95 const record = price as Record<string, unknown>
96 if (Array.isArray(record.contrast_set)) return record.contrast_set.filter((one): one is string => typeof one === 'string')
97 if (Array.isArray(record.tiers)) {
98 return record.tiers.flatMap(one => (one && typeof one === 'object' && typeof (one as { name?: unknown }).name === 'string'
99 ? [(one as { name: string }).name] : []))
100 }
101 return []
102}
103
104/** Vocabulary phrases from the PSP block (an array, or a comma list). */
105export function vocabulary(config: Config): string[] {
106 const value = field(config, 'psp.vocabulary')
107 const list = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[,;\n]/) : []
108 return list.map(one => (typeof one === 'string' ? one.trim() : '')).filter(one => one.length >= 3)
109}
110
111export function consistencyFindings(snapshot: Snapshot): Finding[] {
112 const findings: Finding[] = []
113 const evp = text(field(snapshot.config, 'evp.primary'))
114 const page = snapshot.drafts.page
115 if (evp !== '' && page !== undefined && overlap(evp, draftText(page.text)) < 0.25) {
116 findings.push({
117 id: 'page-evp',
118 kind: 'consistency',
119 packs: ['page', 'evp'],
120 message: 'The landing page shares few words with your EVP line, so the page may not lead with it.',
121 fix: `/landing-page:page Make the page's offer lead with the EVP line: "${evp}"`,
122 })
123 }
124
125 const tiers = tierNames(parse(snapshot.drafts.price))
126 if (tiers.length > 0 && page !== undefined) {
127 const pageText = draftText(page.text).toLowerCase()
128 const missing = tiers.filter(name => !pageText.includes(name.toLowerCase()))
129 if (missing.length > 0) {
130 findings.push({
131 id: 'page-tiers',
132 kind: 'consistency',
133 packs: ['page', 'price'],
134 message: `Price tiers missing from the landing page: ${missing.join(', ')}.`,
135 fix: `/landing-page:page Name the price tiers from gtm/price.json on the page: ${tiers.join(', ')}.`,
136 })
137 }
138 }
139
140 const phrases = vocabulary(snapshot.config)
141 for (const id of ['letter', 'sequence'] as const) {
142 const draft = snapshot.drafts[id]
143 const pack = packById(id)
144 if (draft === undefined || pack === undefined || phrases.length === 0) continue
145 const body = draftText(draft.text).toLowerCase()
146 if (!phrases.some(phrase => body.includes(phrase.toLowerCase()))) {
147 findings.push({
148 id: `vocabulary-${id}`,
149 kind: 'consistency',
150 packs: [id, 'psp'],
151 message: `${pack.name} uses none of the buyer's words from your PSP (${phrases.slice(0, 3).map(one => `"${one}"`).join(', ')}).`,
152 fix: `${pack.command} Use at least one of the buyer's own phrases from psp.vocabulary: ${phrases.join(', ')}.`,
153 })
154 }
155 }
156 return findings
157}
158
159/** Entries in gtm/list.json: one object or an array of them. */
160export function listEntries(list: DraftFile | undefined): Record<string, unknown>[] {
161 const parsed = parse(list)
162 const items = Array.isArray(parsed) ? parsed : parsed && typeof parsed === 'object'
163 ? (Array.isArray((parsed as { accounts?: unknown }).accounts) ? (parsed as { accounts: unknown[] }).accounts : [parsed])
164 : []
165 return items.filter((one): one is Record<string, unknown> => one !== null && typeof one === 'object')
166}
167
168export function coverageFindings(snapshot: Snapshot): Finding[] {
169 const roles = field(snapshot.config, 'icp.role_targets')
170 const list = snapshot.drafts.list
171 if (!Array.isArray(roles) || list === undefined) return []
172 const titles = listEntries(list).map(one => `${text(one.title)} ${text(one.role)}`.toLowerCase())
173 const missing = roles
174 .filter((role): role is string => typeof role === 'string' && role.trim() !== '')
175 .filter(role => {
176 const words = [...contentWords(role), ...(role.toLowerCase().match(/\b[a-z]{2,3}\b/g) ?? [])]
177 return !titles.some(title => words.some(word => new RegExp(`\\b${word}\\b`).test(title)))
178 })
179 return missing.length === 0 ? [] : [{
180 id: 'coverage-roles',
181 kind: 'coverage',
182 packs: ['list'],
183 message: `No prospect on the list for: ${missing.join(', ')} (from icp.role_targets).`,
184 fix: `/prospect-list:who-to-contact Add accounts with a signal for: ${missing.join(', ')}.`,
185 }]
186}
187
188export function allFindings(snapshot: Snapshot): Finding[] {
189 return [...staleFindings(snapshot), ...consistencyFindings(snapshot), ...coverageFindings(snapshot)]
190}
191
192/** A stable hash of a JSON value, for noticing when a block changes. */
193export function hashOf(value: unknown): string {
194 const textValue = JSON.stringify(value ?? null)
195 let hash = 5381
196 for (let i = 0; i < textValue.length; i += 1) hash = ((hash << 5) + hash + textValue.charCodeAt(i)) | 0
197 return (hash >>> 0).toString(36)
198}
199hooks/gtm.ts 181 lines1// The suite's state, read the way /gtm:next reads it (suite/skills/next/SKILL.md).
2import type { GtmBoard, GtmStep } from '../types'
3
4export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
5export type Config = { [key: string]: Json }
6
7const WEEK_MS = 7 * 24 * 60 * 60 * 1000
8
9export const PACKS: Record<string, string> = {
10 '/gtm:setup': 'gtm',
11 '/psp:psp': 'psp',
12 '/evp:evp': 'evp',
13 '/prospect-list:who-to-contact': 'prospect-list',
14 '/cold-email:cold-email': 'cold-email',
15 '/sales-offer:cold-offer': 'sales-offer',
16 '/pricing:pricing': 'pricing',
17 '/landing-page:page': 'landing-page',
18 '/email-sequence:lifecycle-email': 'email-sequence',
19 '/geo:geo': 'geo',
20 '/founder-brand:founder-brand': 'founder-brand',
21}
22
23export function field(config: Config, path: string): Json | undefined {
24 let at: Json | undefined = config
25 for (const part of path.split('.')) {
26 if (at === null || typeof at !== 'object' || Array.isArray(at)) return undefined
27 at = at[part]
28 }
29 return at
30}
31
32export function isFilled(value: Json | undefined): boolean {
33 if (value === undefined || value === null) return false
34 if (typeof value === 'string') return value.trim() !== ''
35 if (Array.isArray(value)) return value.length > 0
36 if (typeof value === 'object') return Object.values(value).some(isFilled)
37 return true
38}
39
40export function parseConfig(text: string | undefined): Config | undefined {
41 if (text === undefined) return undefined
42 try {
43 const parsed: unknown = JSON.parse(text)
44 return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
45 ? (parsed as Config)
46 : undefined
47 } catch {
48 return undefined
49 }
50}
51
52export type ScanInput = {
53 config: Config | undefined
54 hasConfigFile: boolean
55 gtmFiles: string[]
56 hasGtmDir: boolean
57 newestDraftMs: number
58 now: number
59}
60
61export function buildBoard(input: ScanInput): GtmBoard {
62 const c = input.config ?? {}
63 const has = (name: string) => input.gtmFiles.includes(name)
64 const filled = (path: string) => isFilled(field(c, path))
65 const text = (path: string) => {
66 const value = field(c, path)
67 return typeof value === 'string' ? value : ''
68 }
69
70 const step = (
71 n: number,
72 name: string,
73 check: string,
74 command: string,
75 why: string,
76 isDone: boolean,
77 ): GtmStep => ({ n, name, check, command, why, isDone })
78
79 const steps: GtmStep[] = [
80 step(0, 'Setup', 'operator.name + icp.segment', '/gtm:setup',
81 'no operator or buyer segment yet', filled('operator.name') && filled('icp.segment')),
82 step(1, 'Profile (PSP)', 'psp.primary_pain + psp.signal_anchors', '/psp:psp',
83 'no locked pain signal profile yet', filled('psp.primary_pain') && filled('psp.signal_anchors')),
84 step(2, 'Value line (EVP)', 'evp.primary', '/evp:evp',
85 'no value line yet', filled('evp.primary')),
86 step(3, 'Prospect list', 'gtm/list.json', '/prospect-list:who-to-contact',
87 'no list of who to contact', has('list.json')),
88 step(4, 'First touch', 'gtm/letter.json or tone + infrastructure', '/cold-email:cold-email',
89 'no first-touch letter yet', has('letter.json') || (filled('tone') && filled('infrastructure'))),
90 step(5, 'Offer', 'gtm/offer.json', '/sales-offer:cold-offer',
91 'no give-first offer yet', has('offer.json')),
92 step(6, 'Pricing', 'pricing.currentTiers or gtm/price.json', '/pricing:pricing',
93 'no price tiers yet', filled('pricing.currentTiers') || has('price.json')),
94 step(7, 'Landing page', 'gtm/page.json', '/landing-page:page',
95 'no landing page draft yet', has('page.json')),
96 step(8, 'Email sequence', 'gtm/sequence.json', '/email-sequence:lifecycle-email',
97 'no opt-in sequence yet', has('sequence.json')),
98 step(9, 'Findability (GEO)', 'gtm/findability.json', '/geo:geo',
99 'no findability check yet', has('findability.json')),
100 step(10, 'Founder posts', 'drafts/ post in last 7 days', '/founder-brand:founder-brand',
101 'no founder post this week',
102 input.newestDraftMs > 0 && input.now - input.newestDraftMs <= WEEK_MS),
103 ]
104
105 return {
106 hasProject: input.hasConfigFile || input.hasGtmDir,
107 steps,
108 operator: [text('operator.name'), text('operator.company')].filter(Boolean).join(', '),
109 segment: text('icp.segment'),
110 pain: text('psp.primary_pain'),
111 valueLine: text('evp.primary'),
112 checkedAt: input.now,
113 }
114}
115
116export function nextStep(board: GtmBoard): GtmStep | undefined {
117 return board.steps.find(one => !one.isDone)
118}
119
120export function doneCount(board: GtmBoard): number {
121 return board.steps.filter(one => one.isDone).length
122}
123
124export function summary(board: GtmBoard): string {
125 const next = nextStep(board)
126 const done = board.steps.filter(one => one.isDone).map(one => one.n).join(', ') || 'none'
127 const nextLine = next === undefined
128 ? 'Next: every step has its file or field. Refresh the founder post weekly.'
129 : `Next: ${next.command} — ${next.why} (checks ${next.check})`
130 return `Done: ${done}\n${nextLine}`
131}
132
133// Merge, never overwrite: every filled value in the old config must survive.
134function sameJson(a: Json | undefined, b: Json | undefined): boolean {
135 return JSON.stringify(a) === JSON.stringify(b)
136}
137
138export function mergeViolations(before: Config, after: Config, prefix = ''): string[] {
139 const lost: string[] = []
140 for (const [key, old] of Object.entries(before)) {
141 if (!isFilled(old)) continue
142 const path = prefix + key
143 const now = after[key]
144 if (old !== null && typeof old === 'object' && !Array.isArray(old)) {
145 if (now === null || typeof now !== 'object' || Array.isArray(now)) {
146 lost.push(path)
147 } else {
148 lost.push(...mergeViolations(old, now, `${path}.`))
149 }
150 } else if (Array.isArray(old)) {
151 const kept = Array.isArray(now) && old.every(item => now.some(one => sameJson(one, item)))
152 if (!kept) lost.push(path)
153 } else if (!sameJson(old, now)) {
154 lost.push(path)
155 }
156 }
157 return lost
158}
159
160export function soulSections(text: string): string[] {
161 return text
162 .split('\n')
163 .filter(line => /^##\s+\S/.test(line))
164 .map(line => line.replace(/^##\s+/, '').trim())
165}
166
167export function soulViolations(before: string, after: string): string[] {
168 const kept = new Set(soulSections(after))
169 return soulSections(before).filter(name => !kept.has(name))
170}
171
172export function applyEdit(
173 text: string,
174 oldString: string,
175 newString: string,
176 isAll: boolean,
177): string | undefined {
178 if (!text.includes(oldString)) return undefined
179 return isAll ? text.split(oldString).join(newString) : text.replace(oldString, () => newString)
180}
181hooks/history.ts 39 lines1// Score history per project and pack, kept in $.store across sessions.
2import type { History, PackId, Sample } from '../types'
3
4const KEEP = 60
5
6/** The $.store key for one project's history. */
7export function historyKey(root: string): string {
8 return `history:${root}`
9}
10
11/** Adds a sample, skipping one identical to the last, and keeps the newest 60. */
12export function addSample(history: History, pack: PackId, sample: Sample): History {
13 const list = history[pack] ?? []
14 const last = list[list.length - 1]
15 const isSame = last !== undefined && last.score === sample.score && last.status === sample.status &&
16 last.fixes === sample.fixes
17 if (isSame) return history
18 return { ...history, [pack]: [...list, sample].slice(-KEEP) }
19}
20
21const BLOCKS = '▁▂▃▄▅▆▇█'
22
23/** A one-line trend of scores; pass counts as 100 and fail as 0 when no number. */
24export function sparkline(samples: readonly Sample[], width = 16): string {
25 const values = samples
26 .filter(one => one.status === 'pass' || one.status === 'fail')
27 .map(one => one.score ?? (one.status === 'pass' ? 100 : 0))
28 .slice(-width)
29 return values.map(value => BLOCKS[Math.min(7, Math.floor((value / 100) * 7.999))]).join('')
30}
31
32/** Change between the first and last numbered score, if both exist. */
33export function trend(samples: readonly Sample[]): number | undefined {
34 const scored = samples.filter(one => one.score !== undefined)
35 const first = scored[0]?.score
36 const last = scored[scored.length - 1]?.score
37 return first !== undefined && last !== undefined && scored.length > 1 ? last - first : undefined
38}
39hooks/locate.ts 65 lines1// Where a pack's scorer can live on this machine. The hooks module walks the
2// directories (it holds $); this file only says which paths to try, in order.
3import type { PackDef } from './packs'
4
5export type Bases = {
6 home: string | undefined
7 root: string
8 /** The packsDir setting: a folder holding the pack repos. */
9 packsDir: string
10 /** Installed plugin version folders per plugin name, newest first. */
11 cacheVersions: Readonly<Record<string, readonly string[]>>
12}
13
14const join = (...parts: string[]) =>
15 parts.filter(part => part !== '').join('/').replace(/\/+/g, '/')
16
17/** Repo-root folders that may hold this pack, best first. */
18export function repoRoots(pack: PackDef, bases: Bases): string[] {
19 const roots: string[] = []
20 if (bases.packsDir !== '') {
21 roots.push(join(bases.packsDir, pack.repo), join(bases.packsDir, pack.plugin))
22 }
23 roots.push(...(bases.cacheVersions[pack.plugin] ?? []))
24 return roots
25}
26
27/** Installed skill folders that may hold this pack, best first. */
28export function skillRoots(pack: PackDef, bases: Bases): string[] {
29 const roots = [join(bases.root, '.claude/skills', pack.skill)]
30 if (bases.home !== undefined) {
31 roots.push(join(bases.home, '.claude/skills', pack.skill), join(bases.home, '.agents/skills', pack.skill))
32 }
33 return roots
34}
35
36/** Every path to try for the scorer, in order. */
37export function scorerCandidates(pack: PackDef, bases: Bases): string[] {
38 const inSkill = pack.skillScorer ?? pack.scorer
39 const fileName = pack.scorer.slice(pack.scorer.lastIndexOf('/') + 1)
40 const paths = [
41 ...repoRoots(pack, bases).map(root => join(root, pack.scorer)),
42 ...skillRoots(pack, bases).flatMap(root => [join(root, inSkill), join(root, 'scripts', fileName)]),
43 ]
44 return [...new Set(paths)]
45}
46
47/** Sorts version folder names newest first (semver-ish, then by name). */
48export function newestFirst(names: readonly string[]): string[] {
49 const parts = (name: string) => name.split(/[.-]/).map(part => (/^\d+$/.test(part) ? Number(part) : part))
50 return [...names].sort((a, b) => {
51 const pa = parts(a)
52 const pb = parts(b)
53 for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
54 const x = pa[i]
55 const y = pb[i]
56 if (x === y) continue
57 if (x === undefined) return 1
58 if (y === undefined) return -1
59 if (typeof x === 'number' && typeof y === 'number') return y - x
60 return String(y).localeCompare(String(x))
61 }
62 return 0
63 })
64}
65hooks/packs.ts 182 lines1// The ten packs of the GTM operator suite: where each scorer lives in its
2// repo, and which project file or field it scores.
3import type { PackId } from '../types'
4
5/** What a scorer reads from the project. */
6export type DraftSource =
7 | { kind: 'file'; path: string }
8 | { kind: 'config'; field: string }
9 | { kind: 'config-text'; field: string }
10 | { kind: 'latest'; dir: string; ext: readonly string[] }
11
12export type PackDef = {
13 id: PackId
14 /** Step in the suite walk (matches GtmStep.n). */
15 step: number
16 name: string
17 plugin: string
18 repo: string
19 /** The skill directory name, as the skills CLI installs it. */
20 skill: string
21 command: string
22 draft: DraftSource
23 /** Scorer path relative to the repo root. */
24 scorer: string
25 /** Scorer path relative to the installed skill directory, when it differs. */
26 skillScorer?: string
27 /** Builds the scorer's arguments for one input. */
28 args: (input: ScorerInput) => string[]
29}
30
31export type ScorerInput = { file: string } | { stdin: string } | { text: string }
32
33const fileOrStdin = (input: ScorerInput): string[] =>
34 'file' in input ? ['--file', input.file] : ['--stdin']
35
36export const PACKS: readonly PackDef[] = [
37 {
38 id: 'psp',
39 step: 1,
40 name: 'Profile (PSP)',
41 plugin: 'psp',
42 repo: 'claude-psp',
43 skill: 'psp',
44 command: '/psp:psp',
45 draft: { kind: 'config', field: 'psp' },
46 scorer: 'scripts/score_psp.py',
47 args: input =>
48 'file' in input ? ['--file', input.file, '--json-path', 'psp'] : ['--stdin'],
49 },
50 {
51 id: 'evp',
52 step: 2,
53 name: 'Value line (EVP)',
54 plugin: 'evp',
55 repo: 'claude-evp',
56 skill: 'evp',
57 command: '/evp:evp',
58 draft: { kind: 'config-text', field: 'evp.primary' },
59 scorer: 'scripts/score_evp.py',
60 args: input =>
61 'text' in input ? ['--evp', input.text] : fileOrStdin(input),
62 },
63 {
64 id: 'list',
65 step: 3,
66 name: 'Prospect list',
67 plugin: 'prospect-list',
68 repo: 'claude-prospect-list',
69 skill: 'who-to-contact',
70 command: '/prospect-list:who-to-contact',
71 draft: { kind: 'file', path: 'gtm/list.json' },
72 scorer: 'skills/who-to-contact/scripts/score.py',
73 skillScorer: 'scripts/score.py',
74 args: fileOrStdin,
75 },
76 {
77 id: 'letter',
78 step: 4,
79 name: 'First touch',
80 plugin: 'cold-email',
81 repo: 'claude-cold-email',
82 skill: 'cold-email',
83 command: '/cold-email:cold-email',
84 draft: { kind: 'file', path: 'gtm/letter.json' },
85 scorer: 'scripts/score_letter.py',
86 args: fileOrStdin,
87 },
88 {
89 id: 'offer',
90 step: 5,
91 name: 'Offer',
92 plugin: 'sales-offer',
93 repo: 'claude-sales-offer',
94 skill: 'cold-offer',
95 command: '/sales-offer:cold-offer',
96 draft: { kind: 'file', path: 'gtm/offer.json' },
97 scorer: 'skills/cold-offer/scripts/score.py',
98 skillScorer: 'scripts/score.py',
99 args: fileOrStdin,
100 },
101 {
102 id: 'price',
103 step: 6,
104 name: 'Pricing',
105 plugin: 'pricing',
106 repo: 'claude-pricing',
107 skill: 'pricing',
108 command: '/pricing:pricing',
109 draft: { kind: 'file', path: 'gtm/price.json' },
110 scorer: 'scripts/score_price.py',
111 args: fileOrStdin,
112 },
113 {
114 id: 'page',
115 step: 7,
116 name: 'Landing page',
117 plugin: 'landing-page',
118 repo: 'claude-landing-page',
119 skill: 'page',
120 command: '/landing-page:page',
121 draft: { kind: 'file', path: 'gtm/page.json' },
122 scorer: 'scripts/score.py',
123 args: fileOrStdin,
124 },
125 {
126 id: 'sequence',
127 step: 8,
128 name: 'Email sequence',
129 plugin: 'email-sequence',
130 repo: 'claude-email-sequence',
131 skill: 'lifecycle-email',
132 command: '/email-sequence:lifecycle-email',
133 draft: { kind: 'file', path: 'gtm/sequence.json' },
134 scorer: 'scripts/score.py',
135 args: fileOrStdin,
136 },
137 {
138 id: 'findability',
139 step: 9,
140 name: 'Findability (GEO)',
141 plugin: 'geo',
142 repo: 'claude-geo',
143 skill: 'geo',
144 command: '/geo:geo',
145 draft: { kind: 'file', path: 'gtm/findability.json' },
146 scorer: 'scripts/score.py',
147 args: fileOrStdin,
148 },
149 {
150 id: 'posts',
151 step: 10,
152 name: 'Founder posts',
153 plugin: 'founder-brand',
154 repo: 'claude-founder-brand',
155 skill: 'founder-brand',
156 command: '/founder-brand:founder-brand',
157 draft: { kind: 'latest', dir: 'drafts', ext: ['.md', '.txt'] },
158 scorer: 'scripts/score_post.py',
159 // --stdin takes JSON ({ "post": ... }); a Markdown post goes in --post.
160 args: input =>
161 'file' in input ? ['--file', input.file]
162 : 'text' in input ? ['--post', input.text]
163 : input.stdin.trimStart().startsWith('{') ? ['--stdin'] : ['--post', input.stdin],
164 },
165]
166
167export function packById(id: string): PackDef | undefined {
168 return PACKS.find(pack => pack.id === id)
169}
170
171/** The pack whose draft lives at this project-relative path, if any. */
172export function packForPath(rel: string): PackDef | undefined {
173 return PACKS.find(pack => {
174 const draft = pack.draft
175 if (draft.kind === 'file') return draft.path === rel
176 if (draft.kind === 'latest') {
177 return rel.startsWith(`${draft.dir}/`) && draft.ext.some(ext => rel.endsWith(ext))
178 }
179 return false
180 })
181}
182hooks/score.ts 128 lines1// One score shape for every pack's scorer. Scorers print JSON with --json, but
2// each names its verdict differently (ok, pass, total/max_total) and its
3// problems differently (reasons, failures, problems, blockers).
4import type { Axis, PackId, Score } from '../types'
5
6type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
7type Obj = { [key: string]: Json }
8
9const isObj = (value: unknown): value is Obj =>
10 value !== null && typeof value === 'object' && !Array.isArray(value)
11
12function text(value: Json | undefined): string | undefined {
13 if (typeof value === 'string') return value.trim() === '' ? undefined : value.trim()
14 if (isObj(value)) {
15 const head = text(value.message) ?? text(value.problem) ?? text(value.check)
16 const field = text(value.field)
17 const detail = text(value.detail)
18 const line = [field && head ? `${field}: ${head}` : head ?? field, detail].filter(Boolean).join(' (')
19 return line === '' ? undefined : detail ? `${line})` : line
20 }
21 return undefined
22}
23
24function lines(value: Json | undefined): string[] {
25 return Array.isArray(value) ? value.map(text).filter((one): one is string => one !== undefined) : []
26}
27
28/** Fixes nested inside problem objects (sales offer, GEO). */
29function nestedFixes(value: Json | undefined): string[] {
30 if (!Array.isArray(value)) return []
31 return value.flatMap(one => (isObj(one) && typeof one.fix === 'string' ? [one.fix] : []))
32}
33
34/** The first JSON object in a scorer's stdout, or undefined. */
35export function parseJson(stdout: string): Obj | undefined {
36 const start = stdout.indexOf('{')
37 if (start < 0) return undefined
38 try {
39 const parsed: unknown = JSON.parse(stdout.slice(start))
40 return isObj(parsed) ? parsed : undefined
41 } catch {
42 return undefined
43 }
44}
45
46export function normalize(pack: PackId, stdout: string, exitCode: number, at: number): Score {
47 const raw = parseJson(stdout)
48 if (raw === undefined) {
49 return {
50 pack,
51 status: 'unknown',
52 reasons: [],
53 fixes: [],
54 axes: [],
55 detail: exitCode === 0 ? 'the scorer printed no JSON' : `the scorer exited ${exitCode} with no JSON`,
56 at,
57 }
58 }
59
60 const total = typeof raw.total === 'number' ? raw.total : undefined
61 const max = typeof raw.max_total === 'number' && raw.max_total > 0 ? raw.max_total : undefined
62 const score = total !== undefined && max !== undefined ? Math.round((total / max) * 100) : undefined
63
64 const verdictFlag = typeof raw.ok === 'boolean' ? raw.ok : typeof raw.pass === 'boolean' ? raw.pass : undefined
65 const isPass = verdictFlag ?? exitCode === 0
66
67 const reasons = [
68 ...lines(raw.reasons),
69 ...(Array.isArray(raw.reasons) ? [] : [...lines(raw.failures), ...lines(raw.problems)]),
70 ]
71 const fixes = [...lines(raw.fixes), ...nestedFixes(raw.failures), ...nestedFixes(raw.problems)]
72 const axes: Axis[] = Array.isArray(raw.axes)
73 ? raw.axes.flatMap(one =>
74 isObj(one) && typeof one.name === 'string' && typeof one.score === 'number'
75 ? [{
76 name: one.name,
77 score: one.score,
78 max: typeof one.max_score === 'number' ? one.max_score : 0,
79 notes: lines(one.notes),
80 }]
81 : [],
82 )
83 : []
84
85 return {
86 pack,
87 status: isPass ? 'pass' : 'fail',
88 ...(score !== undefined ? { score } : {}),
89 reasons: [...new Set(reasons)],
90 fixes: [...new Set(fixes)],
91 axes,
92 ...(typeof raw.next === 'string' ? { next: raw.next } : {}),
93 ...(typeof raw.verdict === 'string' ? { verdict: raw.verdict } : {}),
94 at,
95 }
96}
97
98/** A short label for the board: "82/100 · 2 fixes", "pass", "fail · 3 fixes". */
99export function scoreLabel(score: Score | undefined): string {
100 if (score === undefined) return ''
101 if (score.status === 'missing' || score.status === 'unknown') return score.status
102 const head = score.score !== undefined ? `${score.score}/100` : score.status
103 const fixCount = score.fixes.length
104 return fixCount > 0 ? `${head} · ${fixCount} fix${fixCount === 1 ? '' : 'es'}` : head
105}
106
107/**
108 * What a gate refusal tells Claude to change: the scorer's fixes, else its
109 * reasons, else the axes furthest below their maximum. Never an empty list.
110 */
111export function gateLines(score: Score, limit = 5): string[] {
112 if (score.fixes.length > 0) return score.fixes.slice(0, limit)
113 if (score.reasons.length > 0) return score.reasons.slice(0, limit)
114 const short = [...score.axes]
115 .filter(axis => axis.score < axis.max)
116 .sort((a, b) => a.score / a.max - b.score / b.max)
117 .slice(0, limit)
118 .map(axis => `raise ${axis.name} (${axis.score}/${axis.max})${axis.notes[0] ? `: ${axis.notes[0]}` : ''}`)
119 return short.length > 0 ? short : ["run the pack's command to see what its scorer wants"]
120}
121
122/** The prompt "Fix with Claude" fills in. */
123export function fixPrompt(command: string, score: Score): string {
124 const items = score.fixes.length > 0 ? score.fixes : score.reasons
125 const list = items.map(line => `- ${line}`).join('\n')
126 return `${command} Fix the draft so the scorer passes. It reported:\n${list}`
127}
128hooks/voice.ts 69 lines1// SOUL.md voice check: phrases the operator refuses, found in any draft.
2
3/** Bullets under "## Phrases I refuse" (or "## Phrases I never use"). */
4export function refusedPhrases(soul: string): string[] {
5 const out: string[] = []
6 let isInSection = false
7 for (const line of soul.split('\n')) {
8 const heading = /^##\s+(.*)$/.exec(line)
9 if (heading) {
10 isInSection = /phrases i (refuse|never use)/i.test(heading[1] ?? '')
11 continue
12 }
13 if (!isInSection) continue
14 const bullet = /^\s*[-*]\s+(.+?)\s*$/.exec(line)
15 if (bullet?.[1] === undefined) continue
16 const phrase = bullet[1].replace(/^["'`]|["'`]$/g, '').trim()
17 if (phrase.length >= 3) out.push(phrase)
18 }
19 return [...new Set(out)]
20}
21
22/** Every string in a draft: the text of a .md file, or the string values of a JSON one. */
23export function draftText(raw: string): string {
24 const trimmed = raw.trimStart()
25 if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return raw
26 try {
27 const strings: string[] = []
28 const walk = (value: unknown): void => {
29 if (typeof value === 'string') strings.push(value)
30 else if (Array.isArray(value)) value.forEach(walk)
31 else if (value !== null && typeof value === 'object') Object.values(value).forEach(walk)
32 }
33 walk(JSON.parse(raw))
34 return strings.join('\n')
35 } catch {
36 return raw
37 }
38}
39
40/** A draft for the Detail tab: one `field: value` line per text field of a JSON draft; other drafts as they are. */
41export function draftExcerpt(raw: string): string {
42 const trimmed = raw.trimStart()
43 if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return raw
44 try {
45 const lines: string[] = []
46 const walk = (value: unknown, key: string): void => {
47 if (typeof value === 'string' || typeof value === 'number') {
48 lines.push(key === '' ? String(value) : `${key}: ${value}`)
49 } else if (Array.isArray(value)) {
50 if (value.every(one => typeof one === 'string' || typeof one === 'number')) {
51 if (value.length > 0) lines.push(`${key === '' ? '' : `${key}: `}${value.join(', ')}`)
52 } else value.forEach(one => walk(one, key))
53 } else if (value !== null && typeof value === 'object') {
54 Object.entries(value).forEach(([name, one]) => walk(one, name))
55 }
56 }
57 walk(JSON.parse(raw), '')
58 return lines.join('\n')
59 } catch {
60 return raw
61 }
62}
63
64/** The refused phrases a draft uses, whole-word and case-insensitive. */
65export function voiceHits(text: string, phrases: readonly string[]): string[] {
66 const escape = (phrase: string) => phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
67 return phrases.filter(phrase => new RegExp(`(^|\\W)${escape(phrase)}(\\W|$)`, 'i').test(text))
68}
69hooks/views.ts 290 lines1// View models for the pack views. Each reads a pack's own files or tool
2// output and returns plain data; register.tsx draws it.
3import type { DraftFile } from './drift'
4import { listEntries } from './drift'
5import { field } from './gtm'
6import type { Config } from './gtm'
7import { parseJson } from './score'
8
9const str = (value: unknown) => (typeof value === 'string' ? value : '')
10const num = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
11
12function parseAny(textValue: string | undefined): unknown {
13 if (textValue === undefined) return undefined
14 const start = textValue.search(/[[{]/)
15 if (start < 0) return undefined
16 try {
17 return JSON.parse(textValue.slice(start)) as unknown
18 } catch {
19 return undefined
20 }
21}
22
23// --- Pricing ---------------------------------------------------------------
24
25export type LeakStep = { name: string; leak: number }
26export type CustomerLeak = { id: string; list: number; pocket: number; leakPct: number; steps: LeakStep[] }
27export type TierCheck = { rule: string; passed: boolean; detail: string }
28export type PricingView = {
29 customers: CustomerLeak[]
30 totalList: number
31 totalPocket: number
32 /** Leak by discount step across customers, largest first. */
33 byStep: LeakStep[]
34 tiersScore?: number
35 checks: TierCheck[]
36 contrastSet: string[]
37 valueMetric: string
38}
39
40export function pricingView(waterfall: string | undefined, decoy: string | undefined, price: DraftFile | undefined): PricingView {
41 const wf = parseJson(waterfall ?? '')
42 const rows = Array.isArray(wf?.per_customer) ? wf.per_customer : []
43 const customers: CustomerLeak[] = rows.flatMap(row => {
44 if (row === null || typeof row !== 'object' || Array.isArray(row)) return []
45 const list = num(row.list_price_annual) ?? 0
46 const pocket = num(row.pocket_price_annual) ?? 0
47 const steps = Array.isArray(row.leak_by_step)
48 ? row.leak_by_step.flatMap(step => (step && typeof step === 'object' && !Array.isArray(step)
49 ? [{ name: str(step.name), leak: num(step.dollar_leak) ?? 0 }] : []))
50 : []
51 return [{ id: str(row.customer_id), list, pocket, leakPct: num(row.leak_pct) ?? 0, steps }]
52 })
53 const byStepMap = new Map<string, number>()
54 for (const customer of customers) {
55 for (const step of customer.steps) byStepMap.set(step.name, (byStepMap.get(step.name) ?? 0) + step.leak)
56 }
57 const dv = parseJson(decoy ?? '')
58 const checks = Array.isArray(dv?.checks)
59 ? dv.checks.flatMap(check => (check && typeof check === 'object' && !Array.isArray(check)
60 ? [{ rule: str(check.rule), passed: check.passed === true, detail: str(check.detail) }] : []))
61 : []
62 const priceDraft = parseAny(price?.text) as Record<string, unknown> | undefined
63 return {
64 customers,
65 totalList: customers.reduce((sum, one) => sum + one.list, 0),
66 totalPocket: customers.reduce((sum, one) => sum + one.pocket, 0),
67 byStep: [...byStepMap].map(([name, leak]) => ({ name, leak })).sort((a, b) => b.leak - a.leak),
68 ...(num(dv?.score) !== undefined ? { tiersScore: num(dv?.score) } : {}),
69 checks,
70 contrastSet: Array.isArray(priceDraft?.contrast_set) ? priceDraft.contrast_set.filter((one): one is string => typeof one === 'string') : [],
71 valueMetric: str(priceDraft?.value_metric),
72 }
73}
74
75// --- Prospect board --------------------------------------------------------
76
77export type Prospect = { title: string; signal: string }
78export type ProspectBoard = { call: Prospect[]; hold: Prospect[]; drop: Prospect[]; unscored: Prospect[] }
79
80export function prospectBoard(list: DraftFile | undefined): ProspectBoard {
81 const board: ProspectBoard = { call: [], hold: [], drop: [], unscored: [] }
82 for (const entry of listEntries(list)) {
83 const prospect = { title: str(entry.title) || str(entry.name) || str(entry.company), signal: str(entry.signal) }
84 const slot = str(entry.score).toLowerCase()
85 if (slot.startsWith('call')) board.call.push(prospect)
86 else if (slot === 'hold') board.hold.push(prospect)
87 else if (slot === 'drop') board.drop.push(prospect)
88 else board.unscored.push(prospect)
89 }
90 return board
91}
92
93// --- Cold email ------------------------------------------------------------
94
95export type ColdEmailView = {
96 subject: string
97 signal: string
98 words: number
99 spamScore?: number
100 spamVerdict: string
101 subjectScore?: number
102 domain: string
103 /** Reply triage counts by category. */
104 replies: Record<string, number>
105 deliverability?: { isRun: boolean; ok: boolean; lines: string[] }
106 /** The sending rhythm, Mon to Fri, with today marked. */
107 rhythm: { day: string; isSendDay: boolean; isToday: boolean }[]
108}
109
110const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']
111
112export function sendingDomain(config: Config): string {
113 const infra = field(config, 'infrastructure')
114 if (infra === null || typeof infra !== 'object' || Array.isArray(infra)) return ''
115 const direct = str(infra.sending_domain) || str(infra.domain)
116 if (direct !== '') return direct
117 const any = Object.values(infra).find(value => typeof value === 'string' && /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(value))
118 return str(any)
119}
120
121export function coldEmailView(input: {
122 letter: DraftFile | undefined
123 config: Config
124 spam?: string
125 subject?: string
126 replies?: string
127 deliverability?: { exitCode: number; stdout: string; stderr: string }
128 now: number
129}): ColdEmailView {
130 const letter = (parseAny(input.letter?.text) ?? {}) as Record<string, unknown>
131 const body = str(letter.letter) || str(letter.body)
132 const spam = parseJson(input.spam ?? '')
133 const subject = parseJson(input.subject ?? '')
134 const replyRows = parseAny(input.replies)
135 const replies: Record<string, number> = {}
136 if (Array.isArray(replyRows)) {
137 for (const row of replyRows) {
138 const category = row && typeof row === 'object' ? str((row as { category?: unknown }).category) : ''
139 if (category !== '') replies[category] = (replies[category] ?? 0) + 1
140 }
141 }
142 const rhythmSetting = str(field(input.config, 'operations.rhythm')) || str(field(input.config, 'operations.send_days'))
143 const sendDays = rhythmSetting === '' ? ['Mon', 'Wed', 'Fri'] : DAYS.filter(day => rhythmSetting.toLowerCase().includes(day.toLowerCase()))
144 const today = DAYS[(new Date(input.now).getUTCDay() + 6) % 7]
145 let deliverability: ColdEmailView['deliverability']
146 if (input.deliverability !== undefined) {
147 const parsed = parseJson(input.deliverability.stdout)
148 const checks = Array.isArray(parsed?.checks) ? parsed.checks : []
149 const lines = checks.length > 0
150 ? checks.flatMap(check => (check && typeof check === 'object' && !Array.isArray(check)
151 ? [`${check.status === 'pass' ? '✓' : check.status === 'fail' ? '✗' : '?'} ${str(check.name)}${str(check.detail) ? `: ${str(check.detail)}` : ''}`] : []))
152 : [(input.deliverability.stderr || input.deliverability.stdout).split('\n')[0] ?? 'no output']
153 deliverability = { isRun: parsed !== undefined, ok: input.deliverability.exitCode === 0, lines }
154 }
155 return {
156 subject: str(letter.subject) || str(subject?.subject),
157 signal: str(letter.public_signal),
158 words: body === '' ? 0 : body.trim().split(/\s+/).length,
159 ...(num(spam?.score) !== undefined ? { spamScore: num(spam?.score) } : {}),
160 spamVerdict: str(spam?.verdict),
161 ...(num(subject?.score) !== undefined ? { subjectScore: num(subject?.score) } : {}),
162 domain: sendingDomain(input.config),
163 replies,
164 ...(deliverability ? { deliverability } : {}),
165 rhythm: DAYS.map(day => ({ day, isSendDay: sendDays.includes(day), isToday: day === today })),
166 }
167}
168
169// --- EVP ladder ------------------------------------------------------------
170
171export const TIERS = ['Unaware', 'Problem-aware', 'Solution-aware', 'Product-aware', 'Most aware'] as const
172
173export type EvpRung = { tier: number; name: string; lines: string[]; isChosen: boolean }
174
175export function evpLadder(config: Config): { primary: string; rungs: EvpRung[] } {
176 const chosenTier = Number(field(config, 'evp.tier') ?? 0)
177 const primary = str(field(config, 'evp.primary'))
178 const drafts = field(config, 'evp_drafts')
179 const byTier = new Map<number, string[]>()
180 const add = (tier: number, line: string) => {
181 if (tier >= 1 && tier <= 5 && line !== '') byTier.set(tier, [...(byTier.get(tier) ?? []), line])
182 }
183 if (Array.isArray(drafts)) {
184 for (const one of drafts) {
185 if (one && typeof one === 'object' && !Array.isArray(one)) add(Number(one.tier ?? 0), str(one.evp) || str(one.line) || str(one.primary) || str(one.text))
186 }
187 } else if (drafts && typeof drafts === 'object') {
188 for (const [key, value] of Object.entries(drafts)) {
189 const tier = Number(key.replace(/\D/g, ''))
190 for (const line of Array.isArray(value) ? value : [value]) add(tier, str(line))
191 }
192 }
193 if (primary !== '' && chosenTier >= 1 && !(byTier.get(chosenTier) ?? []).includes(primary)) add(chosenTier, primary)
194 return {
195 primary,
196 rungs: TIERS.map((name, index) => ({ tier: index + 1, name, lines: byTier.get(index + 1) ?? [], isChosen: chosenTier === index + 1 })),
197 }
198}
199
200// --- GEO -------------------------------------------------------------------
201
202export type GeoView = {
203 question: string
204 killDate: string
205 daysLeft?: number
206 engines: { engine: string; status: string; cited: number }[]
207 blockedBots: string[]
208 isIndexable?: boolean
209}
210
211export function geoView(findability: DraftFile | undefined, now: number): GeoView | undefined {
212 const parsed = parseAny(findability?.text) as Record<string, unknown> | undefined
213 if (parsed === undefined) return undefined
214 const artifact = (parsed.artifact && typeof parsed.artifact === 'object' ? parsed.artifact : parsed) as Record<string, unknown>
215 const brief = (artifact.brief ?? {}) as Record<string, unknown>
216 const killDate = str(artifact.kill_date) || str(brief.kill_date)
217 const killMs = killDate === '' ? NaN : Date.parse(`${killDate}T00:00:00Z`)
218 const record = (artifact.citation_record ?? {}) as Record<string, unknown>
219 const observations = Array.isArray(record.observations) ? record.observations : []
220 const engines = new Map<string, { status: string; cited: number }>()
221 for (const one of observations) {
222 if (!one || typeof one !== 'object') continue
223 const row = one as Record<string, unknown>
224 const engine = str(row.engine)
225 const cited = Array.isArray(row.cited_urls) ? row.cited_urls.length : 0
226 const was = engines.get(engine)
227 engines.set(engine, { status: str(row.brand_status) || was?.status || '', cited: (was?.cited ?? 0) + cited })
228 }
229 const pass = (artifact.indexability_pass ?? {}) as Record<string, unknown>
230 const robots = (pass.robots ?? {}) as Record<string, unknown>
231 return {
232 question: str(artifact.buyer_question),
233 killDate,
234 ...(Number.isFinite(killMs) ? { daysLeft: Math.ceil((killMs - now) / 86_400_000) } : {}),
235 engines: [...engines].map(([engine, value]) => ({ engine, ...value })),
236 blockedBots: Object.entries(robots).filter(([, value]) => value === 'blocked').map(([bot]) => bot),
237 ...(typeof pass.status === 'number'
238 ? { isIndexable: pass.status === 200 && pass.noindex !== true && pass.raw_html_has_answer !== false } : {}),
239 }
240}
241
242// --- Founder brand ---------------------------------------------------------
243
244export const PILLARS = ['pillar', 'proof', 'process', 'person'] as const
245
246export type FounderView = {
247 pillars: { pillar: string; lastDaysAgo?: number; count: number }[]
248 postsLast28: number
249 daysSinceLast?: number
250 isLate: boolean
251}
252
253/** Posts from drafts/ named <YYYY-MM-DD>-<pillar>-<slug>.md (the founder-brand pack's naming). */
254export function founderView(entries: readonly { name: string; mtimeMs: number }[], now: number): FounderView {
255 const posts = entries.flatMap(entry => {
256 const match = /^(\d{4}-\d{2}-\d{2})-([a-z]+)-/.exec(entry.name)
257 const date = match?.[1] ? Date.parse(`${match[1]}T00:00:00Z`) : entry.mtimeMs
258 const pillar = match?.[2] && (PILLARS as readonly string[]).includes(match[2]) ? match[2] : 'other'
259 return /\.(md|txt)$/.test(entry.name) ? [{ pillar, at: Number.isFinite(date) ? date : entry.mtimeMs }] : []
260 })
261 const days = (at: number) => Math.max(0, Math.floor((now - at) / 86_400_000))
262 const last = posts.length > 0 ? Math.max(...posts.map(one => one.at)) : undefined
263 return {
264 pillars: PILLARS.map(pillar => {
265 const mine = posts.filter(one => one.pillar === pillar)
266 const newest = mine.length > 0 ? Math.max(...mine.map(one => one.at)) : undefined
267 return { pillar, count: mine.length, ...(newest !== undefined ? { lastDaysAgo: days(newest) } : {}) }
268 }),
269 postsLast28: posts.filter(one => days(one.at) <= 28).length,
270 ...(last !== undefined ? { daysSinceLast: days(last) } : {}),
271 isLate: last === undefined || days(last) > 7,
272 }
273}
274
275/** A text bar for a share of a whole. */
276export function shareBar(part: number, whole: number, width: number): string {
277 const filled = whole > 0 ? Math.max(0, Math.min(width, Math.round((part / whole) * width))) : 0
278 return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`
279}
280
281/** A customer's leak as a whole percent: '-28%' kept below list, '+64%' above it (fees), '0%'. */
282export function leakLabel(leakPct: number): string {
283 const whole = Math.round(Math.abs(leakPct))
284 return whole === 0 ? '0%' : `${leakPct > 0 ? '-' : '+'}${whole}%`
285}
286
287export function money(value: number): string {
288 return `$${Math.round(value).toLocaleString('en-US')}`
289}
290types/index.d.ts 117 lines1export type GtmStep = {
2 n: number
3 name: string
4 check: string
5 command: string
6 why: string
7 isDone: boolean
8}
9
10export type GtmBoard = {
11 hasProject: boolean
12 steps: GtmStep[]
13 operator: string
14 segment: string
15 pain: string
16 valueLine: string
17 checkedAt: number
18}
19
20export type PackId =
21 | 'psp'
22 | 'evp'
23 | 'list'
24 | 'letter'
25 | 'offer'
26 | 'price'
27 | 'page'
28 | 'sequence'
29 | 'findability'
30 | 'posts'
31
32export type ScoreStatus = 'pass' | 'fail' | 'missing' | 'unknown'
33
34export type Axis = { name: string; score: number; max: number; notes: string[] }
35
36export type Score = {
37 pack: PackId
38 status: ScoreStatus
39 /** 0-100 when the scorer reports one. */
40 score?: number
41 reasons: string[]
42 fixes: string[]
43 axes: Axis[]
44 next?: string
45 verdict?: string
46 /** Why the status is missing or unknown. */
47 detail?: string
48 /** SOUL.md phrases the draft uses. */
49 voice?: string[]
50 /** The draft's text, cut for display. */
51 excerpt?: string
52 /** ms since the epoch. */
53 at: number
54}
55
56export type Scores = Partial<Record<PackId, Score>>
57
58export type Sample = { at: number; score?: number; status: ScoreStatus; fixes: number }
59
60export type History = Partial<Record<PackId, Sample[]>>
61
62export type Finding = {
63 id: string
64 kind: 'stale' | 'consistency' | 'coverage'
65 packs: PackId[]
66 message: string
67 /** The prompt "Fix with Claude" fills in. */
68 fix?: string
69}
70
71export type ViewId = 'pricing' | 'prospects' | 'cold-email' | 'evp' | 'geo' | 'founder'
72
73/** Output of the extra pack tools a view runs (waterfall, decoy, lint, ...), by tool. */
74export type ToolRuns = Partial<Record<string, { at: number; exitCode: number; stdout: string; stderr: string }>>
75
76/** A logged result, tied to the letter version that was live. */
77export type Outcome = { at: number; kind: 'replies' | 'meetings'; count: number; version: string; score?: number }
78
79export type Sprint = {
80 /** The pack whose turn is running. */
81 current: PackId
82 /** Stop after this step number. */
83 target: number
84 startedAt: number
85 /** Why it paused, when it did. */
86 paused?: string
87}
88
89export type Tab = 'board' | 'detail' | 'health' | 'views' | 'analytics'
90
91declare module 'claude-code' {
92 interface PluginState {
93 'gtm-operator': {
94 board: GtmBoard | null
95 scores: Scores
96 history: History
97 tab: Tab
98 selected: PackId
99 findings: Finding[]
100 view: ViewId
101 toolRuns: ToolRuns
102 sprint: Sprint | null
103 outcomes: Outcome[]
104 isBandHidden: boolean
105 isGuardOff: boolean
106 }
107 }
108
109 // The tools this mod registers with $.tool.register, so its own tool.call
110 // matchers type-check whatever MCP servers the machine has connected.
111 interface McpToolInputs {
112 'mcp__gtm-operator__gtm_status': Record<string, never>
113 'mcp__gtm-operator__gtm_score': { pack?: string; file?: string }
114 'mcp__gtm-operator__gtm_consistency': Record<string, never>
115 }
116}
117