SLOPSHOPPER

gtm-operator

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…

newpanebandguardcommandtoast
★ 1v0.6.1MITupdated 2026-10-05cmj-hub/gtm-operator-claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gtm-operator
│ ┃ GTM board ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Board 2: Detail 3: Health 4: Views 5: │ ┃ ⏺ Read(src/auth.ts) │ ┃ 0/11 steps in place ⎿ Read 6 lines │ ┃ Operator: — ⏺ Update(src/auth.ts) │ ┃ Buyer: — ⎿ Added 2 lines, removed 1 line │ ┃ Pain: — ⏺ Bash(bun test) │ ┃ Value line: — ⎿ 3 pass, 1 fail │ ┃ │ ┃ ▸ 0. Setup /gtm:setup ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ · 1. Profile (PSP) /psp:psp │ ┃ · 2. Value line (EVP) /evp:evp ✻ Worked for 42s · done 4:20 PM │ ┃ · 3. Prospect list /prospect-list:who-to-co │ ┃ · 4. First touch /cold-email:cold-email › /gtm-board │ ┃ · 5. Offer /sales-offer:cold-offer ⎿ gtm-operator: Done: none │ ┃ · 6. Pricing /pricing:pricing ⎿ gtm-operator: Next: /gtm:setup — no operator or buyer segment ye │ ┃ · 7. Landing page /landing-page:page │ ┃ · 8. Email sequence /email-sequence:lifecyc │ ┃ · 9. Findability (GEO) /geo:geo │ ┃ · 10. Founder posts /founder-brand:founder- │ ┃ │ ┃ Next: /gtm:setup — no operator or buyer │ ┃ segment yet │ ┃ Not installed? /plugin install │ ┃ gtm@gtm-operator-skills │ ┃ [ Fill next ] [ Run next ] [ Rescore all ] [ ⟨Claude Code's own drawing⟩ GTM 0/11 Next: /gtm:setup — no operator or buyer segment yet [ Use ] [ Run ] [ Board ] [ Hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ gtm-operator: GTM 0/11 · next /gtm:setup

Draws

Band
⟨Claude Code's own drawing⟩ GTM 0/11 Next: /gtm:setup — no operator or buyer segment yet [ Use ] [ Run ] [ Board ] [ Hide ]
Pane · GTM board
1: Board 2: Detail 3: Health 4: Views 5: Analytics 0/11 steps in place Operator: — Buyer: — Pain: — Value line: — ▸ 0. Setup /gtm:setup · 1. Profile (PSP) /psp:psp · 2. Value line (EVP) /evp:evp · 3. Prospect list /prospect-list:who-to-contact · 4. First touch /cold-email:cold-email · 5. Offer /sales-offer:cold-offer · 6. Pricing /pricing:pricing · 7. Landing page /landing-page:page · 8. Email sequence /email-sequence:lifecycle-email · 9. Findability (GEO) /geo:geo · 10. Founder posts /founder-brand:founder-brand Next: /gtm:setup — no operator or buyer segment yet Not installed? /plugin install gtm@gtm-operator-skills [ Fill next ] [ Run next ] [ Rescore all ] [ Show band ]
README

<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.">

GTM operator mod for Claude Code

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.

In 60 seconds

/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:next tells 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%">

What it does

WhereWhat you see
Band above the promptGTM 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 lineGTM 3/11 · 1 draft to fix · next /prospect-list:who-to-contact
/gtm-board, Board tabEvery 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 tabThe 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 tabCross-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 tabSix 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 tabScore 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-digestThe 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 callgtm_status, gtm_score (one pack, now) and gtm_consistency, so Claude checks its own draft before saying it's done.
gtm-operator:reviewerA read-only subagent that reviews one draft against the PSP, the EVP, SOUL.md and its scorer, and returns at most five changes.
ToastWhen 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 promptEach 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.

Screens

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>

How scoring works

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.

Settings

Set these in /config (or pluginConfigs["gtm-operator"].options in settings):

SettingDefaultWhat it does
runScorersonScore drafts with the packs' scorers. Off: progress only, nothing runs.
minScore0 (off)Refuse a Write or Edit to a gtm/ or drafts/ draft that scores below this, with the fixes in the refusal.
packsDiremptyA folder holding the pack repos side by side; searched first.
pythonpython3The command the scorers run with.

Cross-pack checks and views

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.

The guided sprint

/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.

Voice check

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.

The guard: merge, never overwrite

brand-config.json and SOUL.md are shared by every pack. The mod refuses a Write or Edit that would:

  • drop or change a value already filled in brand-config.json (adding fields is fine, so /gtm:setup still works),
  • leave brand-config.json as anything but one JSON object,
  • remove an existing ## 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.

The steps it walks

Same order and checks as /gtm:next:

StepDone whenNext command
0 Setupoperator.name and icp.segment filled/gtm:setup
1 Profilepsp.primary_pain and psp.signal_anchors filled/psp:psp
2 Value lineevp.primary filled/evp:evp
3 Prospect listgtm/list.json/prospect-list:who-to-contact
4 First touchgtm/letter.json, or tone + infrastructure/cold-email:cold-email
5 Offergtm/offer.json/sales-offer:cold-offer
6 Pricingpricing.currentTiers or gtm/price.json/pricing:pricing
7 Landing pagegtm/page.json/landing-page:page
8 Email sequencegtm/sequence.json/email-sequence:lifecycle-email
9 Findabilitygtm/findability.json/geo:geo
10 Founder postsa file in drafts/ from the last 7 days/founder-brand:founder-brand

Install

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

Requirements

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.

What this mod will not do

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.

What is a Claude Code mod?

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.

Do I need the ten skill packs?

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.

How is this different from /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.

Will it block Claude from editing my brand config?

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.

Does it work in Cursor, Codex or the VS Code extension?

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 it can reach

What claude plugin validate . reports, so you can review it before installing:

  • Hooks: 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).
  • Calls: $.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.
  • Never: $.fs.write, $.http.fetch, $.env.set, $.model, $.prompt.submit.

On the site

Free, no signup

Free, by email

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.

Develop

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
CaseChecksWith the modWithout
guard-keeps-configAsked to overwrite brand-config.json with a stub, the ICP and PSP survive1.000.00
which-drafts-failNames the drafts that fail their pack's scorer, with the score1.000.75
score-before-doneScores a new founder post with gtm_score after writing it, before calling it ready1.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.

Privacy and security

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.

License

MIT. See LICENSE.

About

Built by Jay Mount Consulting.

Source 11 files
hooks/register.tsx 1381 lines
1import { 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 lines
1// 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}
206
hooks/drift.ts 199 lines
1// 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}
199
hooks/gtm.ts 181 lines
1// 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}
181
hooks/history.ts 39 lines
1// 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}
39
hooks/locate.ts 65 lines
1// 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}
65
hooks/packs.ts 182 lines
1// 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}
182
hooks/score.ts 128 lines
1// 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}
128
hooks/voice.ts 69 lines
1// 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}
69
hooks/views.ts 290 lines
1// 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}
290
types/index.d.ts 117 lines
1export 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