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.
- package/README.md +16 -1
- package/package.json +2 -2
- package/worker/agent.js +63 -4
- package/worker/compact.js +157 -0
- package/worker/context.js +40 -9
- package/worker/handlers.js +83 -5
- package/worker/ig-stats.js +270 -0
- package/worker/index.js +11 -0
- package/worker/lock.js +103 -0
- package/worker/outreach.js +2 -0
- package/worker/record-write.js +154 -0
- package/worker/record.js +264 -0
package/worker/record.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// The brand record — what replaced the ledger journal.
|
|
2
|
+
//
|
|
3
|
+
// A ledger used to be a chronological log the agent appended to forever.
|
|
4
|
+
// navan.md reached 56KB across 44 dated headings, including entries explicitly
|
|
5
|
+
// marked as superseded ("CORRECTED below, this read was wrong"), all of it
|
|
6
|
+
// spent out of a shared 350k prompt budget. Of that, perhaps 3KB could ever
|
|
7
|
+
// inform a future draft.
|
|
8
|
+
//
|
|
9
|
+
// A record is the opposite: current state, and the decisions that constrain
|
|
10
|
+
// the next one. Two parts, both small.
|
|
11
|
+
//
|
|
12
|
+
// <!-- brand {...} --> machine-readable current state, rewritten in place
|
|
13
|
+
// ## Decisions append-only one-liners — the durable precedent
|
|
14
|
+
//
|
|
15
|
+
// The narrative moves to archive/<slug>.md, which is NOT loaded into drafting
|
|
16
|
+
// context. Chat can still read it when she asks for history.
|
|
17
|
+
//
|
|
18
|
+
// Why a JSON block inside markdown rather than a real database: these files are
|
|
19
|
+
// hers. "Open Claude Code in this folder and talk to it" has to keep working,
|
|
20
|
+
// and the website is a projection of the files, never the owner of them.
|
|
21
|
+
|
|
22
|
+
/** Stages the board understands. Kept in sync with goosetools' BRAND_STAGES. */
|
|
23
|
+
export const STAGES = [
|
|
24
|
+
"inbound",
|
|
25
|
+
"negotiating",
|
|
26
|
+
"contract",
|
|
27
|
+
"producing",
|
|
28
|
+
"posted",
|
|
29
|
+
"awaiting_pay",
|
|
30
|
+
"paid",
|
|
31
|
+
"declined",
|
|
32
|
+
"cancelled",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// The live ledgers drifted: the agent wrote whatever stage word fit the moment.
|
|
36
|
+
const STAGE_ALIASES = {
|
|
37
|
+
scoping: "negotiating",
|
|
38
|
+
"in production": "producing",
|
|
39
|
+
"in-production": "producing",
|
|
40
|
+
signed: "contract",
|
|
41
|
+
dead: "declined",
|
|
42
|
+
rejected: "declined",
|
|
43
|
+
gifting: "inbound",
|
|
44
|
+
live: "posted",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function normalizeStage(raw) {
|
|
48
|
+
if (!raw) return null;
|
|
49
|
+
const s = String(raw).trim().toLowerCase().replace(/\s+/g, " ");
|
|
50
|
+
if (STAGES.includes(s)) return s;
|
|
51
|
+
return STAGE_ALIASES[s] ?? s;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `confirmed` is the creator's answer and nobody else's: true, false, or null
|
|
56
|
+
* for "never asked". The agent has no business writing it — but it did, twice
|
|
57
|
+
* in one migration (a bare `false` on Navan, a date string on Microsoft), and
|
|
58
|
+
* either one silences the "were you actually paid?" prompt for that invoice
|
|
59
|
+
* and then freezes the value against every later update.
|
|
60
|
+
*
|
|
61
|
+
* A type check can't catch it: the agent's `false` and her "no" are the same
|
|
62
|
+
* boolean. So a confirmation only counts when it carries `confirmedAt`, which
|
|
63
|
+
* applyPaymentConfirmation() stamps and nothing else writes. Provenance, not
|
|
64
|
+
* shape — which also means a record hand-edited in a terminal needs the stamp
|
|
65
|
+
* too, and that is the right trade for a field this load-bearing.
|
|
66
|
+
*/
|
|
67
|
+
export function sanitizeInvoice(inv) {
|
|
68
|
+
if (!inv || typeof inv !== "object") return inv;
|
|
69
|
+
const vouched = typeof inv.confirmed === "boolean" && typeof inv.confirmedAt === "string";
|
|
70
|
+
return vouched ? inv : { ...inv, confirmed: null, confirmedAt: null };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const blockRe = (name) => new RegExp(`<!--\\s*${name}\\s([\\s\\S]*?)-->`, "i");
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read one `<!-- name {...} -->` block. Returns null rather than throwing:
|
|
77
|
+
* these blocks are model-written, and a malformed one must degrade to "we
|
|
78
|
+
* don't know" instead of taking down a scan.
|
|
79
|
+
*/
|
|
80
|
+
export function readBlock(md, name) {
|
|
81
|
+
const m = String(md ?? "").match(blockRe(name));
|
|
82
|
+
if (!m) return null;
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(m[1]);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Replace a block in place, or append one if the file has none. */
|
|
91
|
+
export function writeBlock(md, name, value) {
|
|
92
|
+
const body = `<!-- ${name}\n${JSON.stringify(value, null, 2)}\n-->`;
|
|
93
|
+
const src = String(md ?? "");
|
|
94
|
+
return blockRe(name).test(src) ? src.replace(blockRe(name), body) : `${src.trimEnd()}\n\n${body}\n`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DECISION_RE = /^[-*]\s+(\d{4}-\d{2}-\d{2})\s*·\s*(.+)$/;
|
|
98
|
+
|
|
99
|
+
/** One line per decision: `- 2026-07-15 · accepted $14,000 · 2yr paid usage` */
|
|
100
|
+
export function parseDecisions(md) {
|
|
101
|
+
const section = String(md ?? "").split(/^##\s+Decisions\s*$/im)[1];
|
|
102
|
+
if (!section) return [];
|
|
103
|
+
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const line of section.split("\n")) {
|
|
106
|
+
if (/^##\s/.test(line)) break; // next heading ends the section
|
|
107
|
+
const m = line.trim().match(DECISION_RE);
|
|
108
|
+
if (m) {
|
|
109
|
+
const [what, ...why] = m[2].split("·").map((s) => s.trim());
|
|
110
|
+
out.push({ date: m[1], what, why: why.join(" · ") || null });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function renderDecisions(decisions) {
|
|
117
|
+
return decisions
|
|
118
|
+
.slice()
|
|
119
|
+
.sort((a, b) => a.date.localeCompare(b.date))
|
|
120
|
+
.map((d) => `- ${d.date} · ${[d.what, d.why].filter(Boolean).join(" · ")}`)
|
|
121
|
+
.join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Read a record from either format. Files migrate one at a time and a scan can
|
|
126
|
+
* hit an un-migrated one at any point, so the legacy `board` + `calendar` pair
|
|
127
|
+
* has to keep working until every file has moved over.
|
|
128
|
+
*/
|
|
129
|
+
export function parseRecord(md) {
|
|
130
|
+
const src = String(md ?? "");
|
|
131
|
+
const brand = readBlock(src, "brand");
|
|
132
|
+
|
|
133
|
+
if (brand) {
|
|
134
|
+
return {
|
|
135
|
+
record: { ...brand, stage: normalizeStage(brand.stage), money: sanitizeMoney(brand.money) },
|
|
136
|
+
decisions: parseDecisions(src),
|
|
137
|
+
legacy: false,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const board = readBlock(src, "board");
|
|
142
|
+
const calendar = readBlock(src, "calendar");
|
|
143
|
+
if (!board && !calendar) return { record: null, decisions: parseDecisions(src), legacy: true };
|
|
144
|
+
|
|
145
|
+
return { record: fromLegacyBoard(board ?? {}, calendar), decisions: [], legacy: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sanitizeMoney(money) {
|
|
149
|
+
if (!money || typeof money !== "object") return money ?? null;
|
|
150
|
+
return { ...money, invoices: (money.invoices ?? []).map(sanitizeInvoice) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Lift the old board block into a record. Terms stay strings — see below. */
|
|
154
|
+
export function fromLegacyBoard(board, calendar) {
|
|
155
|
+
const agencyRaw = typeof board.agency === "string" ? board.agency.trim() : "";
|
|
156
|
+
// Live data writes "Freeman & Forrest · Rebekah Greene" in one string.
|
|
157
|
+
const [agencyName, agencyContact] = agencyRaw.split("·").map((s) => s.trim());
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
brand: board.brand ?? null,
|
|
161
|
+
agency: agencyName ? { name: agencyName, contact: agencyContact || null } : null,
|
|
162
|
+
contacts: board.contact ? [board.contact] : [],
|
|
163
|
+
stage: normalizeStage(board.stage),
|
|
164
|
+
whoseMove: board.whoseMove ?? null,
|
|
165
|
+
deal: {
|
|
166
|
+
// Deliberately not parsed into a number here. The live values include
|
|
167
|
+
// "asked $2,000, countered $1,200" and "$3.7-4.4K (quoted)" — turning
|
|
168
|
+
// those into one integer would invent a fact. The migration pass reads
|
|
169
|
+
// them with judgement; this path only reshapes what is already there.
|
|
170
|
+
rate: board.rate ?? null,
|
|
171
|
+
deliverables: board.deliverables ?? null,
|
|
172
|
+
usage: board.usage ?? null,
|
|
173
|
+
postWindow: board.postWindow ?? null,
|
|
174
|
+
},
|
|
175
|
+
exclusivity: board.exclusivity ? { raw: board.exclusivity } : null,
|
|
176
|
+
money: null,
|
|
177
|
+
calendar: Array.isArray(calendar) ? calendar : [],
|
|
178
|
+
flags: Array.isArray(board.flags) ? board.flags : [],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function serializeRecord({ record, decisions = [], archiveRef = null }) {
|
|
183
|
+
const parts = [`# ${record.brand ?? "Untitled"}`, "", writeBlock("", "brand", record).trim()];
|
|
184
|
+
|
|
185
|
+
if (decisions.length) parts.push("", "## Decisions", "", renderDecisions(decisions));
|
|
186
|
+
if (archiveRef) parts.push("", `_History: ${archiveRef}_`);
|
|
187
|
+
|
|
188
|
+
return `${parts.join("\n")}\n`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Merge what the drafting agent observed into the stored record.
|
|
193
|
+
*
|
|
194
|
+
* Upserts by stable key so a re-observed date updates in place instead of
|
|
195
|
+
* accumulating duplicates. Two things are never overwritten: a payment Erin
|
|
196
|
+
* confirmed by hand, and a decision already on the log.
|
|
197
|
+
*/
|
|
198
|
+
export function mergeRecord(record, updates) {
|
|
199
|
+
if (!updates) return record;
|
|
200
|
+
const next = { ...record };
|
|
201
|
+
|
|
202
|
+
for (const k of ["stage", "whoseMove", "status"]) {
|
|
203
|
+
if (updates[k] != null) next[k] = k === "stage" ? normalizeStage(updates[k]) : updates[k];
|
|
204
|
+
}
|
|
205
|
+
if (updates.deal) next.deal = { ...(next.deal ?? {}), ...updates.deal };
|
|
206
|
+
if (updates.exclusivity) next.exclusivity = { ...(next.exclusivity ?? {}), ...updates.exclusivity };
|
|
207
|
+
if (Array.isArray(updates.flags)) next.flags = updates.flags;
|
|
208
|
+
|
|
209
|
+
if (Array.isArray(updates.calendar)) {
|
|
210
|
+
next.calendar = upsertBy(next.calendar ?? [], updates.calendar, "key");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (updates.money) {
|
|
214
|
+
const prev = next.money ?? { invoices: [] };
|
|
215
|
+
next.money = {
|
|
216
|
+
...prev,
|
|
217
|
+
...updates.money,
|
|
218
|
+
invoices: upsertBy(prev.invoices ?? [], (updates.money.invoices ?? []).map(dropConfirmed), "id", (old, incoming) =>
|
|
219
|
+
// She answered "was I actually paid?" by hand. The agent re-reading the
|
|
220
|
+
// same ambiguous email must not quietly flip it back. This is the Navan
|
|
221
|
+
// Edge case: she believed she'd been paid and hadn't.
|
|
222
|
+
old.confirmed != null ? { ...incoming, ...pick(old, ["confirmed", "status", "paidAt"]) } : { ...old, ...incoming },
|
|
223
|
+
),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return next;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** An incoming update may never carry `confirmed` — only the creator sets it. */
|
|
231
|
+
function dropConfirmed(inv) {
|
|
232
|
+
if (!inv || typeof inv !== "object") return inv;
|
|
233
|
+
const { confirmed, ...rest } = inv;
|
|
234
|
+
return rest;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function upsertBy(existing, incoming, key, resolve) {
|
|
238
|
+
const out = existing.slice();
|
|
239
|
+
for (const item of incoming) {
|
|
240
|
+
if (item?.[key] == null) continue;
|
|
241
|
+
const i = out.findIndex((e) => e?.[key] === item[key]);
|
|
242
|
+
if (i === -1) out.push(item);
|
|
243
|
+
else out[i] = resolve ? resolve(out[i], item) : { ...out[i], ...item };
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function pick(obj, keys) {
|
|
249
|
+
return Object.fromEntries(keys.filter((k) => obj[k] !== undefined).map((k) => [k, obj[k]]));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Append decisions, skipping any we already recorded on the same date. */
|
|
253
|
+
export function appendDecisions(existing, incoming = []) {
|
|
254
|
+
const seen = new Set(existing.map((d) => `${d.date}|${d.what?.toLowerCase()}`));
|
|
255
|
+
const out = existing.slice();
|
|
256
|
+
for (const d of incoming) {
|
|
257
|
+
if (!d?.date || !d?.what) continue;
|
|
258
|
+
const k = `${d.date}|${String(d.what).toLowerCase()}`;
|
|
259
|
+
if (seen.has(k)) continue;
|
|
260
|
+
seen.add(k);
|
|
261
|
+
out.push(d);
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|