brand-manager-worker 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,270 @@
1
+ // Live Instagram stats, pulled from the Graph API instead of read off
2
+ // screenshots. Rewrites stats/latest.md (and latest.json) so the drafting
3
+ // agent always has a current media kit inlined.
4
+ //
5
+ // Trigger: refreshStatsIfStale() runs before every draft / outreach pitch.
6
+ // It is cheap (four GETs) and never throws — a failed fetch leaves the last
7
+ // snapshot in place and the staleness rule inside latest.md does the rest.
8
+ //
9
+ // Auth: an Instagram User access token (Instagram API with Instagram Login,
10
+ // scope instagram_business_manage_insights) in ~/.goosetools/ig-token. The
11
+ // dashboard's "Generate access tokens" button issues a 60-day long-lived
12
+ // token; we refresh it ourselves once it is a week old, so it never expires
13
+ // as long as the worker keeps running.
14
+
15
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { GOOSE_DIR, STATS_DIR } from "./paths.js";
18
+
19
+ export const IG_TOKEN_FILE = join(GOOSE_DIR, "ig-token");
20
+ const LATEST_MD = join(STATS_DIR, "latest.md");
21
+ const LATEST_JSON = join(STATS_DIR, "latest.json");
22
+ const GRAPH = "https://graph.instagram.com/v22.0";
23
+
24
+ /** Refetch when the snapshot is older than this. A brand asking for numbers
25
+ * gets at most a week-old window, and usually today's. */
26
+ export const MAX_AGE_DAYS = 7;
27
+ const TOKEN_REFRESH_DAYS = 7;
28
+ const DAY = 86_400_000;
29
+
30
+ function readToken() {
31
+ if (!existsSync(IG_TOKEN_FILE)) return null;
32
+ const t = readFileSync(IG_TOKEN_FILE, "utf8").trim();
33
+ return t || null;
34
+ }
35
+
36
+ async function get(token, path, params) {
37
+ const url = new URL(`${GRAPH}/${path}`);
38
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
39
+ url.searchParams.set("access_token", token);
40
+ const res = await fetch(url);
41
+ const body = await res.json().catch(() => ({}));
42
+ if (!res.ok || body.error) {
43
+ throw new Error(`IG ${path}: ${res.status} ${body.error?.message ?? ""}`.trim());
44
+ }
45
+ return body;
46
+ }
47
+
48
+ /** Long-lived tokens last 60 days; refreshing needs the token to be >24h old. */
49
+ async function maybeRefreshToken(token) {
50
+ const ageDays = (Date.now() - statSync(IG_TOKEN_FILE).mtimeMs) / DAY;
51
+ if (ageDays < TOKEN_REFRESH_DAYS) return token;
52
+ try {
53
+ const r = await get(token, "refresh_access_token", { grant_type: "ig_refresh_token" });
54
+ if (r.access_token) {
55
+ writeFileSync(IG_TOKEN_FILE, r.access_token, { mode: 0o600 });
56
+ return r.access_token;
57
+ }
58
+ } catch (err) {
59
+ console.error(`· ig token refresh failed (using current token): ${err.message}`);
60
+ }
61
+ return token;
62
+ }
63
+
64
+ const breakdownOf = (insights) =>
65
+ insights.data?.[0]?.total_value?.breakdowns?.[0]?.results?.map((r) => ({
66
+ key: r.dimension_values[0],
67
+ value: r.value,
68
+ })) ?? [];
69
+
70
+ /** Pull everything the media kit needs. Pure fetch; no file writes. */
71
+ export async function fetchIgStats(token) {
72
+ const now = new Date();
73
+ const since = Math.floor((now.getTime() - 30 * DAY) / 1000);
74
+ const until = Math.floor(now.getTime() / 1000);
75
+
76
+ const profile = await get(token, "me", { fields: "username,followers_count,media_count" });
77
+
78
+ const totals = await get(token, "me/insights", {
79
+ metric: "reach,views,total_interactions,likes,comments,shares,saves,reposts",
80
+ period: "day",
81
+ metric_type: "total_value",
82
+ since: String(since),
83
+ until: String(until),
84
+ });
85
+ const t = Object.fromEntries(
86
+ (totals.data ?? []).map((m) => [m.name, m.total_value?.value ?? 0]),
87
+ );
88
+
89
+ const demo = {};
90
+ for (const b of ["gender", "age", "country", "city"]) {
91
+ const r = await get(token, "me/insights", {
92
+ metric: "follower_demographics",
93
+ period: "lifetime",
94
+ timeframe: "this_month",
95
+ breakdown: b,
96
+ metric_type: "total_value",
97
+ });
98
+ demo[b] = breakdownOf(r).sort((a, z) => z.value - a.value);
99
+ }
100
+
101
+ const media = await get(token, "me/media", {
102
+ fields: "id,media_product_type,timestamp",
103
+ limit: "50",
104
+ });
105
+ const cutoff = new Date(now.getTime() - 30 * DAY).toISOString();
106
+ const recent = (media.data ?? []).filter((m) => m.timestamp > cutoff);
107
+
108
+ return {
109
+ capturedAt: now.toISOString(),
110
+ windowStart: new Date(since * 1000).toISOString().slice(0, 10),
111
+ windowEnd: now.toISOString().slice(0, 10),
112
+ username: profile.username,
113
+ followers: profile.followers_count,
114
+ reach: t.reach ?? 0,
115
+ views: t.views ?? 0,
116
+ interactions: t.total_interactions ?? 0,
117
+ likes: t.likes ?? 0,
118
+ comments: t.comments ?? 0,
119
+ shares: t.shares ?? 0,
120
+ saves: t.saves ?? 0,
121
+ reposts: t.reposts ?? 0,
122
+ postsPosted: recent.length,
123
+ reelsPosted: recent.filter((m) => m.media_product_type === "REELS").length,
124
+ demographics: demo,
125
+ };
126
+ }
127
+
128
+ const k = (n) =>
129
+ n >= 1_000_000
130
+ ? `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
131
+ : n >= 1_000
132
+ ? `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`
133
+ : String(n);
134
+ const pct = (part, whole) => (whole ? `${((100 * part) / whole).toFixed(1)}%` : "n/a");
135
+ const list = (rows, n, whole) =>
136
+ rows
137
+ .slice(0, n)
138
+ .map((r) => `${r.key} ${pct(r.value, whole)}`)
139
+ .join(", ");
140
+
141
+ const AGE_ORDER = ["13-17", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"];
142
+
143
+ /** The media kit the agent reads. Keeps the same headings the screenshot
144
+ * reader wrote, so anything that learned the old layout still works. */
145
+ export function renderLatestMd(s, { screenshotsSection = "" } = {}) {
146
+ const captured = s.capturedAt.slice(0, 10);
147
+ const staleOn = new Date(Date.parse(s.capturedAt) + 30 * DAY).toISOString().slice(0, 10);
148
+ const g = s.demographics.gender ?? [];
149
+ const known = g.filter((r) => r.key === "M" || r.key === "F");
150
+ const knownTotal = known.reduce((a, r) => a + r.value, 0);
151
+ const men = known.find((r) => r.key === "M")?.value ?? 0;
152
+ const women = known.find((r) => r.key === "F")?.value ?? 0;
153
+ const ages = (s.demographics.age ?? []).slice();
154
+ const ageTotal = ages.reduce((a, r) => a + r.value, 0);
155
+ ages.sort((a, z) => AGE_ORDER.indexOf(a.key) - AGE_ORDER.indexOf(z.key));
156
+ const core = ages
157
+ .filter((r) => ["18-24", "25-34", "35-44"].includes(r.key))
158
+ .reduce((a, r) => a + r.value, 0);
159
+ const countryTotal = (s.demographics.country ?? []).reduce((a, r) => a + r.value, 0);
160
+ const cityTotal = (s.demographics.city ?? []).reduce((a, r) => a + r.value, 0);
161
+ const eng = pct(s.interactions, s.reach);
162
+
163
+ return `# @${s.username} — media kit
164
+
165
+ _Source: Instagram Graph API (live). Window: ${s.windowStart} to ${s.windowEnd} (30 days). Captured ${captured}._
166
+
167
+ ## STALENESS RULE (read before using any number below)
168
+ - This snapshot is **current only within 30 days of its capture date** (line above). Compare against
169
+ today's date. Captured ${captured} means it goes stale on ${staleOn}.
170
+ - If stale: do **not** put any of these numbers or the screenshots in a draft, even when a brand asks.
171
+ Write the sentence as "I can send over current reach and audience numbers" and add the flag
172
+ \`"stats stale since ${staleOn} — refresh before sending"\` to the decision JSON so Erin refreshes.
173
+ - If current: numbers go out only when asked (see \`playbook/negotiation.md\`, standing rule).
174
+
175
+ ## Headline (30 days)
176
+ - **Followers:** ${s.followers.toLocaleString("en-US")} (~${k(s.followers)})
177
+ - **Views:** ${k(s.views)}
178
+ - **Accounts reached:** ${k(s.reach)}
179
+ - **Posts:** ${s.postsPosted} (${s.reelsPosted} Reels)
180
+
181
+ ## Engagement (30 days)
182
+ - Likes ${k(s.likes)} · Comments ${k(s.comments)} · Reposts ${k(s.reposts)} · Shares ${k(s.shares)} · Saves ${k(s.saves)}
183
+ - ~${k(s.interactions)} total interactions → **~${eng} engagement by reach**
184
+
185
+ ## Audience (followers)
186
+ - **Gender:** ${pct(men, knownTotal)} men / ${pct(women, knownTotal)} women (of followers who state one)
187
+ - **Age:** ${ages.map((r) => `${r.key} = ${pct(r.value, ageTotal)}`).join(", ")} — ~${pct(core, ageTotal)} aged 18–44.
188
+ - **Top countries:** ${list(s.demographics.country ?? [], 5, countryTotal)}
189
+ - **Top cities:** ${list(s.demographics.city ?? [], 5, cityTotal)}
190
+ - **Niche:** developers / engineers / tech workers (coding, dev life, WFH).
191
+
192
+ ## How to present reach (only when a brand asks for stats / media kit)
193
+ Lead with views/reach, then note that content performs well above the follower count when it does.
194
+ e.g. "Over the last 30 days my Reels pulled ~${k(s.views)} views and reached ~${k(s.reach)} accounts, with ~${eng}
195
+ engagement by reach. Audience is mostly developers, engineers, and tech workers."
196
+ Use these exact figures, never ones from older drafts or the voice file. Never use this framing
197
+ unprompted, to justify a rate, or to correct a brand's benchmark.
198
+
199
+ ${screenshotsSection.trim() || `## Screenshots to attach on request
200
+ Screenshots live in \`screenshots/\`; newest by date prefix is current. If none are newer than this
201
+ snapshot's window, say the numbers come from Instagram's own insights and offer a screenshot on request.`}
202
+ `;
203
+ }
204
+
205
+ /** Capture date of the current snapshot, or null if there is none. */
206
+ export function snapshotCapturedAt() {
207
+ if (existsSync(LATEST_JSON)) {
208
+ try {
209
+ return JSON.parse(readFileSync(LATEST_JSON, "utf8")).capturedAt ?? null;
210
+ } catch {
211
+ /* fall through to the markdown */
212
+ }
213
+ }
214
+ if (!existsSync(LATEST_MD)) return null;
215
+ const m = readFileSync(LATEST_MD, "utf8").match(/Captured (\d{4}-\d{2}-\d{2})/);
216
+ return m ? m[1] : null;
217
+ }
218
+
219
+ /** Fetch now and rewrite latest.md/json. Returns the stats object. */
220
+ export async function refreshStats() {
221
+ let token = readToken();
222
+ if (!token) throw new Error(`no Instagram token at ${IG_TOKEN_FILE}`);
223
+ token = await maybeRefreshToken(token);
224
+ const stats = await fetchIgStats(token);
225
+
226
+ // Keep the screenshot list only if it has a capture inside this window;
227
+ // June screenshots next to September numbers would contradict the kit.
228
+ const prior = existsSync(LATEST_MD) ? readFileSync(LATEST_MD, "utf8") : "";
229
+ const shotsSection = prior.match(/## Screenshots to attach on request[\s\S]*$/)?.[0] ?? "";
230
+ const shotDates = [...shotsSection.matchAll(/screenshots\/(\d{4}-\d{2}-\d{2})/g)].map((m) => m[1]);
231
+ const shots = shotDates.some((d) => d >= stats.windowStart) ? shotsSection : "";
232
+ writeFileSync(LATEST_MD, renderLatestMd(stats, { screenshotsSection: shots }));
233
+ writeFileSync(LATEST_JSON, JSON.stringify(stats, null, 2));
234
+ return stats;
235
+ }
236
+
237
+ /**
238
+ * The pre-draft hook. Refreshes when the snapshot is older than MAX_AGE_DAYS
239
+ * (or missing). Never throws: no token, network down, or a Meta error all
240
+ * leave the previous snapshot alone and log one line.
241
+ */
242
+ export async function refreshStatsIfStale({ maxAgeDays = MAX_AGE_DAYS, log = console } = {}) {
243
+ if (!readToken()) return { refreshed: false, reason: "no-token" };
244
+ const at = snapshotCapturedAt();
245
+ const ageDays = at ? (Date.now() - Date.parse(at)) / DAY : Infinity;
246
+ if (ageDays < maxAgeDays) return { refreshed: false, reason: "fresh", ageDays };
247
+ try {
248
+ const s = await refreshStats();
249
+ log.log(
250
+ `· ig stats refreshed: ${k(s.followers)} followers, ${k(s.views)} views / ${k(s.reach)} reach (30d)`,
251
+ );
252
+ return { refreshed: true, stats: s };
253
+ } catch (err) {
254
+ log.error(`· ig stats refresh failed (keeping last snapshot): ${err.message}`);
255
+ return { refreshed: false, reason: "error", error: err.message };
256
+ }
257
+ }
258
+
259
+ // `node worker/ig-stats.js` — refresh on demand from a terminal.
260
+ if (process.argv[1] && process.argv[1].endsWith("ig-stats.js")) {
261
+ refreshStats()
262
+ .then((s) => {
263
+ console.log(readFileSync(LATEST_MD, "utf8"));
264
+ console.log(`wrote ${LATEST_MD} (captured ${s.capturedAt})`);
265
+ })
266
+ .catch((err) => {
267
+ console.error(err.message);
268
+ process.exit(1);
269
+ });
270
+ }
package/worker/index.js CHANGED
@@ -17,12 +17,21 @@ import { setAgent } from "./claude.js";
17
17
  import {
18
18
  runChat,
19
19
  runDraft,
20
+ runCompact,
20
21
  runEdit,
21
22
  runLearn,
22
23
  runScan,
23
24
  runStats,
24
25
  runVoiceAudit,
25
26
  } from "./handlers.js";
27
+ import { acquireWorkerLock } from "./lock.js";
28
+
29
+ // One worker of each kind per machine — a second one would split the queue
30
+ // with this one. Stands down with an explanation if another already holds it.
31
+ acquireWorkerLock("brand", {
32
+ label: "The Brand Manager worker",
33
+ stopHint: "launchctl bootout gui/$(id -u)/com.goosetools.brand (or close its terminal)",
34
+ });
26
35
 
27
36
  if (!TOKEN) {
28
37
  console.error(
@@ -67,6 +76,8 @@ async function handle(job) {
67
76
  return runStats(job);
68
77
  case "edit":
69
78
  return runEdit(job);
79
+ case "compact":
80
+ return runCompact(job);
70
81
  // Brand Outreach (its own tool on the site; same worker, same token).
71
82
  case "outreach-chat":
72
83
  return runOutreachChat(job);
package/worker/lock.js ADDED
@@ -0,0 +1,103 @@
1
+ // One worker of each kind per machine.
2
+ //
3
+ // Nothing stops you starting a second worker: the daemon runs in the
4
+ // background, and `run` in a terminal is the normal way to watch one work or
5
+ // to try a change from a checkout. Both then poll the same queue with the same
6
+ // token.
7
+ //
8
+ // The server is safe — claiming a job is a single compare-and-set, so two
9
+ // workers never get the same one. The damage is quieter than that. They SPLIT
10
+ // the queue, so jobs land on whichever copy happened to pick them up: half
11
+ // from the code you're editing, half from the installed release, with a
12
+ // different state directory behind each. And the polling doubles, which is
13
+ // what the idle interval exists to keep down in the first place.
14
+ //
15
+ // So: whoever gets here first holds the lock, and the second one stands down
16
+ // with an explanation instead of quietly competing.
17
+
18
+ import { execFileSync } from "node:child_process";
19
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ const LOCK_DIR = join(homedir(), ".goosetools", "locks");
24
+
25
+ /**
26
+ * Take the lock for `name` ("worker", "caption", "brand", "overlay"), or print
27
+ * who has it and exit. Returns nothing — it either succeeds or ends the
28
+ * process.
29
+ *
30
+ * `stopHint` is the command that stops the OTHER copy, and it's the whole
31
+ * point of the message: "already running" without it just moves the puzzle.
32
+ */
33
+ export function acquireWorkerLock(name, { label, stopHint }) {
34
+ mkdirSync(LOCK_DIR, { recursive: true });
35
+ const file = join(LOCK_DIR, `${name}.pid`);
36
+
37
+ const holder = readHolder(file);
38
+ if (holder && isAlive(holder.pid)) {
39
+ console.log(
40
+ `\n${label} is already running on this computer (pid ${holder.pid}${
41
+ holder.since ? `, since ${holder.since}` : ""
42
+ }).\n\n` +
43
+ "Two of them would split the queue between them — some jobs done by\n" +
44
+ "one copy, some by the other. Stopping here instead.\n\n" +
45
+ ` Stop the other one: ${stopHint}\n`,
46
+ );
47
+ process.exit(0);
48
+ }
49
+
50
+ // Either no lock, or one left behind by a worker that was killed. Both are
51
+ // ours to take: an O_EXCL create loses to a worker that beat us here by
52
+ // milliseconds, which is the one race worth caring about.
53
+ if (holder) rmSync(file, { force: true });
54
+ let fd;
55
+ try {
56
+ fd = openSync(file, "wx");
57
+ } catch {
58
+ console.log(`\n${label} started somewhere else a moment ago. Stopping here.\n`);
59
+ process.exit(0);
60
+ }
61
+ writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n`);
62
+ closeSync(fd);
63
+
64
+ const release = () => rmSync(file, { force: true });
65
+ process.on("exit", release);
66
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
67
+ process.on(sig, () => {
68
+ release();
69
+ process.exit(0);
70
+ });
71
+ }
72
+ }
73
+
74
+ function readHolder(file) {
75
+ if (!existsSync(file)) return null;
76
+ try {
77
+ const [pid, since] = readFileSync(file, "utf8").split("\n");
78
+ const n = Number.parseInt(pid, 10);
79
+ return Number.isFinite(n) ? { pid: n, since: since?.trim() || null } : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ // A pid file outlives a SIGKILLed worker, and pids get reused — so "is that
86
+ // pid alive" isn't enough on its own. Checking that it's a node process is
87
+ // cheap and rules out the reuse case that would otherwise lock out the daemon
88
+ // until someone deleted the file by hand.
89
+ function isAlive(pid) {
90
+ try {
91
+ process.kill(pid, 0);
92
+ } catch {
93
+ return false;
94
+ }
95
+ try {
96
+ return /node/.test(execFileSync("ps", ["-p", String(pid), "-o", "command="], {
97
+ encoding: "utf8",
98
+ stdio: ["ignore", "pipe", "ignore"],
99
+ }));
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
@@ -13,6 +13,7 @@
13
13
  import { existsSync, readFileSync, readdirSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { BASE_DIR, BRAND_DIR, PLAYBOOK_DIR, STATS_DIR, VOICE_DIR } from "./paths.js";
16
+ import { refreshStatsIfStale } from "./ig-stats.js";
16
17
  import { oneShot, parseJson, session, canResume } from "./claude.js";
17
18
  import { createGmailDraft } from "./gmail-draft.js";
18
19
 
@@ -104,6 +105,7 @@ export async function runOutreachDraft(job) {
104
105
  if (!job.gmailAccessToken)
105
106
  return { ok: false, error: "Connect Gmail on goosetools.com first" };
106
107
 
108
+ await refreshStatsIfStale();
107
109
  const prompt = `${outreachMode()}
108
110
 
109
111
  ## The creator's layers (personal wins over base on any conflict)
@@ -0,0 +1,154 @@
1
+ // Writing observations back into a brand record.
2
+ //
3
+ // The drafting pass used to be read-only: decide() runs tools-off, so a scan
4
+ // that produced ten replies updated zero files. When Heli pulled the Navan
5
+ // go-live at 08:25 on the morning it was due, the agent drafted a reply about
6
+ // it and the calendar block kept the dead date forever.
7
+ //
8
+ // Rather than pay for a second, tool-enabled agent pass per thread, the draft
9
+ // decision carries a `recordUpdates` object and this merges it in with plain
10
+ // code. Same run, no extra call, no added latency — and because the merge is
11
+ // deterministic it can't wander off and rewrite prose it wasn't asked to.
12
+
13
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { DEALS_DIR } from "./paths.js";
16
+ import { appendDecisions, mergeRecord, parseRecord, serializeRecord } from "./record.js";
17
+
18
+ const SLUG_RE = /^[a-z0-9-]+$/;
19
+
20
+ /** Guess a ledger filename from a brand name: "Navan Edge" -> "navan-edge". */
21
+ export function slugify(brand) {
22
+ return String(brand ?? "")
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, "-")
25
+ .replace(/^-|-$/g, "");
26
+ }
27
+
28
+ /**
29
+ * Find the ledger a brand name belongs to without creating one by accident.
30
+ * An exact slug hit wins; otherwise a prefix match, so a draft that says
31
+ * "Navan" still lands on navan.md. Returns null when nothing matches — the
32
+ * caller must not invent a file, because a typo'd brand name would otherwise
33
+ * quietly fork a deal into two records.
34
+ */
35
+ export function resolveSlug(brand, known) {
36
+ const slug = slugify(brand);
37
+ if (!slug) return null;
38
+ if (known.includes(slug)) return slug;
39
+ return known.find((k) => k.startsWith(slug) || slug.startsWith(k)) ?? null;
40
+ }
41
+
42
+ /**
43
+ * Apply `recordUpdates` to deals/<slug>.md.
44
+ *
45
+ * Returns a short description of what changed, or null if nothing did — the
46
+ * caller logs it against the draft so a surprising edit is traceable to the
47
+ * email that caused it.
48
+ */
49
+ export function applyRecordUpdates(slug, updates) {
50
+ if (!SLUG_RE.test(slug ?? "")) return null;
51
+ if (!updates || typeof updates !== "object") return null;
52
+
53
+ const path = join(DEALS_DIR, `${slug}.md`);
54
+ if (!existsSync(path)) return null;
55
+
56
+ const md = readFileSync(path, "utf8");
57
+ const { record, decisions } = parseRecord(md);
58
+ if (!record) return null;
59
+
60
+ const { decisions: incoming, ...rest } = updates;
61
+ const nextRecord = mergeRecord(record, rest);
62
+ const nextDecisions = appendDecisions(decisions, Array.isArray(incoming) ? incoming : []);
63
+
64
+ const changed = summarize(record, nextRecord, decisions.length, nextDecisions.length);
65
+ if (!changed) return null;
66
+
67
+ writeFileSync(
68
+ path,
69
+ serializeRecord({
70
+ record: nextRecord,
71
+ decisions: nextDecisions,
72
+ archiveRef: existsSync(join(DEALS_DIR, "..", "archive", `${slug}.md`))
73
+ ? `archive/${slug}.md`
74
+ : null,
75
+ }),
76
+ );
77
+ return changed;
78
+ }
79
+
80
+ /**
81
+ * Record her answer to "were you actually paid?".
82
+ *
83
+ * This is the only fact in the system that starts on the website, so it has to
84
+ * end up in the file or the mirror becomes storage. mergeRecord() then treats
85
+ * `confirmed` as untouchable, so the agent re-reading the same ambiguous email
86
+ * can't flip it back.
87
+ */
88
+ export function applyPaymentConfirmation(slug, { invoiceId, paid }) {
89
+ if (!SLUG_RE.test(slug ?? "")) return null;
90
+
91
+ const path = join(DEALS_DIR, `${slug}.md`);
92
+ if (!existsSync(path)) return null;
93
+
94
+ const md = readFileSync(path, "utf8");
95
+ const { record, decisions } = parseRecord(md);
96
+ if (!record) return null;
97
+
98
+ const invoices = record.money?.invoices ?? [];
99
+ const i = invoices.findIndex((inv) => inv.id === invoiceId);
100
+ if (i === -1) return null;
101
+
102
+ const next = {
103
+ ...record,
104
+ money: {
105
+ ...record.money,
106
+ invoices: invoices.map((inv, n) =>
107
+ n === i
108
+ ? {
109
+ ...inv,
110
+ confirmed: paid,
111
+ // The stamp is what makes the confirmation real — sanitizeInvoice
112
+ // discards a `confirmed` that arrives without it.
113
+ confirmedAt: new Date().toISOString(),
114
+ status: paid ? "paid" : "unpaid",
115
+ // Her word is the ceiling on certainty, not the agent's guess.
116
+ confidence: 1,
117
+ evidence: `Confirmed by hand on ${new Date().toISOString().slice(0, 10)}: ${
118
+ paid ? "paid" : "not paid"
119
+ }. Previously: ${inv.evidence ?? "no evidence recorded"}`,
120
+ }
121
+ : inv,
122
+ ),
123
+ },
124
+ };
125
+
126
+ writeFileSync(path, serializeRecord({ record: next, decisions, archiveRef: archiveRefFor(slug) }));
127
+ return `${slug} ${invoiceId}: ${paid ? "confirmed paid" : "confirmed NOT paid"}`;
128
+ }
129
+
130
+ function archiveRefFor(slug) {
131
+ return existsSync(join(DEALS_DIR, "..", "archive", `${slug}.md`)) ? `archive/${slug}.md` : null;
132
+ }
133
+
134
+ function summarize(before, after, beforeDecisions, afterDecisions) {
135
+ const bits = [];
136
+ if (before.stage !== after.stage) bits.push(`stage ${before.stage ?? "?"} -> ${after.stage}`);
137
+ if (before.whoseMove !== after.whoseMove) bits.push(`move -> ${after.whoseMove}`);
138
+
139
+ const dateChanges = (after.calendar ?? []).filter((e) => {
140
+ const old = (before.calendar ?? []).find((o) => o.key === e.key);
141
+ return !old || old.date !== e.date || old.tbd !== e.tbd;
142
+ }).length;
143
+ if (dateChanges) bits.push(`${dateChanges} date(s)`);
144
+
145
+ const invChanges = (after.money?.invoices ?? []).filter((inv) => {
146
+ const old = (before.money?.invoices ?? []).find((o) => o.id === inv.id);
147
+ return !old || old.status !== inv.status;
148
+ }).length;
149
+ if (invChanges) bits.push(`${invChanges} invoice(s)`);
150
+
151
+ if (afterDecisions > beforeDecisions) bits.push(`+${afterDecisions - beforeDecisions} decision(s)`);
152
+
153
+ return bits.length ? bits.join(", ") : null;
154
+ }