@veleads/gtm 0.3.0 → 0.6.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/CHANGELOG.md +39 -0
- package/README.md +18 -6
- package/dist/cli.js +1161 -287
- package/kit/AGENTS.md +37 -17
- package/kit/guides/github.md +60 -0
- package/kit/schemas/campaign-config.schema.json +30 -0
- package/kit/schemas/icp.schema.json +97 -8
- package/kit/schemas/project.schema.json +24 -3
- package/kit/skills/00-intake/SKILL.md +33 -16
- package/kit/skills/01-icp/SKILL.md +88 -33
- package/kit/skills/02-tam/SKILL.md +11 -7
- package/kit/skills/05-copy/SKILL.md +9 -3
- package/kit/skills/07-launch/SKILL.md +1 -1
- package/kit/skills/research/SKILL.md +62 -0
- package/kit/templates/campaign/config.yaml +3 -0
- package/kit/templates/project/icp.md +44 -8
- package/kit/templates/project/icp.yaml +13 -4
- package/kit/templates/project/intake.md +26 -11
- package/kit/templates/project/project.yaml +8 -4
- package/kit/templates/project/research.md +45 -0
- package/kit/templates/repo/README.md +4 -0
- package/kit/templates/repo/github/workflows/gtm.yml +5 -2
- package/package.json +5 -3
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,8 @@ var IsoDate = z.iso.date();
|
|
|
16
16
|
var IsoDateTime = z.iso.datetime({ offset: true });
|
|
17
17
|
var Domain = z.string().regex(/^(?!-)[a-z0-9-]+(\.[a-z0-9-]+)+$/, "bare lowercase domain, e.g. acme.com");
|
|
18
18
|
var Tier = z.enum(["A", "B", "C"]);
|
|
19
|
+
var GitHubHandle = z.string().regex(/^@[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/, "GitHub handle like @username");
|
|
20
|
+
var isGitHubHandle = (value) => !!value && GitHubHandle.safeParse(value).success;
|
|
19
21
|
var Stage = z.enum([
|
|
20
22
|
"intake",
|
|
21
23
|
"icp",
|
|
@@ -33,7 +35,8 @@ var Approval = z.object({
|
|
|
33
35
|
approved_by: z.string().min(1).nullable(),
|
|
34
36
|
approved_at: IsoDate.nullable(),
|
|
35
37
|
notes: z.string().optional(),
|
|
36
|
-
content_hash: z.string().regex(/^sha256:[0-9a-f]{16}$/).optional().describe("Fingerprint of the approved content, written by `gtm approve`. Never edit by hand.")
|
|
38
|
+
content_hash: z.string().regex(/^sha256:[0-9a-f]{16}$/).optional().describe("Fingerprint of the approved content, written by `gtm approve`. Never edit by hand."),
|
|
39
|
+
approved_via: z.string().url().optional().describe("Pull request whose review is the approval (GitHub mode). Written by `gtm approve --pr`.")
|
|
37
40
|
}).refine(
|
|
38
41
|
(a) => a.status !== "approved" || a.approved_by !== null && a.approved_at !== null,
|
|
39
42
|
"approved requires approved_by and approved_at"
|
|
@@ -63,26 +66,43 @@ var ProjectFile = z2.object({
|
|
|
63
66
|
status: z2.enum(["onboarding", "active", "paused", "closed"]),
|
|
64
67
|
started_at: IsoDate,
|
|
65
68
|
owners: z2.object({
|
|
66
|
-
gtm_engineer: z2.string().min(1),
|
|
67
|
-
sdrs: z2.array(z2.string().min(1)),
|
|
68
|
-
approver: z2.string().min(1).describe("
|
|
69
|
+
gtm_engineer: z2.string().min(1).describe("GitHub handle (@user) or name"),
|
|
70
|
+
sdrs: z2.array(z2.string().min(1)).describe("GitHub handles (@user) or names"),
|
|
71
|
+
approver: z2.string().min(1).describe("Signs off ICP and copy. A GitHub handle (@user) makes approvals PR reviews enforced by GitHub; a name records them by name")
|
|
69
72
|
}),
|
|
70
73
|
regions: z2.array(z2.string().min(2)).min(1).describe("Target regions, e.g. EU, UK, US"),
|
|
71
|
-
features: z2.record(z2.string(), z2.boolean()).default({}).describe("Opt-in core features, see FEATURES")
|
|
74
|
+
features: z2.record(z2.string(), z2.boolean()).default({}).describe("Opt-in core features, see FEATURES"),
|
|
75
|
+
tracker: z2.object({ kind: z2.enum(["github", "none"]) }).default({ kind: "none" }).describe("Where `gtm track` mirrors stages and gates. Git files stay the source of truth")
|
|
72
76
|
});
|
|
73
77
|
|
|
74
78
|
// ../schemas/src/icp.ts
|
|
75
79
|
import { z as z3 } from "zod";
|
|
80
|
+
var FirmographicFilters = z3.object({
|
|
81
|
+
industries: z3.array(z3.string()).optional(),
|
|
82
|
+
employee_count: IntRange.optional(),
|
|
83
|
+
revenue_usd: IntRange.optional(),
|
|
84
|
+
hq_countries: z3.array(z3.string().length(2)).optional().describe("ISO 3166-1 alpha-2"),
|
|
85
|
+
technologies_any: z3.array(z3.string()).optional(),
|
|
86
|
+
technologies_none: z3.array(z3.string()).optional(),
|
|
87
|
+
keywords_any: z3.array(z3.string()).optional(),
|
|
88
|
+
keywords_none: z3.array(z3.string()).optional()
|
|
89
|
+
});
|
|
76
90
|
var Firmographics = z3.object({
|
|
77
91
|
industries: z3.array(z3.string()).default([]),
|
|
78
92
|
employee_count: IntRange.optional(),
|
|
79
93
|
revenue_usd: IntRange.optional(),
|
|
80
|
-
hq_countries: z3.array(z3.string().length(2)).default([])
|
|
94
|
+
hq_countries: z3.array(z3.string().length(2)).default([]),
|
|
81
95
|
technologies_any: z3.array(z3.string()).default([]),
|
|
82
96
|
technologies_none: z3.array(z3.string()).default([]),
|
|
83
97
|
keywords_any: z3.array(z3.string()).default([]),
|
|
84
98
|
keywords_none: z3.array(z3.string()).default([])
|
|
85
99
|
});
|
|
100
|
+
var IcpSegmentSpec = z3.object({
|
|
101
|
+
id: Slug,
|
|
102
|
+
description: z3.string().min(1),
|
|
103
|
+
firmographics: FirmographicFilters.default({}),
|
|
104
|
+
personas: z3.array(Slug).min(1).optional().describe("Restrict to these persona ids; default: all personas")
|
|
105
|
+
});
|
|
86
106
|
var IcpSegment = z3.object({
|
|
87
107
|
id: Slug,
|
|
88
108
|
description: z3.string().min(1),
|
|
@@ -96,11 +116,22 @@ var IcpPersona = z3.object({
|
|
|
96
116
|
seniorities: z3.array(z3.enum(["c_level", "vp", "head", "director", "manager", "senior", "entry"])).default([]),
|
|
97
117
|
departments: z3.array(z3.string()).default([])
|
|
98
118
|
});
|
|
119
|
+
var FILTER_KEYS = Object.keys(FirmographicFilters.shape);
|
|
120
|
+
function mergeFilters(core, segment) {
|
|
121
|
+
const merged = {};
|
|
122
|
+
for (const key of FILTER_KEYS) {
|
|
123
|
+
const value = segment?.[key] !== void 0 ? segment[key] : core?.[key];
|
|
124
|
+
if (value !== void 0) merged[key] = value;
|
|
125
|
+
}
|
|
126
|
+
return Firmographics.parse(merged);
|
|
127
|
+
}
|
|
128
|
+
var hasAnyFilter = (f) => f.industries.length + f.hq_countries.length + f.technologies_any.length + f.keywords_any.length > 0 || !!f.employee_count || !!f.revenue_usd;
|
|
99
129
|
var IcpFile = z3.object({
|
|
100
130
|
version: z3.literal(FILE_VERSIONS.icp),
|
|
101
131
|
project_id: Slug,
|
|
102
132
|
approval: Approval,
|
|
103
|
-
|
|
133
|
+
core: z3.object({ firmographics: FirmographicFilters.default({}) }).optional().describe("Profile every segment shares. Segments only add or replace what differs"),
|
|
134
|
+
segments: z3.array(IcpSegmentSpec).min(1),
|
|
104
135
|
personas: z3.array(IcpPersona).min(1),
|
|
105
136
|
exclusions: z3.object({
|
|
106
137
|
domains: z3.array(Domain).default([]),
|
|
@@ -113,7 +144,39 @@ var IcpFile = z3.object({
|
|
|
113
144
|
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
|
|
114
145
|
if (dupes.length) ctx.addIssue({ code: "custom", path: [key], message: `duplicate ids: ${dupes.join(", ")}` });
|
|
115
146
|
}
|
|
147
|
+
const personaIds = new Set(icp.personas.map((p) => p.id));
|
|
148
|
+
icp.segments.forEach((s, i) => {
|
|
149
|
+
for (const p of s.personas ?? []) {
|
|
150
|
+
if (!personaIds.has(p)) ctx.addIssue({ code: "custom", path: ["segments", i, "personas"], message: `unknown persona "${p}"` });
|
|
151
|
+
}
|
|
152
|
+
let resolved;
|
|
153
|
+
try {
|
|
154
|
+
resolved = mergeFilters(icp.core?.firmographics, s.firmographics);
|
|
155
|
+
} catch {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!hasAnyFilter(resolved)) {
|
|
159
|
+
ctx.addIssue({ code: "custom", path: ["segments", i, "firmographics"], message: `segment "${s.id}" has no filters (core + segment): it would match every company` });
|
|
160
|
+
}
|
|
161
|
+
const range = resolved.employee_count;
|
|
162
|
+
if (range?.min !== void 0 && range.max !== void 0 && range.min > range.max) {
|
|
163
|
+
ctx.addIssue({ code: "custom", path: ["segments", i, "firmographics", "employee_count"], message: `segment "${s.id}" resolves to min > max` });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
116
166
|
});
|
|
167
|
+
function resolveIcp(icp) {
|
|
168
|
+
const allPersonas = icp.personas.map((p) => p.id);
|
|
169
|
+
return {
|
|
170
|
+
exclusions: icp.exclusions,
|
|
171
|
+
segments: icp.segments.map((s) => ({
|
|
172
|
+
id: s.id,
|
|
173
|
+
description: s.description,
|
|
174
|
+
firmographics: mergeFilters(icp.core?.firmographics, s.firmographics),
|
|
175
|
+
personas: s.personas ?? allPersonas,
|
|
176
|
+
overrides: FILTER_KEYS.filter((k) => s.firmographics?.[k] !== void 0)
|
|
177
|
+
}))
|
|
178
|
+
};
|
|
179
|
+
}
|
|
117
180
|
|
|
118
181
|
// ../schemas/src/signals-config.ts
|
|
119
182
|
import { z as z4 } from "zod";
|
|
@@ -148,6 +211,10 @@ var Enrichment = z5.object({
|
|
|
148
211
|
max_contacts_per_account: z5.number().int().min(1).max(10),
|
|
149
212
|
cache_ttl_days: z5.number().int().positive()
|
|
150
213
|
});
|
|
214
|
+
var Tam = z5.object({
|
|
215
|
+
source: Slug.describe("Account source adapter (mock-accounts for local proofs; real providers in .gtm/providers/)"),
|
|
216
|
+
max_accounts_per_segment: z5.number().int().positive().max(1e5).default(500)
|
|
217
|
+
});
|
|
151
218
|
var Budget = z5.object({
|
|
152
219
|
currency: z5.literal("USD"),
|
|
153
220
|
max_total: z5.number().positive().describe("Hard cap for this campaign; jobs stop when reached"),
|
|
@@ -194,6 +261,7 @@ var CampaignConfig = z5.object({
|
|
|
194
261
|
owner: z5.string().min(1),
|
|
195
262
|
stage: Stage.describe("The stage currently being worked on"),
|
|
196
263
|
tam_id: Slug,
|
|
264
|
+
tam: Tam.optional().describe("How `gtm job tam` builds the TAM"),
|
|
197
265
|
segments: z5.array(Slug).min(1).describe("Segment ids from icp.yaml"),
|
|
198
266
|
personas: z5.array(Slug).min(1).describe("Persona ids from icp.yaml"),
|
|
199
267
|
enrichment: Enrichment,
|
|
@@ -343,10 +411,155 @@ function copyContentHash(markdown) {
|
|
|
343
411
|
return `sha256:${createHash("sha256").update(normalized).digest("hex").slice(0, 16)}`;
|
|
344
412
|
}
|
|
345
413
|
|
|
414
|
+
// ../schemas/src/jobs.ts
|
|
415
|
+
import { z as z10 } from "zod";
|
|
416
|
+
var JOB_NAMES = { tam: "gtm-tam" };
|
|
417
|
+
var JobContext = z10.object({
|
|
418
|
+
project_id: Slug,
|
|
419
|
+
campaign_id: Slug,
|
|
420
|
+
triggered_by: z10.string().min(1),
|
|
421
|
+
core_version: z10.string().min(1),
|
|
422
|
+
git_sha: z10.string().nullable(),
|
|
423
|
+
config_hash: z10.string().min(1)
|
|
424
|
+
});
|
|
425
|
+
var TamJobInput = z10.object({
|
|
426
|
+
ctx: JobContext,
|
|
427
|
+
tam_id: Slug,
|
|
428
|
+
source: Slug.describe("Account source adapter, e.g. mock-accounts"),
|
|
429
|
+
max_accounts_per_segment: z10.number().int().positive().max(1e5),
|
|
430
|
+
segments: z10.array(IcpSegment).min(1),
|
|
431
|
+
exclusions: z10.object({ domains: z10.array(Domain), industries: z10.array(z10.string()), hq_countries: z10.array(z10.string()) }),
|
|
432
|
+
budget_usd: z10.number().nonnegative()
|
|
433
|
+
});
|
|
434
|
+
var TamJobResult = z10.object({
|
|
435
|
+
run_id: z10.uuid(),
|
|
436
|
+
segments: z10.array(z10.object({
|
|
437
|
+
segment_id: Slug,
|
|
438
|
+
found: z10.number().int(),
|
|
439
|
+
inserted: z10.number().int(),
|
|
440
|
+
updated: z10.number().int(),
|
|
441
|
+
excluded: z10.number().int(),
|
|
442
|
+
reachable: z10.number().int(),
|
|
443
|
+
active: z10.number().int()
|
|
444
|
+
})),
|
|
445
|
+
totals: z10.object({ tam: z10.number().int(), reachable: z10.number().int(), active: z10.number().int() }),
|
|
446
|
+
cost_usd: z10.number().nonnegative()
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// src/commands/approve.ts
|
|
450
|
+
import { parse } from "yaml";
|
|
451
|
+
|
|
452
|
+
// src/github.ts
|
|
453
|
+
import { spawnSync } from "child_process";
|
|
454
|
+
function repoFromGit(root2) {
|
|
455
|
+
const res = spawnSync("git", ["remote", "get-url", "origin"], { cwd: root2, encoding: "utf8" });
|
|
456
|
+
return res.status === 0 ? parseRepoUrl(res.stdout.trim()) : null;
|
|
457
|
+
}
|
|
458
|
+
function parseRepoUrl(url) {
|
|
459
|
+
const m = url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
460
|
+
return m ? { owner: m[1], repo: m[2] } : null;
|
|
461
|
+
}
|
|
462
|
+
function githubToken() {
|
|
463
|
+
const env = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
|
|
464
|
+
if (env) return env;
|
|
465
|
+
const gh = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
|
|
466
|
+
return gh.status === 0 && gh.stdout.trim() ? gh.stdout.trim() : null;
|
|
467
|
+
}
|
|
468
|
+
var login = (handle) => handle.replace(/^@/, "").toLowerCase();
|
|
469
|
+
var GitHubError = class extends Error {
|
|
470
|
+
constructor(status, message) {
|
|
471
|
+
super(message);
|
|
472
|
+
this.status = status;
|
|
473
|
+
this.name = "GitHubError";
|
|
474
|
+
}
|
|
475
|
+
status;
|
|
476
|
+
};
|
|
477
|
+
var GitHubApi = class _GitHubApi {
|
|
478
|
+
constructor(repo, token, fetchImpl = fetch) {
|
|
479
|
+
this.repo = repo;
|
|
480
|
+
this.token = token;
|
|
481
|
+
this.fetchImpl = fetchImpl;
|
|
482
|
+
}
|
|
483
|
+
repo;
|
|
484
|
+
token;
|
|
485
|
+
fetchImpl;
|
|
486
|
+
/** Resolves repo + token from the working copy and environment; null with a reason if unavailable. */
|
|
487
|
+
static fromRepo(root2, fetchImpl = fetch) {
|
|
488
|
+
const repo = repoFromGit(root2);
|
|
489
|
+
if (!repo) return "no GitHub remote: `git remote get-url origin` must point to github.com";
|
|
490
|
+
const token = githubToken();
|
|
491
|
+
if (!token) return "no GitHub token: run `gh auth login`, or set GH_TOKEN / GITHUB_TOKEN";
|
|
492
|
+
return new _GitHubApi(repo, token, fetchImpl);
|
|
493
|
+
}
|
|
494
|
+
/** Path relative to /repos/{owner}/{repo} when it starts with "repo:", else absolute (e.g. /user). */
|
|
495
|
+
async request(method, path, body) {
|
|
496
|
+
const url = path.startsWith("repo:") ? `https://api.github.com/repos/${this.repo.owner}/${this.repo.repo}${path.slice(5)}` : `https://api.github.com${path}`;
|
|
497
|
+
const res = await this.fetchImpl(url, {
|
|
498
|
+
method,
|
|
499
|
+
headers: {
|
|
500
|
+
Accept: "application/vnd.github+json",
|
|
501
|
+
Authorization: `Bearer ${this.token}`,
|
|
502
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
503
|
+
...body ? { "Content-Type": "application/json" } : {}
|
|
504
|
+
},
|
|
505
|
+
body: body ? JSON.stringify(body) : void 0
|
|
506
|
+
});
|
|
507
|
+
if (!res.ok) throw new GitHubError(res.status, `GitHub ${method} ${path.replace(/^repo:/, "")}: ${res.status} ${await res.text()}`);
|
|
508
|
+
return res.status === 204 ? void 0 : await res.json();
|
|
509
|
+
}
|
|
510
|
+
/** Blob URL for a repo file on the default branch, for links in issue bodies. */
|
|
511
|
+
fileUrl(path) {
|
|
512
|
+
return `https://github.com/${this.repo.owner}/${this.repo.repo}/blob/HEAD/${path}`;
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
// src/github-approval.ts
|
|
517
|
+
function prNumber(ref) {
|
|
518
|
+
const m = ref.match(/(?:^#?|\/pull\/)(\d+)\/?$/);
|
|
519
|
+
return m ? Number(m[1]) : null;
|
|
520
|
+
}
|
|
521
|
+
function latestStateBy(reviews, approver) {
|
|
522
|
+
return reviews.filter((r) => r.user && login(r.user.login) === login(approver) && r.state !== "COMMENTED").at(-1);
|
|
523
|
+
}
|
|
524
|
+
async function verifyPrApproval(api, ref, approver, path) {
|
|
525
|
+
const n = prNumber(ref);
|
|
526
|
+
if (!n) return `--pr must be a pull request number or URL (got "${ref}")`;
|
|
527
|
+
const pr = await api.request("GET", `repo:/pulls/${n}`);
|
|
528
|
+
const files = await api.request("GET", `repo:/pulls/${n}/files?per_page=100`);
|
|
529
|
+
if (!files.some((f) => f.filename === path)) return `PR #${n} does not change ${path}`;
|
|
530
|
+
if (login(pr.user.login) === login(approver)) {
|
|
531
|
+
return `PR #${n} was opened by ${approver}, and GitHub doesn't let authors approve their own PRs. The PR must be opened by someone else (e.g. the GTM engineer or the agent's account) and approved by ${approver}.`;
|
|
532
|
+
}
|
|
533
|
+
const reviews = await api.request("GET", `repo:/pulls/${n}/reviews?per_page=100`);
|
|
534
|
+
const review = latestStateBy(reviews, approver);
|
|
535
|
+
if (!review || review.state !== "APPROVED") return `${approver} has not approved PR #${n} (latest review: ${review?.state ?? "none"})`;
|
|
536
|
+
if (review.commit_id !== pr.head.sha) return `${approver} approved an older commit of PR #${n}; the PR changed since. Ask for a fresh approval.`;
|
|
537
|
+
const file = await api.request("GET", `repo:/contents/${path}?ref=${pr.head.sha}`);
|
|
538
|
+
return {
|
|
539
|
+
prUrl: pr.html_url,
|
|
540
|
+
reviewer: `@${review.user.login}`,
|
|
541
|
+
approvedAt: review.submitted_at.slice(0, 10),
|
|
542
|
+
approvedContent: Buffer.from(file.content, file.encoding).toString("utf8")
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
async function verifyRecordedApproval(api, prUrl, approver, path, pinnedHash, hashOf) {
|
|
546
|
+
const n = prNumber(prUrl);
|
|
547
|
+
if (!n) return `approved_via is not a pull request URL: ${prUrl}`;
|
|
548
|
+
if (!prUrl.includes(`/${api.repo.owner}/${api.repo.repo}/pull/`)) return `approved_via points to another repository: ${prUrl}`;
|
|
549
|
+
const reviews = await api.request("GET", `repo:/pulls/${n}/reviews?per_page=100`);
|
|
550
|
+
const review = latestStateBy(reviews, approver);
|
|
551
|
+
if (review?.state !== "APPROVED") return `${approver}'s latest review on PR #${n} is ${review?.state ?? "missing"}, not APPROVED`;
|
|
552
|
+
const file = await api.request("GET", `repo:/contents/${path}?ref=${review.commit_id}`);
|
|
553
|
+
const reviewed = hashOf(Buffer.from(file.content, file.encoding).toString("utf8"));
|
|
554
|
+
return reviewed === pinnedHash ? true : `${path} at the commit ${approver} approved (${review.commit_id.slice(0, 7)}) has content ${reviewed}, but ${pinnedHash ?? "no hash"} is pinned`;
|
|
555
|
+
}
|
|
556
|
+
|
|
346
557
|
// src/commands/approve.ts
|
|
347
558
|
var USAGE = `Usage:
|
|
348
|
-
gtm approve icp --
|
|
349
|
-
gtm approve copy <campaign> --
|
|
559
|
+
gtm approve icp --pr <number> [--notes ".."] (approver is a GitHub @handle)
|
|
560
|
+
gtm approve copy <campaign> --pr <number> [--notes ".."]
|
|
561
|
+
gtm approve icp --by "<Name (role)>" [--notes ".."] [--date YYYY-MM-DD] (approver is a name)
|
|
562
|
+
gtm approve copy <campaign> --by "<Name (role)>" [--notes ".."] [--date YYYY-MM-DD]`;
|
|
350
563
|
function load(file) {
|
|
351
564
|
if (!existsSync(file)) {
|
|
352
565
|
console.error(`${file} not found: run this from the root of a project repo`);
|
|
@@ -362,6 +575,7 @@ function load(file) {
|
|
|
362
575
|
function pin(file, doc, path, schema, hash, opts) {
|
|
363
576
|
const approval = { status: "approved", approved_by: opts.by, approved_at: opts.date, content_hash: hash };
|
|
364
577
|
if (opts.notes) approval.notes = opts.notes;
|
|
578
|
+
if (opts.via) approval.approved_via = opts.via;
|
|
365
579
|
const candidate = doc.toJS();
|
|
366
580
|
let target = candidate;
|
|
367
581
|
for (const key of path.slice(0, -1)) target = target[key] ??= {};
|
|
@@ -373,94 +587,469 @@ function pin(file, doc, path, schema, hash, opts) {
|
|
|
373
587
|
return false;
|
|
374
588
|
}
|
|
375
589
|
for (const [key, value] of Object.entries(approval)) doc.setIn([...path, key], value);
|
|
590
|
+
if (!opts.via) doc.deleteIn([...path, "approved_via"]);
|
|
376
591
|
writeFileSync(file, doc.toString({ flowCollectionPadding: false, lineWidth: 0 }));
|
|
377
592
|
return true;
|
|
378
593
|
}
|
|
379
|
-
function runApprove(root2, args2, opts) {
|
|
594
|
+
async function runApprove(root2, args2, opts) {
|
|
380
595
|
const [gate, campaign] = args2;
|
|
381
596
|
if (gate !== "icp" && gate !== "copy") {
|
|
382
597
|
console.error(USAGE);
|
|
383
598
|
if (gate) console.error(`The "${gate}" gate is recorded by hand in config.yaml \u2192 gates (AGENTS.md section 4).`);
|
|
384
599
|
return false;
|
|
385
600
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
console.error('--by is required: the human who approved, e.g. --by "Maria Chen (VP Marketing)"');
|
|
601
|
+
if (gate === "copy" && !campaign) {
|
|
602
|
+
console.error(USAGE);
|
|
389
603
|
return false;
|
|
390
604
|
}
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
605
|
+
const approver = (existsSync(join(root2, "project.yaml")) ? parse(readFileSync(join(root2, "project.yaml"), "utf8")) : null)?.owners?.approver;
|
|
606
|
+
const githubMode = isGitHubHandle(approver);
|
|
607
|
+
const contentPath = gate === "icp" ? "context/icp.yaml" : `campaigns/${campaign}/copy.md`;
|
|
608
|
+
const contentFile = join(root2, contentPath);
|
|
609
|
+
if (!existsSync(contentFile)) {
|
|
610
|
+
console.error(`${contentPath} not found`);
|
|
394
611
|
return false;
|
|
395
612
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const hash2 = icpContentHash(doc2.toJS());
|
|
401
|
-
if (!pin(file2, doc2, ["approval"], IcpFile, hash2, { ...opts, by, date })) return false;
|
|
402
|
-
console.log(`ICP approved by ${by} on ${date} (content ${hash2}).`);
|
|
403
|
-
console.log("Commit context/icp.yaml together with the run log that quotes the approval.");
|
|
404
|
-
return true;
|
|
613
|
+
const local = readFileSync(contentFile, "utf8");
|
|
614
|
+
if (gate === "copy" && !local.split("\n").some((l) => l.trim() && !/^\s*(>|#)/.test(l))) {
|
|
615
|
+
console.error(`${contentPath} has no copy yet`);
|
|
616
|
+
return false;
|
|
405
617
|
}
|
|
406
|
-
|
|
407
|
-
|
|
618
|
+
const hashOf = (text) => gate === "icp" ? icpContentHash(parse(text)) : copyContentHash(text);
|
|
619
|
+
let by, date, via;
|
|
620
|
+
if (githubMode) {
|
|
621
|
+
if (!opts.pr) {
|
|
622
|
+
console.error(`The approver is ${approver} (GitHub mode): the approval is their APPROVED review on a pull request.`);
|
|
623
|
+
console.error(`Open a PR with ${contentPath}, have ${approver} approve it on GitHub, then run: npx gtm approve ${gate}${campaign ? ` ${campaign}` : ""} --pr <number>`);
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
const api = GitHubApi.fromRepo(root2, opts.fetchImpl);
|
|
627
|
+
if (typeof api === "string") {
|
|
628
|
+
console.error(`Cannot verify on GitHub: ${api}`);
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
const verified = await verifyPrApproval(api, opts.pr, approver, contentPath);
|
|
632
|
+
if (typeof verified === "string") {
|
|
633
|
+
console.error(verified);
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
if (hashOf(verified.approvedContent) !== hashOf(local)) {
|
|
637
|
+
console.error(`Your local ${contentPath} differs from the version ${approver} approved on the PR. Check out / pull the PR's latest commit, then run this again.`);
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
by = verified.reviewer;
|
|
641
|
+
date = verified.approvedAt;
|
|
642
|
+
via = verified.prUrl;
|
|
643
|
+
} else {
|
|
644
|
+
if (opts.pr) {
|
|
645
|
+
console.error(`--pr needs a GitHub approver: set project.yaml \u2192 owners.approver to their @handle, or record with --by.`);
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
by = opts.by?.trim() ?? "";
|
|
649
|
+
if (!by) {
|
|
650
|
+
console.error('--by is required: the human who approved, e.g. --by "Maria Chen (VP Marketing)"');
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
date = opts.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
654
|
+
if (!IsoDate.safeParse(date).success) {
|
|
655
|
+
console.error(`--date must be YYYY-MM-DD (got "${date}")`);
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const hash = hashOf(local);
|
|
660
|
+
const file = gate === "icp" ? contentFile : join(root2, "campaigns", campaign, "config.yaml");
|
|
661
|
+
const doc = load(file);
|
|
662
|
+
if (!doc) return false;
|
|
663
|
+
const path = gate === "icp" ? ["approval"] : ["gates", "copy"];
|
|
664
|
+
if (!pin(file, doc, path, gate === "icp" ? IcpFile : CampaignConfig, hash, { ...opts, by, date, via })) return false;
|
|
665
|
+
const what = gate === "icp" ? "ICP" : `Copy for ${campaign}`;
|
|
666
|
+
console.log(`${what} approved by ${by} on ${date}${via ? ` via ${via}` : ""} (content ${hash}).`);
|
|
667
|
+
console.log(`Commit ${gate === "icp" ? "context/icp.yaml" : `campaigns/${campaign}/config.yaml and copy.md`} together with the run log that quotes the approval${via ? "" : ""}.`);
|
|
668
|
+
if (via) console.log(`Then close the tracker item: npx gtm track gate-done ${gate}${campaign ? ` ${campaign}` : ""}`);
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// src/commands/icp.ts
|
|
673
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
674
|
+
import { join as join2 } from "path";
|
|
675
|
+
import { parse as parse2 } from "yaml";
|
|
676
|
+
var LABELS = {
|
|
677
|
+
industries: "industries",
|
|
678
|
+
employee_count: "employees",
|
|
679
|
+
revenue_usd: "revenue (USD)",
|
|
680
|
+
hq_countries: "HQ countries",
|
|
681
|
+
technologies_any: "tech (any of)",
|
|
682
|
+
technologies_none: "tech (none of)",
|
|
683
|
+
keywords_any: "keywords (any)",
|
|
684
|
+
keywords_none: "keywords (none)"
|
|
685
|
+
};
|
|
686
|
+
function show(value) {
|
|
687
|
+
if (Array.isArray(value)) return value.length ? value.join(", ") : null;
|
|
688
|
+
if (value && typeof value === "object") {
|
|
689
|
+
const r = value;
|
|
690
|
+
return r.min !== void 0 && r.max !== void 0 ? `${r.min}\u2013${r.max}` : r.min !== void 0 ? `\u2265 ${r.min}` : r.max !== void 0 ? `\u2264 ${r.max}` : null;
|
|
691
|
+
}
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
function runIcp(root2) {
|
|
695
|
+
const file = join2(root2, "context", "icp.yaml");
|
|
696
|
+
if (!existsSync2(file)) {
|
|
697
|
+
console.error("context/icp.yaml not found: run this from the root of a project repo");
|
|
408
698
|
return false;
|
|
409
699
|
}
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
console.error(`
|
|
700
|
+
const parsed = IcpFile.safeParse(parse2(readFileSync2(file, "utf8")));
|
|
701
|
+
if (!parsed.success) {
|
|
702
|
+
console.error("context/icp.yaml is not valid (run `npx gtm validate` for details):");
|
|
703
|
+
for (const i of parsed.error.issues) console.error(` \u2717 ${i.path.join(".") || "(root)"}: ${i.message}`);
|
|
414
704
|
return false;
|
|
415
705
|
}
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
706
|
+
const icp = parsed.data;
|
|
707
|
+
const resolved = resolveIcp(icp);
|
|
708
|
+
console.log(`ICP for ${icp.project_id} \xB7 approval: ${icp.approval.status}${icp.approval.approved_by ? ` by ${icp.approval.approved_by}` : ""}`);
|
|
709
|
+
const core = icp.core?.firmographics ?? {};
|
|
710
|
+
const coreLines = Object.keys(LABELS).map((k) => [LABELS[k], show(core[k])]).filter((line) => line[1] !== null);
|
|
711
|
+
console.log(`
|
|
712
|
+
Core (every segment): ${coreLines.length ? "" : "none (each segment defines its own filters)"}`);
|
|
713
|
+
for (const [label, v] of coreLines) console.log(` ${label.padEnd(16)}${v}`);
|
|
714
|
+
for (const s of resolved.segments) {
|
|
715
|
+
console.log(`
|
|
716
|
+
Segment ${s.id}: ${s.description}`);
|
|
717
|
+
for (const k of Object.keys(LABELS)) {
|
|
718
|
+
const v = show(s.firmographics[k]);
|
|
719
|
+
if (v) console.log(` ${LABELS[k].padEnd(16)}${v}${s.overrides.includes(k) ? core[k] !== void 0 ? " \u2190 segment (replaces core)" : " \u2190 segment" : ""}`);
|
|
720
|
+
}
|
|
721
|
+
console.log(` ${"personas".padEnd(16)}${s.personas.join(", ")}${s.personas.length < icp.personas.length ? " \u2190 restricted" : ""}`);
|
|
722
|
+
}
|
|
723
|
+
const x = resolved.exclusions;
|
|
724
|
+
const ex = [x.domains.length && `${x.domains.length} domain(s)`, x.industries.length && `industries: ${x.industries.join(", ")}`, x.hq_countries.length && `countries: ${x.hq_countries.join(", ")}`].filter(Boolean);
|
|
725
|
+
console.log(`
|
|
726
|
+
Exclusions (all segments): ${ex.length ? ex.join(" \xB7 ") : "none"}`);
|
|
727
|
+
return true;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// src/commands/github.ts
|
|
731
|
+
var LABELS2 = [
|
|
732
|
+
{ name: "gtm", color: "5319e7", description: "Managed by gtm track" },
|
|
733
|
+
{ name: "campaign", color: "1d76db", description: "gtm campaign tracker" },
|
|
734
|
+
{ name: "gate", color: "d93f0b", description: "gtm approval gate (hard stop)" },
|
|
735
|
+
...["icp", "copy", "launch"].map((g) => ({ name: `gate:${g}`, color: "d93f0b", description: `gtm ${g} gate` })),
|
|
736
|
+
...Stage.options.map((s) => ({ name: `stage:${s}`, color: "c5def5", description: `gtm stage ${s}` }))
|
|
737
|
+
];
|
|
738
|
+
var RULESET_NAME = "gtm: approvals";
|
|
739
|
+
function rulesetBody() {
|
|
740
|
+
return {
|
|
741
|
+
name: RULESET_NAME,
|
|
742
|
+
target: "branch",
|
|
743
|
+
enforcement: "active",
|
|
744
|
+
conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
|
|
745
|
+
rules: [
|
|
746
|
+
{ type: "deletion" },
|
|
747
|
+
{ type: "non_fast_forward" },
|
|
748
|
+
{
|
|
749
|
+
type: "pull_request",
|
|
750
|
+
parameters: {
|
|
751
|
+
required_approving_review_count: 0,
|
|
752
|
+
require_code_owner_review: true,
|
|
753
|
+
// Off on purpose: `gtm approve --pr` commits the approval record to the PR after the review.
|
|
754
|
+
// Content after approval is guarded by the pinned hash, re-checked by `gtm validate
|
|
755
|
+
// --verify-github` against the exact reviewed commit (a required CI check).
|
|
756
|
+
dismiss_stale_reviews_on_push: false,
|
|
757
|
+
require_last_push_approval: false,
|
|
758
|
+
required_review_thread_resolution: false
|
|
759
|
+
}
|
|
760
|
+
},
|
|
761
|
+
{ type: "required_status_checks", parameters: { strict_required_status_checks_policy: false, required_status_checks: [{ context: "check" }] } }
|
|
762
|
+
]
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
async function runGithub(root2, args2, opts) {
|
|
766
|
+
if (args2[0] !== "setup") {
|
|
767
|
+
console.error("Usage: gtm github setup [--apply]");
|
|
419
768
|
return false;
|
|
420
769
|
}
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
console.log(`
|
|
770
|
+
const api = GitHubApi.fromRepo(root2, opts.fetchImpl);
|
|
771
|
+
if (typeof api === "string") {
|
|
772
|
+
console.error(api);
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
const repo = `${api.repo.owner}/${api.repo.repo}`;
|
|
776
|
+
console.log(`GitHub setup for ${repo}${opts.apply ? "" : " (plan only: re-run with --apply)"}:`);
|
|
777
|
+
console.log(` \u2022 labels: ${LABELS2.map((l) => l.name).join(", ")}`);
|
|
778
|
+
console.log(` \u2022 ruleset "${RULESET_NAME}" on the default branch: PR required, review from CODEOWNERS required, CI job "check" must pass, no force-push or deletion`);
|
|
779
|
+
console.log(" \u2022 CODEOWNERS is maintained by `npx gtm sync` from project.yaml \u2192 owners.approver");
|
|
780
|
+
if (!opts.apply) return true;
|
|
781
|
+
const existing = new Set((await api.request("GET", "repo:/labels?per_page=100")).map((l) => l.name));
|
|
782
|
+
for (const label of LABELS2) {
|
|
783
|
+
if (existing.has(label.name)) continue;
|
|
784
|
+
await api.request("POST", "repo:/labels", label);
|
|
785
|
+
console.log(` + label ${label.name}`);
|
|
786
|
+
}
|
|
787
|
+
try {
|
|
788
|
+
const rulesets = await api.request("GET", "repo:/rulesets");
|
|
789
|
+
const found = rulesets.find((r) => r.name === RULESET_NAME);
|
|
790
|
+
if (found) await api.request("PUT", `repo:/rulesets/${found.id}`, rulesetBody());
|
|
791
|
+
else await api.request("POST", "repo:/rulesets", rulesetBody());
|
|
792
|
+
console.log(` ${found ? "~ updated" : "+ created"} ruleset "${RULESET_NAME}"`);
|
|
793
|
+
} catch (e) {
|
|
794
|
+
if (e instanceof GitHubError && (e.status === 403 || e.status === 404)) {
|
|
795
|
+
console.error(` \u2717 could not create the ruleset (${e.status}). Rulesets on private repos need a paid GitHub plan (Team/Pro), and the token needs admin rights.`);
|
|
796
|
+
console.error(" Without it GitHub still requests the approver's review via CODEOWNERS, and `gtm validate --verify-github` in CI still verifies every approval.");
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
throw e;
|
|
800
|
+
}
|
|
801
|
+
console.log("Done. In CI, `npx gtm validate --verify-github` checks each recorded approval against GitHub.");
|
|
802
|
+
return true;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// src/commands/track.ts
|
|
806
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
807
|
+
import { join as join3 } from "path";
|
|
808
|
+
import { parse as parse3 } from "yaml";
|
|
809
|
+
|
|
810
|
+
// src/tracker/github.ts
|
|
811
|
+
var marker = (key) => `<!-- gtm-key: ${key} -->`;
|
|
812
|
+
var BASE_LABEL = "gtm";
|
|
813
|
+
var GitHubTracker = class {
|
|
814
|
+
constructor(api) {
|
|
815
|
+
this.api = api;
|
|
816
|
+
}
|
|
817
|
+
api;
|
|
818
|
+
kind = "github";
|
|
819
|
+
async find(key) {
|
|
820
|
+
for (let page = 1; page <= 10; page++) {
|
|
821
|
+
const issues = await this.api.request("GET", `repo:/issues?labels=${BASE_LABEL}&state=all&per_page=100&page=${page}`);
|
|
822
|
+
const hit = issues.find((i) => !i.pull_request && i.body?.includes(marker(key)));
|
|
823
|
+
if (hit) return hit;
|
|
824
|
+
if (issues.length < 100) return null;
|
|
825
|
+
}
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
async upsert(item) {
|
|
829
|
+
const body = `${item.body}
|
|
830
|
+
|
|
831
|
+
${marker(item.key)}`;
|
|
832
|
+
const wanted = [.../* @__PURE__ */ new Set([BASE_LABEL, ...item.labels])];
|
|
833
|
+
const existing = await this.find(item.key);
|
|
834
|
+
if (!existing) {
|
|
835
|
+
const created = await this.api.request("POST", "repo:/issues", { title: item.title, body, labels: wanted, assignees: item.assignees ?? [] });
|
|
836
|
+
return { key: item.key, url: created.html_url, created: true };
|
|
837
|
+
}
|
|
838
|
+
const exclusive = item.exclusiveLabelPrefixes ?? [];
|
|
839
|
+
const kept = existing.labels.map((l) => l.name).filter((n) => !exclusive.some((p) => n.startsWith(p)) || wanted.includes(n));
|
|
840
|
+
await this.api.request("PATCH", `repo:/issues/${existing.number}`, {
|
|
841
|
+
title: item.title,
|
|
842
|
+
body,
|
|
843
|
+
state: "open",
|
|
844
|
+
labels: [.../* @__PURE__ */ new Set([...kept, ...wanted])],
|
|
845
|
+
...item.assignees ? { assignees: item.assignees } : {}
|
|
846
|
+
});
|
|
847
|
+
return { key: item.key, url: existing.html_url, created: false };
|
|
848
|
+
}
|
|
849
|
+
async comment(key, text) {
|
|
850
|
+
const issue = await this.find(key);
|
|
851
|
+
if (!issue) throw new Error(`no tracked issue for "${key}"`);
|
|
852
|
+
await this.api.request("POST", `repo:/issues/${issue.number}/comments`, { body: text });
|
|
853
|
+
}
|
|
854
|
+
async close(key, text) {
|
|
855
|
+
const issue = await this.find(key);
|
|
856
|
+
if (!issue) throw new Error(`no tracked issue for "${key}"`);
|
|
857
|
+
if (text) await this.api.request("POST", `repo:/issues/${issue.number}/comments`, { body: text });
|
|
858
|
+
await this.api.request("PATCH", `repo:/issues/${issue.number}`, { state: "closed", state_reason: "completed" });
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
// src/tracker/none.ts
|
|
863
|
+
var noneTracker = {
|
|
864
|
+
kind: "none",
|
|
865
|
+
async upsert(item) {
|
|
866
|
+
return { key: item.key, url: "", created: false };
|
|
867
|
+
},
|
|
868
|
+
async comment() {
|
|
869
|
+
},
|
|
870
|
+
async close() {
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
// src/tracker/index.ts
|
|
875
|
+
function createTracker(kind, root2, fetchImpl = fetch) {
|
|
876
|
+
if (kind === "none") return noneTracker;
|
|
877
|
+
if (kind === "github") {
|
|
878
|
+
const api = GitHubApi.fromRepo(root2, fetchImpl);
|
|
879
|
+
return typeof api === "string" ? `tracker github: ${api}` : new GitHubTracker(api);
|
|
880
|
+
}
|
|
881
|
+
return `unknown tracker kind "${kind}" (supported: github, none)`;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// src/commands/track.ts
|
|
885
|
+
var GATES = ["icp", "copy", "launch"];
|
|
886
|
+
var USAGE2 = `Usage:
|
|
887
|
+
gtm track stage <campaign>
|
|
888
|
+
gtm track gate-open <icp|copy|launch> [campaign] [--pr <number>]
|
|
889
|
+
gtm track gate-done <icp|copy|launch> [campaign]`;
|
|
890
|
+
function readYaml(file) {
|
|
891
|
+
return existsSync3(file) ? parse3(readFileSync3(file, "utf8")) : null;
|
|
892
|
+
}
|
|
893
|
+
var handles = (...values2) => values2.filter(isGitHubHandle).map((h) => h.slice(1));
|
|
894
|
+
async function upsert(ctx, item) {
|
|
895
|
+
try {
|
|
896
|
+
const res = await ctx.tracker.upsert(item);
|
|
897
|
+
return res.url;
|
|
898
|
+
} catch (e) {
|
|
899
|
+
if (e instanceof GitHubError && e.status === 422 && item.assignees?.length) {
|
|
900
|
+
console.log(` ! could not assign ${item.assignees.map((a) => "@" + a).join(", ")} (not a collaborator?): created unassigned`);
|
|
901
|
+
return (await ctx.tracker.upsert({ ...item, assignees: [] })).url;
|
|
902
|
+
}
|
|
903
|
+
throw e;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
function gateKey(gate, campaign) {
|
|
907
|
+
return gate === "icp" ? "gate:icp" : `gate:${gate}:${campaign}`;
|
|
908
|
+
}
|
|
909
|
+
function gateFile(gate, campaign) {
|
|
910
|
+
return gate === "icp" ? "context/icp.yaml" : `campaigns/${campaign}/config.yaml`;
|
|
911
|
+
}
|
|
912
|
+
function gateApproval(root2, gate, campaign) {
|
|
913
|
+
const doc = readYaml(join3(root2, gateFile(gate, campaign)));
|
|
914
|
+
return gate === "icp" ? doc?.approval : doc?.gates?.[gate];
|
|
915
|
+
}
|
|
916
|
+
async function trackStage(ctx, campaign) {
|
|
917
|
+
const cfg = campaign ? readYaml(join3(ctx.root, "campaigns", campaign, "config.yaml")) : null;
|
|
918
|
+
if (!campaign || !cfg) {
|
|
919
|
+
console.error(campaign ? `campaigns/${campaign}/config.yaml not found` : USAGE2);
|
|
920
|
+
return false;
|
|
921
|
+
}
|
|
922
|
+
const current = Stage.safeParse(cfg.stage).success ? cfg.stage : null;
|
|
923
|
+
if (!current) {
|
|
924
|
+
console.error(`campaigns/${campaign}/config.yaml has no valid stage`);
|
|
925
|
+
return false;
|
|
926
|
+
}
|
|
927
|
+
const idx = Stage.options.indexOf(current);
|
|
928
|
+
const checklist = Stage.options.map((s, i) => `- [${i < idx ? "x" : " "}] ${s}${s === current ? " \u2190 current" : ""}`).join("\n");
|
|
929
|
+
const url = await upsert(ctx, {
|
|
930
|
+
key: `campaign:${campaign}`,
|
|
931
|
+
title: `Campaign: ${campaign}`,
|
|
932
|
+
body: `**Stage: \`${current}\`** (source of truth: \`campaigns/${campaign}/config.yaml\`)
|
|
933
|
+
|
|
934
|
+
${checklist}
|
|
935
|
+
|
|
936
|
+
Brief: \`campaigns/${campaign}/brief.md\`. Counts and decisions only in this issue: never lead data.`,
|
|
937
|
+
labels: ["campaign", `stage:${current}`],
|
|
938
|
+
exclusiveLabelPrefixes: ["stage:"],
|
|
939
|
+
assignees: handles(cfg.owner, ctx.project?.owners?.gtm_engineer)
|
|
940
|
+
});
|
|
941
|
+
if (url) await ctx.tracker.comment(`campaign:${campaign}`, `Stage \u2192 \`${current}\``);
|
|
942
|
+
console.log(url ? `tracked campaign ${campaign} at stage ${current}: ${url}` : `tracker none: skipped (campaign ${campaign} at ${current})`);
|
|
428
943
|
return true;
|
|
429
944
|
}
|
|
945
|
+
var GATE_TEXT = {
|
|
946
|
+
icp: { title: "ICP sign-off", review: "segments + filters, personas, exclusions, signals with weights, expected TAM range (`context/icp.md`, `context/icp.yaml`, `context/signals.yaml`)" },
|
|
947
|
+
copy: { title: "Copy sign-off", review: "every step of every variant in `copy.md`, rendered with one realistic fictional example" },
|
|
948
|
+
launch: { title: "Launch go/no-go", review: "`qa.md` result, leads by tier, daily volume, spend so far, paused sender campaign" }
|
|
949
|
+
};
|
|
950
|
+
async function gateOpen(ctx, gate, campaign, pr) {
|
|
951
|
+
if (gate !== "icp" && !campaign) {
|
|
952
|
+
console.error(USAGE2);
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
const owners = ctx.project?.owners ?? {};
|
|
956
|
+
const approver = gate === "launch" ? owners.gtm_engineer : owners.approver;
|
|
957
|
+
const how = isGitHubHandle(approver) ? `**${approver}**: review and approve the pull request${pr ? ` #${pr}` : ""} on GitHub. Then the agent records it with \`npx gtm approve ${gate === "icp" ? "icp" : `${gate} ${campaign}`} --pr <number>\`.` : `Approver **${approver ?? "(not set)"}**: reply with an explicit approval; it is recorded with \`npx gtm approve ... --by "<name (role)>"\`.`;
|
|
958
|
+
const key = gateKey(gate, campaign);
|
|
959
|
+
const url = await upsert(ctx, {
|
|
960
|
+
key,
|
|
961
|
+
title: `${GATE_TEXT[gate].title}${campaign ? `: ${campaign}` : ""}`,
|
|
962
|
+
body: `**Hard stop.** Nothing moves past this gate until it is approved.
|
|
963
|
+
|
|
964
|
+
Review: ${GATE_TEXT[gate].review}.
|
|
965
|
+
|
|
966
|
+
${how}
|
|
967
|
+
|
|
968
|
+
Source of truth: \`${gateFile(gate, campaign)}\`.`,
|
|
969
|
+
labels: ["gate", `gate:${gate}`],
|
|
970
|
+
assignees: handles(approver)
|
|
971
|
+
});
|
|
972
|
+
console.log(url ? `opened ${GATE_TEXT[gate].title}: ${url}` : `tracker none: skipped (${key})`);
|
|
973
|
+
return true;
|
|
974
|
+
}
|
|
975
|
+
async function gateDone(ctx, gate, campaign) {
|
|
976
|
+
if (gate !== "icp" && !campaign) {
|
|
977
|
+
console.error(USAGE2);
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
const approval = gateApproval(ctx.root, gate, campaign);
|
|
981
|
+
if (approval?.status !== "approved") {
|
|
982
|
+
console.error(`${gateFile(gate, campaign)} says this gate is "${approval?.status ?? "missing"}": record the approval first; the tracker only mirrors it.`);
|
|
983
|
+
return false;
|
|
984
|
+
}
|
|
985
|
+
const via = approval.approved_via ? ` via ${approval.approved_via}` : "";
|
|
986
|
+
if (ctx.tracker.kind === "none") {
|
|
987
|
+
console.log(`tracker none: skipped (${gateKey(gate, campaign)})`);
|
|
988
|
+
return true;
|
|
989
|
+
}
|
|
990
|
+
await ctx.tracker.close(gateKey(gate, campaign), `\u2705 Approved by ${approval.approved_by} on ${approval.approved_at}${via}.`);
|
|
991
|
+
console.log(`closed ${GATE_TEXT[gate].title}${campaign ? `: ${campaign}` : ""}`);
|
|
992
|
+
return true;
|
|
993
|
+
}
|
|
994
|
+
async function runTrack(root2, args2, opts = {}) {
|
|
995
|
+
const [action, a1, a2] = args2;
|
|
996
|
+
const project = readYaml(join3(root2, "project.yaml"));
|
|
997
|
+
if (!project) {
|
|
998
|
+
console.error("project.yaml not found: run this from the root of a project repo");
|
|
999
|
+
return false;
|
|
1000
|
+
}
|
|
1001
|
+
const tracker = createTracker(project.tracker?.kind ?? "none", root2, opts.fetchImpl);
|
|
1002
|
+
if (typeof tracker === "string") {
|
|
1003
|
+
console.error(tracker);
|
|
1004
|
+
return false;
|
|
1005
|
+
}
|
|
1006
|
+
const ctx = { root: root2, project, tracker };
|
|
1007
|
+
try {
|
|
1008
|
+
if (action === "stage") return await trackStage(ctx, a1);
|
|
1009
|
+
if ((action === "gate-open" || action === "gate-done") && GATES.includes(a1)) {
|
|
1010
|
+
return action === "gate-open" ? await gateOpen(ctx, a1, a2, opts.pr) : await gateDone(ctx, a1, a2);
|
|
1011
|
+
}
|
|
1012
|
+
console.error(USAGE2);
|
|
1013
|
+
return false;
|
|
1014
|
+
} catch (e) {
|
|
1015
|
+
console.error(`tracker error: ${e.message}`);
|
|
1016
|
+
return false;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
430
1019
|
|
|
431
1020
|
// src/commands/init.ts
|
|
432
|
-
import { spawnSync } from "child_process";
|
|
433
|
-
import {
|
|
434
|
-
import { join as
|
|
1021
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1022
|
+
import { appendFileSync, existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
|
|
1023
|
+
import { basename, join as join8, relative as relative2, resolve } from "path";
|
|
435
1024
|
|
|
436
1025
|
// src/paths.ts
|
|
437
|
-
import { existsSync as
|
|
438
|
-
import { dirname, join as
|
|
1026
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
|
|
1027
|
+
import { dirname, join as join4 } from "path";
|
|
439
1028
|
import { fileURLToPath } from "url";
|
|
440
|
-
var pkgRoot =
|
|
1029
|
+
var pkgRoot = join4(dirname(fileURLToPath(import.meta.url)), "..");
|
|
441
1030
|
var PACKAGE_NAME = "@veleads/gtm";
|
|
442
|
-
var GTM_VERSION = JSON.parse(
|
|
1031
|
+
var GTM_VERSION = JSON.parse(readFileSync4(join4(pkgRoot, "package.json"), "utf8")).version;
|
|
443
1032
|
function firstExisting(candidates) {
|
|
444
|
-
return candidates.find((p) =>
|
|
1033
|
+
return candidates.find((p) => existsSync4(p)) ?? null;
|
|
445
1034
|
}
|
|
446
|
-
var kit = firstExisting([
|
|
1035
|
+
var kit = firstExisting([join4(pkgRoot, "kit"), join4(pkgRoot, "..", "..", "kit")]);
|
|
447
1036
|
if (!kit) throw new Error("gtm: kit/ not found next to the package; reinstall @veleads/gtm");
|
|
448
1037
|
var KIT_DIR = kit;
|
|
449
|
-
var CHANGELOG_FILE = firstExisting([
|
|
1038
|
+
var CHANGELOG_FILE = firstExisting([join4(pkgRoot, "CHANGELOG.md"), join4(pkgRoot, "..", "..", "CHANGELOG.md")]);
|
|
450
1039
|
|
|
451
1040
|
// src/template.ts
|
|
452
|
-
import {
|
|
453
|
-
import { dirname as dirname2, join as
|
|
1041
|
+
import { mkdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
|
|
1042
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
454
1043
|
|
|
455
1044
|
// src/fsutil.ts
|
|
456
1045
|
import { readdirSync, statSync } from "fs";
|
|
457
|
-
import { join as
|
|
1046
|
+
import { join as join5, relative } from "path";
|
|
458
1047
|
function walk(dir, skip = []) {
|
|
459
1048
|
const out = [];
|
|
460
1049
|
const visit = (d) => {
|
|
461
1050
|
for (const name of readdirSync(d)) {
|
|
462
1051
|
if (skip.includes(name)) continue;
|
|
463
|
-
const p =
|
|
1052
|
+
const p = join5(d, name);
|
|
464
1053
|
if (statSync(p).isDirectory()) visit(p);
|
|
465
1054
|
else out.push(relative(dir, p));
|
|
466
1055
|
}
|
|
@@ -470,7 +1059,7 @@ function walk(dir, skip = []) {
|
|
|
470
1059
|
}
|
|
471
1060
|
function subdirs(dir) {
|
|
472
1061
|
try {
|
|
473
|
-
return readdirSync(dir).filter((n) => !n.startsWith(".") && statSync(
|
|
1062
|
+
return readdirSync(dir).filter((n) => !n.startsWith(".") && statSync(join5(dir, n)).isDirectory()).sort();
|
|
474
1063
|
} catch {
|
|
475
1064
|
return [];
|
|
476
1065
|
}
|
|
@@ -505,41 +1094,60 @@ function destName(rel) {
|
|
|
505
1094
|
const [first, ...rest] = rel.split("/");
|
|
506
1095
|
return [DOTFILES.has(first) ? `.${first}` : first, ...rest].join("/");
|
|
507
1096
|
}
|
|
1097
|
+
function planTemplate(src, vars, rename = destName) {
|
|
1098
|
+
return walk(src).map((rel) => {
|
|
1099
|
+
const raw = readFileSync5(join6(src, rel));
|
|
1100
|
+
if (!/\.(md|ya?ml|json|txt|example)$|gitignore$/.test(rel)) return { rel: rename(rel), content: raw };
|
|
1101
|
+
let text = raw.toString("utf8");
|
|
1102
|
+
for (const [k, v] of Object.entries(vars)) text = text.replaceAll(`__${k}__`, v);
|
|
1103
|
+
return { rel: rename(rel), content: text };
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
function writePlanned(root2, file) {
|
|
1107
|
+
const out = join6(root2, file.rel);
|
|
1108
|
+
mkdirSync(dirname2(out), { recursive: true });
|
|
1109
|
+
writeFileSync2(out, file.content);
|
|
1110
|
+
}
|
|
508
1111
|
function copyTemplate(src, dest, vars, opts = {}) {
|
|
509
|
-
for (const
|
|
510
|
-
if (opts.only && !opts.only.includes(rel)) continue;
|
|
511
|
-
const out = join4(dest, (opts.rename ?? destName)(rel));
|
|
512
|
-
mkdirSync(dirname2(out), { recursive: true });
|
|
513
|
-
if (/\.(md|ya?ml|json|txt|example)$|gitignore$/.test(rel)) {
|
|
514
|
-
let text = readFileSync3(join4(src, rel), "utf8");
|
|
515
|
-
for (const [k, v] of Object.entries(vars)) text = text.replaceAll(`__${k}__`, v);
|
|
516
|
-
writeFileSync2(out, text);
|
|
517
|
-
} else {
|
|
518
|
-
cpSync(join4(src, rel), out);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
1112
|
+
for (const file of planTemplate(src, vars, opts.rename)) writePlanned(dest, file);
|
|
521
1113
|
}
|
|
522
1114
|
|
|
523
1115
|
// src/commands/sync.ts
|
|
524
|
-
import { existsSync as
|
|
525
|
-
import {
|
|
526
|
-
|
|
1116
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync3 } from "fs";
|
|
1117
|
+
import { parse as parse4 } from "yaml";
|
|
1118
|
+
import { dirname as dirname3, join as join7 } from "path";
|
|
1119
|
+
var SHIPPED_DIRS = ["skills", "templates", "providers", "schemas", "guides"];
|
|
1120
|
+
var CODEOWNERS = join7(".github", "CODEOWNERS");
|
|
1121
|
+
var BLOCK_START = "# BEGIN gtm: generated by `gtm sync` from project.yaml \u2192 owners.approver. Edit owners there, not here.";
|
|
1122
|
+
var BLOCK_END = "# END gtm";
|
|
1123
|
+
function expectedCodeowners(root2) {
|
|
1124
|
+
const projectFile = join7(root2, "project.yaml");
|
|
1125
|
+
const approver = existsSync5(projectFile) ? parse4(readFileSync6(projectFile, "utf8"))?.owners?.approver : void 0;
|
|
1126
|
+
const path = join7(root2, CODEOWNERS);
|
|
1127
|
+
const current = existsSync5(path) ? readFileSync6(path, "utf8") : "";
|
|
1128
|
+
const outside = current.replace(new RegExp(`\\n?${BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${BLOCK_END}\\n?`), "\n").trim();
|
|
1129
|
+
const block = isGitHubHandle(approver) ? [BLOCK_START, `/context/icp.yaml ${approver}`, `/context/signals.yaml ${approver}`, `/campaigns/*/copy.md ${approver}`, BLOCK_END].join("\n") : "";
|
|
1130
|
+
const next = [outside, block].filter(Boolean).join("\n\n");
|
|
1131
|
+
if (!next) return current ? "" : null;
|
|
1132
|
+
return `${next}
|
|
1133
|
+
`;
|
|
1134
|
+
}
|
|
527
1135
|
function agentsMd() {
|
|
528
1136
|
const header = `<!-- GENERATED by \`gtm sync\` from ${PACKAGE_NAME} v${GTM_VERSION}. Do not edit: it is overwritten on upgrade.
|
|
529
1137
|
Project-specific rules go in PROJECT.md. Process changes go to the Veleads/gtm-core repo. -->
|
|
530
1138
|
|
|
531
1139
|
`;
|
|
532
|
-
return header +
|
|
1140
|
+
return header + readFileSync6(join7(KIT_DIR, "AGENTS.md"), "utf8").replaceAll("{{GTM_VERSION}}", GTM_VERSION);
|
|
533
1141
|
}
|
|
534
1142
|
function expectedFiles() {
|
|
535
1143
|
const files = /* @__PURE__ */ new Map();
|
|
536
1144
|
for (const dir of SHIPPED_DIRS) {
|
|
537
|
-
for (const rel of walk(
|
|
1145
|
+
for (const rel of walk(join7(KIT_DIR, dir))) files.set(join7(".gtm", dir, rel), readFileSync6(join7(KIT_DIR, dir, rel), "utf8"));
|
|
538
1146
|
}
|
|
539
|
-
files.set(
|
|
1147
|
+
files.set(join7(".gtm", "VERSION"), `${GTM_VERSION}
|
|
540
1148
|
`);
|
|
541
1149
|
files.set(
|
|
542
|
-
|
|
1150
|
+
join7(".gtm", "README.md"),
|
|
543
1151
|
`# .gtm/
|
|
544
1152
|
|
|
545
1153
|
Copied from ${PACKAGE_NAME} v${GTM_VERSION} by \`gtm sync\`. Read-only: CI fails if it differs from the
|
|
@@ -553,13 +1161,16 @@ function checkSync(root2) {
|
|
|
553
1161
|
const expected = expectedFiles();
|
|
554
1162
|
const diffs = [];
|
|
555
1163
|
for (const [rel, content] of expected) {
|
|
556
|
-
const p =
|
|
557
|
-
if (!
|
|
558
|
-
else if (
|
|
1164
|
+
const p = join7(root2, rel);
|
|
1165
|
+
if (!existsSync5(p)) diffs.push(`missing ${rel}`);
|
|
1166
|
+
else if (readFileSync6(p, "utf8") !== content) diffs.push(`changed ${rel}`);
|
|
559
1167
|
}
|
|
560
|
-
if (
|
|
561
|
-
for (const rel of walk(
|
|
1168
|
+
if (existsSync5(join7(root2, ".gtm"))) {
|
|
1169
|
+
for (const rel of walk(join7(root2, ".gtm"))) if (!expected.has(join7(".gtm", rel))) diffs.push(`extra ${join7(".gtm", rel)}`);
|
|
562
1170
|
}
|
|
1171
|
+
const owners = expectedCodeowners(root2);
|
|
1172
|
+
const ownersPath = join7(root2, CODEOWNERS);
|
|
1173
|
+
if (owners !== null && (existsSync5(ownersPath) ? readFileSync6(ownersPath, "utf8") : "") !== owners) diffs.push(`changed ${CODEOWNERS} (gtm block)`);
|
|
563
1174
|
return diffs;
|
|
564
1175
|
}
|
|
565
1176
|
function runSync(root2, opts = {}) {
|
|
@@ -573,73 +1184,153 @@ function runSync(root2, opts = {}) {
|
|
|
573
1184
|
for (const d of diffs) console.log(` ${d}`);
|
|
574
1185
|
return false;
|
|
575
1186
|
}
|
|
576
|
-
rmSync(
|
|
1187
|
+
rmSync(join7(root2, ".gtm"), { recursive: true, force: true });
|
|
577
1188
|
for (const [rel, content] of expectedFiles()) {
|
|
578
|
-
mkdirSync2(dirname3(
|
|
579
|
-
writeFileSync3(
|
|
1189
|
+
mkdirSync2(dirname3(join7(root2, rel)), { recursive: true });
|
|
1190
|
+
writeFileSync3(join7(root2, rel), content);
|
|
1191
|
+
}
|
|
1192
|
+
if (!existsSync5(join7(root2, "CLAUDE.md"))) writeFileSync3(join7(root2, "CLAUDE.md"), "@AGENTS.md\n");
|
|
1193
|
+
const owners = expectedCodeowners(root2);
|
|
1194
|
+
if (owners !== null) {
|
|
1195
|
+
mkdirSync2(join7(root2, ".github"), { recursive: true });
|
|
1196
|
+
writeFileSync3(join7(root2, CODEOWNERS), owners);
|
|
580
1197
|
}
|
|
581
|
-
if (!existsSync3(join5(root2, "CLAUDE.md"))) writeFileSync3(join5(root2, "CLAUDE.md"), "@AGENTS.md\n");
|
|
582
1198
|
console.log(`synced .gtm/ and AGENTS.md to ${PACKAGE_NAME} v${GTM_VERSION}`);
|
|
583
1199
|
return true;
|
|
584
1200
|
}
|
|
585
1201
|
|
|
586
1202
|
// src/commands/init.ts
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
1203
|
+
var USAGE3 = `Usage:
|
|
1204
|
+
gtm init <project-slug> --name "<Project Name>" [--dir <path>] create ./gtm-project-<slug> (or <path>)
|
|
1205
|
+
gtm init . --name "<Project Name>" [--slug <project-slug>] set up the current folder
|
|
1206
|
+
Slugs: lowercase letters, digits and hyphens.`;
|
|
1207
|
+
var MERGEABLE = /* @__PURE__ */ new Set([".gitignore", "README.md", "CLAUDE.md", "campaigns/.gitkeep", "tam/.gitkeep"]);
|
|
1208
|
+
function slugFromFolder(path) {
|
|
1209
|
+
return basename(path).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/^gtm-project-/, "");
|
|
1210
|
+
}
|
|
1211
|
+
function missingIgnoreLines(existing, ours) {
|
|
1212
|
+
const have = new Set(existing.split("\n").map((l) => l.trim()));
|
|
1213
|
+
return ours.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#") && !have.has(l.trim()));
|
|
1214
|
+
}
|
|
1215
|
+
function runInit(target, opts) {
|
|
1216
|
+
const here = target === "." || opts.dir === ".";
|
|
1217
|
+
const root2 = resolve(opts.cwd, here ? opts.dir ?? "." : opts.dir ?? `gtm-project-${target ?? ""}`);
|
|
1218
|
+
const slug = target && target !== "." ? target : opts.slug ?? slugFromFolder(root2);
|
|
1219
|
+
if (!opts.name || !slug || !Slug.safeParse(slug).success) {
|
|
1220
|
+
console.error(USAGE3);
|
|
1221
|
+
if (!slug && here) console.error(`Couldn't derive a slug from the folder name "${basename(root2)}": pass one with --slug`);
|
|
1222
|
+
else if (slug && !Slug.safeParse(slug).success) console.error(`"${slug}" is not a valid slug: pass one with --slug`);
|
|
590
1223
|
return false;
|
|
591
1224
|
}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
console.error(`${root2} exists and is not empty`);
|
|
1225
|
+
if (existsSync6(join8(root2, "project.yaml")) || existsSync6(join8(root2, "client.yaml"))) {
|
|
1226
|
+
console.error(`${root2} is already a gtm project. Use \`npx gtm sync\` or \`npx gtm upgrade\` instead.`);
|
|
595
1227
|
return false;
|
|
596
1228
|
}
|
|
597
1229
|
const vars = { PROJECT_ID: slug, PROJECT_NAME: opts.name, TODAY: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), GTM_VERSION };
|
|
598
|
-
const tpl =
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
1230
|
+
const tpl = join8(KIT_DIR, "templates");
|
|
1231
|
+
const planned = [
|
|
1232
|
+
...planTemplate(join8(tpl, "repo"), vars),
|
|
1233
|
+
...planTemplate(join8(tpl, "project"), vars, (rel) => rel === "project.yaml" ? rel : join8("context", rel)),
|
|
1234
|
+
{ rel: "campaigns/.gitkeep", content: "" },
|
|
1235
|
+
{ rel: "tam/.gitkeep", content: "" }
|
|
1236
|
+
];
|
|
1237
|
+
const coreOwned = [...expectedFiles().keys()];
|
|
1238
|
+
const conflicts = [...planned.map((f) => f.rel), ...coreOwned].filter((rel) => existsSync6(join8(root2, rel)) && !MERGEABLE.has(rel));
|
|
1239
|
+
if (existsSync6(join8(root2, ".gtm"))) conflicts.unshift(".gtm/");
|
|
1240
|
+
if (conflicts.length) {
|
|
1241
|
+
console.error(`Cannot set up ${root2}: these files already exist and belong to gtm. Nothing was written.`);
|
|
1242
|
+
for (const c of [...new Set(conflicts)].slice(0, 15)) console.error(` \u2717 ${c}`);
|
|
1243
|
+
console.error("Move them away (or use an empty folder) and run init again.");
|
|
1244
|
+
return false;
|
|
604
1245
|
}
|
|
1246
|
+
const notes = [];
|
|
1247
|
+
for (const file of planned) {
|
|
1248
|
+
const path = join8(root2, file.rel);
|
|
1249
|
+
if (!existsSync6(path)) {
|
|
1250
|
+
writePlanned(root2, file);
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
1253
|
+
if (file.rel === ".gitignore") {
|
|
1254
|
+
const add = missingIgnoreLines(readFileSync7(path, "utf8"), file.content.toString());
|
|
1255
|
+
if (add.length) {
|
|
1256
|
+
appendFileSync(path, `
|
|
1257
|
+
# gtm-brain
|
|
1258
|
+
${add.join("\n")}
|
|
1259
|
+
`);
|
|
1260
|
+
notes.push(`.gitignore: added ${add.length} gtm rule(s)`);
|
|
1261
|
+
}
|
|
1262
|
+
} else if (file.rel === "README.md") {
|
|
1263
|
+
notes.push("README.md: kept yours (see .gtm/templates/repo/README.md for the gtm version)");
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
const claude = join8(root2, "CLAUDE.md");
|
|
1267
|
+
const hadClaude = existsSync6(claude);
|
|
605
1268
|
runSync(root2);
|
|
606
|
-
|
|
1269
|
+
if (hadClaude && !readFileSync7(claude, "utf8").includes("@AGENTS.md")) {
|
|
1270
|
+
appendFileSync(claude, "\n@AGENTS.md\n");
|
|
1271
|
+
notes.push("CLAUDE.md: added @AGENTS.md so Claude Code loads the operating manual");
|
|
1272
|
+
}
|
|
1273
|
+
const inRepo = spawnSync2("git", ["rev-parse", "--is-inside-work-tree"], { cwd: root2, encoding: "utf8" }).stdout?.trim() === "true";
|
|
1274
|
+
const gitNote = inRepo ? "existing git repository kept" : spawnSync2("git", ["init", "-q", "-b", "main"], { cwd: root2 }).status === 0 ? "git initialised" : "git not initialised";
|
|
1275
|
+
const where = relative2(opts.cwd, root2) || ".";
|
|
1276
|
+
console.log(`
|
|
1277
|
+
Set up project "${opts.name}" (${slug}) in ${root2} (core v${GTM_VERSION}; ${gitNote}).`);
|
|
1278
|
+
for (const n of notes) console.log(` \xB7 ${n}`);
|
|
607
1279
|
console.log(`
|
|
608
|
-
Created ${root2} (core v${GTM_VERSION})${git.status === 0 ? " with git initialised" : ""}.
|
|
609
|
-
|
|
610
1280
|
Next:
|
|
611
|
-
1.
|
|
612
|
-
|
|
613
|
-
2. npm install && npx gtm validate (or pnpm/yarn; TODO warnings remain until intake is done)
|
|
1281
|
+
1. ${where === "." ? "" : `cd ${where} && `}npm install && npx gtm validate (or pnpm/yarn; TODO warnings remain until intake is done)
|
|
1282
|
+
2. ${inRepo ? "Commit and push to your PRIVATE repo." : `Create a PRIVATE GitHub repo (e.g. <your-org>/gtm-project-${slug}) and push.`} Project repos hold project context: never make them public.
|
|
614
1283
|
3. Open the repo in Claude Code or Codex and start with stage 00-intake.`);
|
|
615
1284
|
return true;
|
|
616
1285
|
}
|
|
617
1286
|
|
|
618
1287
|
// src/commands/job.ts
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
1288
|
+
import { createHash as createHash4 } from "crypto";
|
|
1289
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
1290
|
+
import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
|
|
1291
|
+
import { join as join12 } from "path";
|
|
1292
|
+
import { parse as parse6 } from "yaml";
|
|
1293
|
+
|
|
1294
|
+
// ../core/src/cache.ts
|
|
1295
|
+
import { createHash as createHash2 } from "crypto";
|
|
1296
|
+
|
|
1297
|
+
// ../core/src/stack-env.ts
|
|
1298
|
+
import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
|
|
1299
|
+
import { homedir } from "os";
|
|
1300
|
+
import { join as join9 } from "path";
|
|
1301
|
+
var STACK_ENV_FILE = process.env.GTM_STACK_ENV ?? join9(homedir(), ".gtm", "stack.env");
|
|
1302
|
+
function loadStackEnv(file = STACK_ENV_FILE) {
|
|
1303
|
+
if (!existsSync7(file)) return false;
|
|
1304
|
+
for (const line of readFileSync8(file, "utf8").split("\n")) {
|
|
1305
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
|
1306
|
+
if (m && process.env[m[1]] === void 0) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
1307
|
+
}
|
|
1308
|
+
return true;
|
|
626
1309
|
}
|
|
627
1310
|
|
|
1311
|
+
// ../core/src/adapters/mock-account-source.ts
|
|
1312
|
+
import { createHash as createHash3 } from "crypto";
|
|
1313
|
+
|
|
1314
|
+
// src/commands/validate.ts
|
|
1315
|
+
import { existsSync as existsSync9, readdirSync as readdirSync2, readFileSync as readFileSync10 } from "fs";
|
|
1316
|
+
import { join as join11 } from "path";
|
|
1317
|
+
import { parse as parse5 } from "yaml";
|
|
1318
|
+
|
|
628
1319
|
// src/commands/migrate.ts
|
|
629
|
-
import { existsSync as
|
|
630
|
-
import { join as
|
|
1320
|
+
import { existsSync as existsSync8, readFileSync as readFileSync9, renameSync, writeFileSync as writeFileSync4 } from "fs";
|
|
1321
|
+
import { join as join10, relative as relative3 } from "path";
|
|
631
1322
|
import { parseDocument as parseDocument2 } from "yaml";
|
|
632
1323
|
function projectYamlFiles(root2) {
|
|
633
1324
|
const files = [
|
|
634
|
-
{ kind: "project", file:
|
|
635
|
-
{ kind: "icp", file:
|
|
636
|
-
{ kind: "signals", file:
|
|
637
|
-
...subdirs(
|
|
1325
|
+
{ kind: "project", file: join10(root2, "project.yaml") },
|
|
1326
|
+
{ kind: "icp", file: join10(root2, "context", "icp.yaml") },
|
|
1327
|
+
{ kind: "signals", file: join10(root2, "context", "signals.yaml") },
|
|
1328
|
+
...subdirs(join10(root2, "campaigns")).map((c) => ({ kind: "campaign-config", file: join10(root2, "campaigns", c, "config.yaml") }))
|
|
638
1329
|
];
|
|
639
|
-
return files.filter((f) =>
|
|
1330
|
+
return files.filter((f) => existsSync8(f.file));
|
|
640
1331
|
}
|
|
641
1332
|
function pendingRenames(root2) {
|
|
642
|
-
return FILE_RENAMES.filter((r) =>
|
|
1333
|
+
return FILE_RENAMES.filter((r) => existsSync8(join10(root2, r.from)) && !existsSync8(join10(root2, r.to)));
|
|
643
1334
|
}
|
|
644
1335
|
function runMigrate(root2, opts = {}) {
|
|
645
1336
|
let ok = true;
|
|
@@ -648,10 +1339,10 @@ function runMigrate(root2, opts = {}) {
|
|
|
648
1339
|
changed++;
|
|
649
1340
|
console.log(` ${opts.dryRun ? "would rename" : "renamed"} ${from} \u2192 ${to}`);
|
|
650
1341
|
if (opts.dryRun) continue;
|
|
651
|
-
renameSync(
|
|
1342
|
+
renameSync(join10(root2, from), join10(root2, to));
|
|
652
1343
|
if (to.endsWith(".md")) {
|
|
653
|
-
const p =
|
|
654
|
-
|
|
1344
|
+
const p = join10(root2, to);
|
|
1345
|
+
writeFileSync4(p, readFileSync9(p, "utf8").replace(new RegExp(`^# ${from.replace(".", "\\.")}`, "m"), `# ${to}`));
|
|
655
1346
|
}
|
|
656
1347
|
}
|
|
657
1348
|
if (opts.dryRun && changed) {
|
|
@@ -659,8 +1350,8 @@ function runMigrate(root2, opts = {}) {
|
|
|
659
1350
|
return ok;
|
|
660
1351
|
}
|
|
661
1352
|
for (const { kind, file } of projectYamlFiles(root2)) {
|
|
662
|
-
const rel =
|
|
663
|
-
const doc = parseDocument2(
|
|
1353
|
+
const rel = relative3(root2, file);
|
|
1354
|
+
const doc = parseDocument2(readFileSync9(file, "utf8"));
|
|
664
1355
|
if (doc.errors.length) {
|
|
665
1356
|
console.log(` \u2717 ${rel}: invalid YAML: ${doc.errors[0].message}`);
|
|
666
1357
|
ok = false;
|
|
@@ -677,7 +1368,7 @@ function runMigrate(root2, opts = {}) {
|
|
|
677
1368
|
doc.setIn(["approval", "content_hash"], icpContentHash(doc.toJS()));
|
|
678
1369
|
console.log(` re-pinned ICP approval hash in ${rel} (core migration, content approved by the project approver)`);
|
|
679
1370
|
}
|
|
680
|
-
if (!opts.dryRun)
|
|
1371
|
+
if (!opts.dryRun) writeFileSync4(file, doc.toString({ flowCollectionPadding: false, lineWidth: 0 }));
|
|
681
1372
|
} catch (e) {
|
|
682
1373
|
console.log(` \u2717 ${rel}: ${e.message}`);
|
|
683
1374
|
ok = false;
|
|
@@ -687,96 +1378,7 @@ function runMigrate(root2, opts = {}) {
|
|
|
687
1378
|
return ok;
|
|
688
1379
|
}
|
|
689
1380
|
|
|
690
|
-
// src/commands/new-campaign.ts
|
|
691
|
-
import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "fs";
|
|
692
|
-
import { join as join8 } from "path";
|
|
693
|
-
import { parse } from "yaml";
|
|
694
|
-
function runNewCampaign(root2, campaign) {
|
|
695
|
-
if (!campaign || !Slug.safeParse(campaign).success) {
|
|
696
|
-
console.error("Usage: gtm new-campaign <campaign-slug>");
|
|
697
|
-
return false;
|
|
698
|
-
}
|
|
699
|
-
const projectFile = join8(root2, "project.yaml");
|
|
700
|
-
if (!existsSync6(projectFile)) {
|
|
701
|
-
console.error("project.yaml not found: run this from the root of a project repo");
|
|
702
|
-
return false;
|
|
703
|
-
}
|
|
704
|
-
const dest = join8(root2, "campaigns", campaign);
|
|
705
|
-
if (existsSync6(dest)) {
|
|
706
|
-
console.error(`campaigns/${campaign} already exists`);
|
|
707
|
-
return false;
|
|
708
|
-
}
|
|
709
|
-
const projectId = parse(readFileSync6(projectFile, "utf8"))?.project_id ?? "TODO";
|
|
710
|
-
copyTemplate(join8(KIT_DIR, "templates", "campaign"), dest, { PROJECT_ID: projectId, CAMPAIGN_ID: campaign, OWNER: process.env.GTM_USER ?? "TODO" });
|
|
711
|
-
mkdirSync4(join8(dest, "runs"), { recursive: true });
|
|
712
|
-
writeFileSync6(join8(dest, "runs", ".gitkeep"), "");
|
|
713
|
-
console.log(`Created campaigns/${campaign}. Branch: git switch -c campaign/${campaign}`);
|
|
714
|
-
return true;
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// src/commands/status.ts
|
|
718
|
-
import { existsSync as existsSync7, readdirSync as readdirSync3, readFileSync as readFileSync7 } from "fs";
|
|
719
|
-
import { join as join9 } from "path";
|
|
720
|
-
import { parse as parse2 } from "yaml";
|
|
721
|
-
function readYaml(file) {
|
|
722
|
-
try {
|
|
723
|
-
return parse2(readFileSync7(file, "utf8"));
|
|
724
|
-
} catch {
|
|
725
|
-
return null;
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
function campaignStages(root2) {
|
|
729
|
-
return subdirs(join9(root2, "campaigns")).map((id) => ({ id, stage: readYaml(join9(root2, "campaigns", id, "config.yaml"))?.stage ?? "?" }));
|
|
730
|
-
}
|
|
731
|
-
function runStatus(root2) {
|
|
732
|
-
const project = readYaml(join9(root2, "project.yaml"));
|
|
733
|
-
const icp = readYaml(join9(root2, "context", "icp.yaml"));
|
|
734
|
-
console.log(`${project?.name ?? "?"} (${project?.project_id ?? "?"}) \xB7 status ${project?.status ?? "?"} \xB7 core v${GTM_VERSION}`);
|
|
735
|
-
console.log(`ICP gate: ${icp?.approval?.status ?? "?"}${icp?.approval?.approved_by ? ` by ${icp.approval.approved_by} on ${icp.approval.approved_at}` : ""}
|
|
736
|
-
`);
|
|
737
|
-
const rows = subdirs(join9(root2, "campaigns")).map((id) => {
|
|
738
|
-
const cfg = readYaml(join9(root2, "campaigns", id, "config.yaml")) ?? {};
|
|
739
|
-
const runsDir = join9(root2, "campaigns", id, "runs");
|
|
740
|
-
const last = existsSync7(runsDir) ? readdirSync3(runsDir).filter((f) => f.endsWith(".md")).sort().at(-1) ?? "-" : "-";
|
|
741
|
-
return [id, cfg.stage ?? "?", cfg.gates?.copy?.status ?? "?", cfg.gates?.launch?.status ?? "?", cfg.owner ?? "?", last];
|
|
742
|
-
});
|
|
743
|
-
if (!rows.length) {
|
|
744
|
-
console.log("no campaigns yet: npx gtm new-campaign <campaign-slug>");
|
|
745
|
-
return true;
|
|
746
|
-
}
|
|
747
|
-
const header = ["campaign", "stage", "copy gate", "launch gate", "owner", "latest run log"];
|
|
748
|
-
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
|
|
749
|
-
const line = (r) => r.map((c, i) => String(c).padEnd(widths[i])).join(" ");
|
|
750
|
-
console.log(line(header));
|
|
751
|
-
console.log(widths.map((w) => "-".repeat(w)).join(" "));
|
|
752
|
-
for (const r of rows) console.log(line(r));
|
|
753
|
-
return true;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
// src/commands/upgrade.ts
|
|
757
|
-
import { spawnSync as spawnSync2 } from "child_process";
|
|
758
|
-
import { existsSync as existsSync9, readFileSync as readFileSync9 } from "fs";
|
|
759
|
-
import { join as join11 } from "path";
|
|
760
|
-
|
|
761
|
-
// src/changelog.ts
|
|
762
|
-
function compareVersions(a, b) {
|
|
763
|
-
const pa = a.replace(/^[^\d]*/, "").split(/[.-]/).slice(0, 3).map(Number);
|
|
764
|
-
const pb = b.replace(/^[^\d]*/, "").split(/[.-]/).slice(0, 3).map(Number);
|
|
765
|
-
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
766
|
-
return 0;
|
|
767
|
-
}
|
|
768
|
-
function changelogBetween(text, from, to) {
|
|
769
|
-
const parts = text.split(/^(?=## )/m);
|
|
770
|
-
return parts.filter((p) => {
|
|
771
|
-
const m = p.match(/^## \[?(\d+\.\d+\.\d+)/);
|
|
772
|
-
return m && compareVersions(m[1], from) > 0 && compareVersions(m[1], to) <= 0;
|
|
773
|
-
}).join("").trim();
|
|
774
|
-
}
|
|
775
|
-
|
|
776
1381
|
// src/commands/validate.ts
|
|
777
|
-
import { existsSync as existsSync8, readdirSync as readdirSync4, readFileSync as readFileSync8 } from "fs";
|
|
778
|
-
import { join as join10 } from "path";
|
|
779
|
-
import { parse as parse3 } from "yaml";
|
|
780
1382
|
var REQUIRED_CONTEXT = ["intake.md", "icp.md", "icp.yaml", "signals.yaml", "personas.md", "offer.md", "proof.md", "voice.md"];
|
|
781
1383
|
var REQUIRED_CAMPAIGN = ["brief.md", "config.yaml", "copy.md"];
|
|
782
1384
|
var DATA_FILES = /\.(csv|tsv|xlsx?|parquet|jsonl|ndjson)$/i;
|
|
@@ -786,21 +1388,21 @@ var ALLOWED_EMAIL = /@(example\.(com|org|net)|[a-z0-9-]+\.example)$/i;
|
|
|
786
1388
|
var SKIP_DIRS = ["node_modules", ".git", ".gtm"];
|
|
787
1389
|
var stageIndex = (s) => Stage.options.indexOf(s);
|
|
788
1390
|
function readPackageJson(root2) {
|
|
789
|
-
const p =
|
|
790
|
-
return
|
|
1391
|
+
const p = join11(root2, "package.json");
|
|
1392
|
+
return existsSync9(p) ? JSON.parse(readFileSync10(p, "utf8")) : null;
|
|
791
1393
|
}
|
|
792
1394
|
function pinnedVersion(root2) {
|
|
793
1395
|
const pkg = readPackageJson(root2);
|
|
794
1396
|
return pkg?.devDependencies?.[PACKAGE_NAME] ?? pkg?.dependencies?.[PACKAGE_NAME] ?? null;
|
|
795
1397
|
}
|
|
796
1398
|
function loadVersioned(r, file, kind, schema) {
|
|
797
|
-
if (!
|
|
1399
|
+
if (!existsSync9(file)) {
|
|
798
1400
|
r.err(file, "missing");
|
|
799
1401
|
return null;
|
|
800
1402
|
}
|
|
801
1403
|
let raw;
|
|
802
1404
|
try {
|
|
803
|
-
raw =
|
|
1405
|
+
raw = parse5(readFileSync10(file, "utf8"));
|
|
804
1406
|
} catch (e) {
|
|
805
1407
|
r.err(file, `invalid YAML: ${e.message}`);
|
|
806
1408
|
return null;
|
|
@@ -822,22 +1424,22 @@ function loadVersioned(r, file, kind, schema) {
|
|
|
822
1424
|
function checkCoreSync(r, root2) {
|
|
823
1425
|
const pinned = pinnedVersion(root2);
|
|
824
1426
|
if (!pinned) return;
|
|
825
|
-
if (!/^\d+\.\d+\.\d+/.test(pinned)) r.err(
|
|
826
|
-
const versionFile =
|
|
827
|
-
const synced =
|
|
1427
|
+
if (!/^\d+\.\d+\.\d+/.test(pinned)) r.err(join11(root2, "package.json"), `${PACKAGE_NAME} must be pinned exactly (got "${pinned}")`);
|
|
1428
|
+
const versionFile = join11(root2, ".gtm", "VERSION");
|
|
1429
|
+
const synced = existsSync9(versionFile) ? readFileSync10(versionFile, "utf8").trim() : null;
|
|
828
1430
|
if (synced !== GTM_VERSION) r.err(versionFile, `is ${synced ?? "missing"}, installed core is ${GTM_VERSION}: run \`npx gtm sync\``);
|
|
829
|
-
if (!
|
|
1431
|
+
if (!existsSync9(join11(root2, "AGENTS.md"))) r.err(join11(root2, "AGENTS.md"), "missing: run `npx gtm sync`");
|
|
830
1432
|
}
|
|
831
1433
|
function checkNoLeadData(r, root2) {
|
|
832
1434
|
for (const rel of walk(root2, SKIP_DIRS)) {
|
|
833
|
-
const file =
|
|
1435
|
+
const file = join11(root2, rel);
|
|
834
1436
|
if (DATA_FILES.test(rel)) {
|
|
835
1437
|
r.err(file, "data files are not allowed in a project repo (lead data belongs in Postgres)");
|
|
836
1438
|
continue;
|
|
837
1439
|
}
|
|
838
1440
|
if (!/\.(md|ya?ml|txt)$/i.test(rel) || rel === "pnpm-lock.yaml") continue;
|
|
839
|
-
if (/\.ya?ml$/i.test(rel) && /\bTODO\b/.test(
|
|
840
|
-
const found = (
|
|
1441
|
+
if (/\.ya?ml$/i.test(rel) && /\bTODO\b/.test(readFileSync10(file, "utf8"))) r.warn(file, "still contains TODO placeholders");
|
|
1442
|
+
const found = (readFileSync10(file, "utf8").match(EMAIL) ?? []).filter((e) => !ALLOWED_EMAIL.test(e));
|
|
841
1443
|
if (found.length) r.warn(file, `contains email address(es) ${[...new Set(found)].slice(0, 3).join(", ")}: confirm none are prospects`);
|
|
842
1444
|
}
|
|
843
1445
|
}
|
|
@@ -852,11 +1454,24 @@ function checkPinnedApproval(r, contentFile, approval, current, what, approveCmd
|
|
|
852
1454
|
r.err(contentFile, `${what} changed after approval (approved ${pinned}, now ${current}). Re-present it to the approver and record it with \`${approveCmd} --by "<name>"\`, or revert the change`);
|
|
853
1455
|
return false;
|
|
854
1456
|
}
|
|
855
|
-
function
|
|
856
|
-
|
|
1457
|
+
function checkGitHubMode(r, file, what, approval, approver, prApprovals, content) {
|
|
1458
|
+
if (approval.status !== "approved" || !isGitHubHandle(approver)) return true;
|
|
1459
|
+
if (!approval.approved_via) {
|
|
1460
|
+
r.err(file, `${what} approval was recorded without a pull request, but the approver is ${approver} (GitHub mode): get an APPROVED review on a PR and run \`npx gtm approve ... --pr <number>\``);
|
|
1461
|
+
return false;
|
|
1462
|
+
}
|
|
1463
|
+
if (!approval.approved_by || login(approval.approved_by) !== login(approver)) {
|
|
1464
|
+
r.err(file, `${what} was approved by ${approval.approved_by}, but the project approver is ${approver}`);
|
|
1465
|
+
return false;
|
|
1466
|
+
}
|
|
1467
|
+
prApprovals.push({ file, what, approval, approver, ...content });
|
|
1468
|
+
return true;
|
|
1469
|
+
}
|
|
1470
|
+
function validateCampaign(r, root2, projectId, campaign, icp, signals, icpApproved, approver, prApprovals) {
|
|
1471
|
+
const dir = join11(root2, "campaigns", campaign);
|
|
857
1472
|
console.log(`campaign ${campaign}`);
|
|
858
|
-
for (const f of REQUIRED_CAMPAIGN) if (!
|
|
859
|
-
const cfgFile =
|
|
1473
|
+
for (const f of REQUIRED_CAMPAIGN) if (!existsSync9(join11(dir, f))) r.err(join11(dir, f), "missing");
|
|
1474
|
+
const cfgFile = join11(dir, "config.yaml");
|
|
860
1475
|
const cfg = loadVersioned(r, cfgFile, "campaign-config", CampaignConfig);
|
|
861
1476
|
if (cfg) {
|
|
862
1477
|
if (cfg.project_id !== projectId) r.err(cfgFile, `project_id "${cfg.project_id}" does not match project.yaml "${projectId}"`);
|
|
@@ -873,16 +1488,16 @@ function validateCampaign(r, root2, projectId, campaign, icp, signals, icpApprov
|
|
|
873
1488
|
}
|
|
874
1489
|
const providers = /* @__PURE__ */ new Set([...cfg.enrichment.email_waterfall, cfg.enrichment.verification.provider, cfg.sending.sender]);
|
|
875
1490
|
for (const p of providers) {
|
|
876
|
-
if (!p.startsWith("mock-") && !
|
|
1491
|
+
if (!p.startsWith("mock-") && !existsSync9(join11(KIT_DIR, "providers", `${p}.md`))) r.warn(cfgFile, `no .gtm/providers/${p}.md documenting "${p}"`);
|
|
877
1492
|
}
|
|
878
|
-
if (stageIndex(cfg.stage) > stageIndex("tam") && !
|
|
1493
|
+
if (stageIndex(cfg.stage) > stageIndex("tam") && !existsSync9(join11(root2, "tam", `${cfg.tam_id}.md`))) {
|
|
879
1494
|
r.err(cfgFile, `stage is "${cfg.stage}" but tam/${cfg.tam_id}.md does not exist`);
|
|
880
1495
|
}
|
|
881
1496
|
if (stageIndex(cfg.stage) > stageIndex("icp") && !icpApproved) {
|
|
882
1497
|
r.err(cfgFile, `stage is "${cfg.stage}" but the ICP gate (context/icp.yaml approval) is not approved, or the ICP changed since approval`);
|
|
883
1498
|
}
|
|
884
|
-
const copyFile =
|
|
885
|
-
const copyApproved =
|
|
1499
|
+
const copyFile = join11(dir, "copy.md");
|
|
1500
|
+
const copyApproved = existsSync9(copyFile) ? checkPinnedApproval(r, copyFile, cfg.gates.copy, copyContentHash(readFileSync10(copyFile, "utf8")), "copy", `npx gtm approve copy ${campaign}`) && checkGitHubMode(r, cfgFile, `copy (${campaign})`, cfg.gates.copy, approver, prApprovals, { contentPath: `campaigns/${campaign}/copy.md`, hashOf: copyContentHash }) : false;
|
|
886
1501
|
if (stageIndex(cfg.stage) > stageIndex("copy") && !copyApproved) {
|
|
887
1502
|
r.err(cfgFile, `stage is "${cfg.stage}" but gates.copy is not approved, or copy.md changed since approval`);
|
|
888
1503
|
}
|
|
@@ -892,64 +1507,266 @@ function validateCampaign(r, root2, projectId, campaign, icp, signals, icpApprov
|
|
|
892
1507
|
if (cfg.gates.launch.status === "approved" && !copyApproved) {
|
|
893
1508
|
r.err(cfgFile, "gates.launch is approved but gates.copy is not");
|
|
894
1509
|
}
|
|
895
|
-
if (stageIndex(cfg.stage) > stageIndex("qa") && !
|
|
896
|
-
r.err(
|
|
1510
|
+
if (stageIndex(cfg.stage) > stageIndex("qa") && !existsSync9(join11(dir, "qa.md"))) {
|
|
1511
|
+
r.err(join11(dir, "qa.md"), "missing: QA report is required before launch");
|
|
897
1512
|
}
|
|
898
1513
|
}
|
|
899
|
-
const runs =
|
|
900
|
-
if (
|
|
901
|
-
for (const f of
|
|
902
|
-
if (!RUN_LOG_NAME.test(f)) r.err(
|
|
1514
|
+
const runs = join11(dir, "runs");
|
|
1515
|
+
if (existsSync9(runs)) {
|
|
1516
|
+
for (const f of readdirSync2(runs).filter((f2) => !f2.startsWith("."))) {
|
|
1517
|
+
if (!RUN_LOG_NAME.test(f)) r.err(join11(runs, f), "run logs must be named YYYY-MM-DD-<user>[-n].md");
|
|
903
1518
|
}
|
|
904
1519
|
}
|
|
905
1520
|
}
|
|
906
|
-
function runValidate(root2, onlyCampaign) {
|
|
1521
|
+
async function runValidate(root2, onlyCampaign, opts = {}) {
|
|
907
1522
|
const r = new Report(root2);
|
|
908
1523
|
console.log(`validating ${root2} against core v${GTM_VERSION}`);
|
|
909
1524
|
checkCoreSync(r, root2);
|
|
910
|
-
for (const { from, to } of pendingRenames(root2)) r.err(
|
|
911
|
-
const meta = loadVersioned(r,
|
|
1525
|
+
for (const { from, to } of pendingRenames(root2)) r.err(join11(root2, from), `renamed to ${to} in core 0.3.0: run \`npx gtm migrate\``);
|
|
1526
|
+
const meta = loadVersioned(r, join11(root2, "project.yaml"), "project", ProjectFile);
|
|
912
1527
|
const projectId = meta?.project_id ?? "";
|
|
913
1528
|
if (meta) {
|
|
914
1529
|
for (const key of Object.keys(meta.features)) {
|
|
915
|
-
if (!FEATURES[key]) r.err(
|
|
1530
|
+
if (!FEATURES[key]) r.err(join11(root2, "project.yaml"), `unknown feature "${key}" (known: ${Object.keys(FEATURES).join(", ") || "none"})`);
|
|
916
1531
|
}
|
|
917
1532
|
}
|
|
918
|
-
if (!
|
|
919
|
-
|
|
920
|
-
const
|
|
921
|
-
const
|
|
922
|
-
const
|
|
923
|
-
const
|
|
1533
|
+
if (!existsSync9(join11(root2, "context", "research.md"))) r.warn(join11(root2, "context", "research.md"), "missing: run the research skill (.gtm/skills/research/SKILL.md) before intake questions");
|
|
1534
|
+
if (!existsSync9(join11(root2, "PROJECT.md"))) r.warn(join11(root2, "PROJECT.md"), "missing (project-specific rules for agents)");
|
|
1535
|
+
for (const f of REQUIRED_CONTEXT) if (!existsSync9(join11(root2, "context", f))) r.err(join11(root2, "context", f), "missing");
|
|
1536
|
+
const icpFile = join11(root2, "context", "icp.yaml");
|
|
1537
|
+
const sigFile = join11(root2, "context", "signals.yaml");
|
|
1538
|
+
const icp = existsSync9(icpFile) ? loadVersioned(r, icpFile, "icp", IcpFile) : null;
|
|
1539
|
+
const signals = existsSync9(sigFile) ? loadVersioned(r, sigFile, "signals", SignalsFile) : null;
|
|
924
1540
|
if (meta && icp && icp.project_id !== projectId) r.err(icpFile, `project_id "${icp.project_id}" does not match project.yaml "${projectId}"`);
|
|
925
1541
|
if (meta && signals && signals.project_id !== projectId) r.err(sigFile, `project_id "${signals.project_id}" does not match project.yaml "${projectId}"`);
|
|
926
|
-
const
|
|
1542
|
+
const approver = meta?.owners.approver;
|
|
1543
|
+
const prApprovals = [];
|
|
1544
|
+
const icpApproved = icp ? checkPinnedApproval(r, icpFile, icp.approval, icpContentHash(parse5(readFileSync10(icpFile, "utf8"))), "ICP", "npx gtm approve icp") && checkGitHubMode(r, icpFile, "ICP", icp.approval, approver, prApprovals, { contentPath: "context/icp.yaml", hashOf: (t) => icpContentHash(parse5(t)) }) : false;
|
|
927
1545
|
checkNoLeadData(r, root2);
|
|
928
|
-
const campaigns = onlyCampaign ? [onlyCampaign] : subdirs(
|
|
1546
|
+
const campaigns = onlyCampaign ? [onlyCampaign] : subdirs(join11(root2, "campaigns"));
|
|
929
1547
|
for (const c of campaigns) {
|
|
930
|
-
if (!
|
|
931
|
-
r.err(
|
|
1548
|
+
if (!existsSync9(join11(root2, "campaigns", c))) {
|
|
1549
|
+
r.err(join11(root2, "campaigns", c), "campaign folder not found");
|
|
932
1550
|
continue;
|
|
933
1551
|
}
|
|
934
|
-
validateCampaign(r, root2, projectId, c, icp, signals, icpApproved);
|
|
1552
|
+
validateCampaign(r, root2, projectId, c, icp, signals, icpApproved, approver, prApprovals);
|
|
1553
|
+
}
|
|
1554
|
+
if (opts.verifyGithub && prApprovals.length) {
|
|
1555
|
+
const api = GitHubApi.fromRepo(root2, opts.fetchImpl);
|
|
1556
|
+
if (typeof api === "string") r.err(root2, `--verify-github: ${api}`);
|
|
1557
|
+
else {
|
|
1558
|
+
for (const p of prApprovals) {
|
|
1559
|
+
const ok = await verifyRecordedApproval(api, p.approval.approved_via, p.approver, p.contentPath, p.approval.content_hash, p.hashOf);
|
|
1560
|
+
if (ok === true) console.log(` \u2713 ${p.what}: ${p.approver} approved ${p.approval.approved_via}`);
|
|
1561
|
+
else r.err(p.file, `${p.what}: ${ok}`);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
935
1564
|
}
|
|
936
1565
|
r.summary();
|
|
937
1566
|
return r.errors === 0;
|
|
938
1567
|
}
|
|
939
1568
|
|
|
1569
|
+
// src/commands/job.ts
|
|
1570
|
+
var PLANNED = ["enrich", "suppression:import", "signals", "qualify", "qa", "push", "activate", "results"];
|
|
1571
|
+
function git(root2, args2) {
|
|
1572
|
+
const res = spawnSync3("git", args2, { cwd: root2, encoding: "utf8" });
|
|
1573
|
+
return res.status === 0 ? res.stdout.trim() : null;
|
|
1574
|
+
}
|
|
1575
|
+
function buildTamInput(root2, campaign) {
|
|
1576
|
+
const read = (rel) => parse6(readFileSync11(join12(root2, rel), "utf8"));
|
|
1577
|
+
const project = ProjectFile.parse(read("project.yaml"));
|
|
1578
|
+
const icp = IcpFile.parse(read("context/icp.yaml"));
|
|
1579
|
+
const cfgText = readFileSync11(join12(root2, "campaigns", campaign, "config.yaml"), "utf8");
|
|
1580
|
+
const cfg = CampaignConfig.parse(parse6(cfgText));
|
|
1581
|
+
if (icp.approval.status !== "approved") return "TAM needs an approved ICP (hard stop, AGENTS.md section 4). Get ICP sign-off first.";
|
|
1582
|
+
if (Stage.options.indexOf(cfg.stage) < Stage.options.indexOf("tam")) return `campaign stage is "${cfg.stage}": move it to "tam" first.`;
|
|
1583
|
+
if (!cfg.tam) return "config.yaml has no `tam:` section. Add `tam: { source: mock-accounts }` for a local proof, or a real account source.";
|
|
1584
|
+
const segments = resolveIcp(icp).segments.filter((s) => cfg.segments.includes(s.id)).map(({ id, description, firmographics }) => ({ id, description, firmographics }));
|
|
1585
|
+
return TamJobInput.parse({
|
|
1586
|
+
ctx: {
|
|
1587
|
+
project_id: project.project_id,
|
|
1588
|
+
campaign_id: campaign,
|
|
1589
|
+
triggered_by: process.env.GTM_USER ?? git(root2, ["config", "user.name"]) ?? "unknown",
|
|
1590
|
+
core_version: GTM_VERSION,
|
|
1591
|
+
git_sha: git(root2, ["rev-parse", "HEAD"]),
|
|
1592
|
+
config_hash: `sha256:${createHash4("sha256").update(cfgText).digest("hex").slice(0, 16)}`
|
|
1593
|
+
},
|
|
1594
|
+
tam_id: cfg.tam_id,
|
|
1595
|
+
source: cfg.tam.source,
|
|
1596
|
+
max_accounts_per_segment: cfg.tam.max_accounts_per_segment,
|
|
1597
|
+
segments,
|
|
1598
|
+
exclusions: icp.exclusions,
|
|
1599
|
+
budget_usd: cfg.budget.max_total
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
function printTam(res, campaign) {
|
|
1603
|
+
const header = ["segment", "found", "excluded", "new", "updated", "reachable", "active"];
|
|
1604
|
+
const rows = res.segments.map((s) => [s.segment_id, s.found, s.excluded, s.inserted, s.updated, s.reachable, s.active].map(String));
|
|
1605
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
1606
|
+
const line = (r) => r.map((c, i) => i === 0 ? c.padEnd(widths[i]) : c.padStart(widths[i])).join(" ");
|
|
1607
|
+
console.log(`
|
|
1608
|
+
${line(header)}
|
|
1609
|
+
${widths.map((w) => "-".repeat(w)).join(" ")}`);
|
|
1610
|
+
for (const r of rows) console.log(line(r));
|
|
1611
|
+
console.log(`
|
|
1612
|
+
Funnel: TAM ${res.totals.tam} \u2192 reachable ${res.totals.reachable} \u2192 active ${res.totals.active} cost $${res.cost_usd.toFixed(2)} run ${res.run_id}`);
|
|
1613
|
+
console.log(`
|
|
1614
|
+
Run log row (campaigns/${campaign}/runs/\u2026):
|
|
1615
|
+
| tam | ${res.run_id} | succeeded | ${res.segments.length} segment(s) | TAM ${res.totals.tam} \u2192 reachable ${res.totals.reachable} \u2192 active ${res.totals.active} | ${res.cost_usd.toFixed(2)} |`);
|
|
1616
|
+
}
|
|
1617
|
+
async function runJob(root2, args2) {
|
|
1618
|
+
const [name, campaign] = args2;
|
|
1619
|
+
if (name && PLANNED.includes(name)) {
|
|
1620
|
+
console.error(`gtm job ${name}: not available yet (planned). Per AGENTS.md this is a mandatory stop: do not improvise it with scripts.`);
|
|
1621
|
+
return false;
|
|
1622
|
+
}
|
|
1623
|
+
if (name !== "tam" || !campaign) {
|
|
1624
|
+
console.error(`Usage: gtm job tam <campaign> (planned: ${PLANNED.join(", ")})`);
|
|
1625
|
+
return false;
|
|
1626
|
+
}
|
|
1627
|
+
if (!existsSync10(join12(root2, "campaigns", campaign, "config.yaml"))) {
|
|
1628
|
+
console.error(`campaigns/${campaign}/config.yaml not found`);
|
|
1629
|
+
return false;
|
|
1630
|
+
}
|
|
1631
|
+
console.log(`\u2022 validating ${campaign}`);
|
|
1632
|
+
if (!await runValidate(root2, campaign)) {
|
|
1633
|
+
console.error("\nFix the validation errors above before running jobs.");
|
|
1634
|
+
return false;
|
|
1635
|
+
}
|
|
1636
|
+
let input;
|
|
1637
|
+
try {
|
|
1638
|
+
input = buildTamInput(root2, campaign);
|
|
1639
|
+
} catch (e) {
|
|
1640
|
+
console.error(e.message);
|
|
1641
|
+
return false;
|
|
1642
|
+
}
|
|
1643
|
+
if (typeof input === "string") {
|
|
1644
|
+
console.error(input);
|
|
1645
|
+
return false;
|
|
1646
|
+
}
|
|
1647
|
+
loadStackEnv();
|
|
1648
|
+
if (!process.env.HATCHET_CLIENT_TOKEN) {
|
|
1649
|
+
console.error(`No Hatchet connection. Local: run \`pnpm stack:up\` and \`pnpm worker\` in gtm-core (writes ${STACK_ENV_FILE}). Deployed: set HATCHET_CLIENT_TOKEN.`);
|
|
1650
|
+
return false;
|
|
1651
|
+
}
|
|
1652
|
+
const { HatchetClient } = await import("@hatchet-dev/typescript-sdk/v1");
|
|
1653
|
+
const hatchet = HatchetClient.init();
|
|
1654
|
+
const tam = hatchet.task({ name: JOB_NAMES.tam, fn: async () => {
|
|
1655
|
+
throw new Error("runs on the gtm worker");
|
|
1656
|
+
} });
|
|
1657
|
+
console.log(`\u2022 running tam for ${input.segments.length} segment(s) via ${input.source} (budget $${input.budget_usd})`);
|
|
1658
|
+
const hint = setTimeout(() => console.log(" \u2026 still waiting. Is the worker running? (`pnpm worker` in gtm-core)"), 2e4);
|
|
1659
|
+
try {
|
|
1660
|
+
const output = await tam.run(input);
|
|
1661
|
+
printTam(TamJobResult.parse(output), campaign);
|
|
1662
|
+
return true;
|
|
1663
|
+
} catch (e) {
|
|
1664
|
+
console.error(`tam failed: ${e.message}`);
|
|
1665
|
+
return false;
|
|
1666
|
+
} finally {
|
|
1667
|
+
clearTimeout(hint);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// src/commands/new-campaign.ts
|
|
1672
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync3, readFileSync as readFileSync12, writeFileSync as writeFileSync5 } from "fs";
|
|
1673
|
+
import { join as join13 } from "path";
|
|
1674
|
+
import { parse as parse7 } from "yaml";
|
|
1675
|
+
function runNewCampaign(root2, campaign) {
|
|
1676
|
+
if (!campaign || !Slug.safeParse(campaign).success) {
|
|
1677
|
+
console.error("Usage: gtm new-campaign <campaign-slug>");
|
|
1678
|
+
return false;
|
|
1679
|
+
}
|
|
1680
|
+
const projectFile = join13(root2, "project.yaml");
|
|
1681
|
+
if (!existsSync11(projectFile)) {
|
|
1682
|
+
console.error("project.yaml not found: run this from the root of a project repo");
|
|
1683
|
+
return false;
|
|
1684
|
+
}
|
|
1685
|
+
const dest = join13(root2, "campaigns", campaign);
|
|
1686
|
+
if (existsSync11(dest)) {
|
|
1687
|
+
console.error(`campaigns/${campaign} already exists`);
|
|
1688
|
+
return false;
|
|
1689
|
+
}
|
|
1690
|
+
const projectId = parse7(readFileSync12(projectFile, "utf8"))?.project_id ?? "TODO";
|
|
1691
|
+
copyTemplate(join13(KIT_DIR, "templates", "campaign"), dest, { PROJECT_ID: projectId, CAMPAIGN_ID: campaign, OWNER: process.env.GTM_USER ?? "TODO" });
|
|
1692
|
+
mkdirSync3(join13(dest, "runs"), { recursive: true });
|
|
1693
|
+
writeFileSync5(join13(dest, "runs", ".gitkeep"), "");
|
|
1694
|
+
console.log(`Created campaigns/${campaign}. Branch: git switch -c campaign/${campaign}`);
|
|
1695
|
+
return true;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// src/commands/status.ts
|
|
1699
|
+
import { existsSync as existsSync12, readdirSync as readdirSync3, readFileSync as readFileSync13 } from "fs";
|
|
1700
|
+
import { join as join14 } from "path";
|
|
1701
|
+
import { parse as parse8 } from "yaml";
|
|
1702
|
+
function readYaml2(file) {
|
|
1703
|
+
try {
|
|
1704
|
+
return parse8(readFileSync13(file, "utf8"));
|
|
1705
|
+
} catch {
|
|
1706
|
+
return null;
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
function campaignStages(root2) {
|
|
1710
|
+
return subdirs(join14(root2, "campaigns")).map((id) => ({ id, stage: readYaml2(join14(root2, "campaigns", id, "config.yaml"))?.stage ?? "?" }));
|
|
1711
|
+
}
|
|
1712
|
+
function runStatus(root2) {
|
|
1713
|
+
const project = readYaml2(join14(root2, "project.yaml"));
|
|
1714
|
+
const icp = readYaml2(join14(root2, "context", "icp.yaml"));
|
|
1715
|
+
console.log(`${project?.name ?? "?"} (${project?.project_id ?? "?"}) \xB7 status ${project?.status ?? "?"} \xB7 core v${GTM_VERSION}`);
|
|
1716
|
+
console.log(`ICP gate: ${icp?.approval?.status ?? "?"}${icp?.approval?.approved_by ? ` by ${icp.approval.approved_by} on ${icp.approval.approved_at}` : ""}
|
|
1717
|
+
`);
|
|
1718
|
+
const rows = subdirs(join14(root2, "campaigns")).map((id) => {
|
|
1719
|
+
const cfg = readYaml2(join14(root2, "campaigns", id, "config.yaml")) ?? {};
|
|
1720
|
+
const runsDir = join14(root2, "campaigns", id, "runs");
|
|
1721
|
+
const last = existsSync12(runsDir) ? readdirSync3(runsDir).filter((f) => f.endsWith(".md")).sort().at(-1) ?? "-" : "-";
|
|
1722
|
+
return [id, cfg.stage ?? "?", cfg.gates?.copy?.status ?? "?", cfg.gates?.launch?.status ?? "?", cfg.owner ?? "?", last];
|
|
1723
|
+
});
|
|
1724
|
+
if (!rows.length) {
|
|
1725
|
+
console.log("no campaigns yet: npx gtm new-campaign <campaign-slug>");
|
|
1726
|
+
return true;
|
|
1727
|
+
}
|
|
1728
|
+
const header = ["campaign", "stage", "copy gate", "launch gate", "owner", "latest run log"];
|
|
1729
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
|
|
1730
|
+
const line = (r) => r.map((c, i) => String(c).padEnd(widths[i])).join(" ");
|
|
1731
|
+
console.log(line(header));
|
|
1732
|
+
console.log(widths.map((w) => "-".repeat(w)).join(" "));
|
|
1733
|
+
for (const r of rows) console.log(line(r));
|
|
1734
|
+
return true;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// src/commands/upgrade.ts
|
|
1738
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
1739
|
+
import { existsSync as existsSync13, readFileSync as readFileSync14 } from "fs";
|
|
1740
|
+
import { join as join15 } from "path";
|
|
1741
|
+
|
|
1742
|
+
// src/changelog.ts
|
|
1743
|
+
function compareVersions(a, b) {
|
|
1744
|
+
const pa = a.replace(/^[^\d]*/, "").split(/[.-]/).slice(0, 3).map(Number);
|
|
1745
|
+
const pb = b.replace(/^[^\d]*/, "").split(/[.-]/).slice(0, 3).map(Number);
|
|
1746
|
+
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
1747
|
+
return 0;
|
|
1748
|
+
}
|
|
1749
|
+
function changelogBetween(text, from, to) {
|
|
1750
|
+
const parts = text.split(/^(?=## )/m);
|
|
1751
|
+
return parts.filter((p) => {
|
|
1752
|
+
const m = p.match(/^## \[?(\d+\.\d+\.\d+)/);
|
|
1753
|
+
return m && compareVersions(m[1], from) > 0 && compareVersions(m[1], to) <= 0;
|
|
1754
|
+
}).join("").trim();
|
|
1755
|
+
}
|
|
1756
|
+
|
|
940
1757
|
// src/commands/upgrade.ts
|
|
941
1758
|
function detectPackageManager(root2) {
|
|
942
|
-
if (
|
|
943
|
-
if (
|
|
1759
|
+
if (existsSync13(join15(root2, "pnpm-lock.yaml"))) return "pnpm";
|
|
1760
|
+
if (existsSync13(join15(root2, "yarn.lock"))) return "yarn";
|
|
944
1761
|
return "npm";
|
|
945
1762
|
}
|
|
946
1763
|
function installExact(root2, pm, spec) {
|
|
947
|
-
const onPath = (bin) =>
|
|
1764
|
+
const onPath = (bin) => spawnSync4(bin, ["--version"], { stdio: "ignore" }).status === 0;
|
|
948
1765
|
const [cmd, args2] = pm === "npm" ? ["npm", ["install", "--save-dev", "--save-exact", spec]] : pm === "pnpm" ? onPath("pnpm") ? ["pnpm", ["add", "-D", "--save-exact", spec]] : ["npx", ["-y", "pnpm@10", "add", "-D", "--save-exact", spec]] : onPath("yarn") ? ["yarn", ["add", "-D", "--exact", spec]] : ["npx", ["-y", "yarn@1", "add", "-D", "--exact", spec]];
|
|
949
|
-
return
|
|
1766
|
+
return spawnSync4(cmd, args2, { cwd: root2, stdio: "inherit" }).status ?? 1;
|
|
950
1767
|
}
|
|
951
1768
|
function gtm(root2, args2) {
|
|
952
|
-
return
|
|
1769
|
+
return spawnSync4("npx", ["gtm", ...args2], { cwd: root2, stdio: "inherit" }).status ?? 1;
|
|
953
1770
|
}
|
|
954
1771
|
var FROZEN_STAGES = ["qa", "launch"];
|
|
955
1772
|
function runUpgrade(root2, opts) {
|
|
@@ -964,7 +1781,7 @@ function runUpgrade(root2, opts) {
|
|
|
964
1781
|
console.error("Finish the launch first, or re-run with --force if a GTM engineer approved it.");
|
|
965
1782
|
return false;
|
|
966
1783
|
}
|
|
967
|
-
const dirty =
|
|
1784
|
+
const dirty = spawnSync4("git", ["status", "--porcelain"], { cwd: root2, encoding: "utf8" });
|
|
968
1785
|
if (dirty.status === 0 && dirty.stdout.trim() && !opts.force) {
|
|
969
1786
|
console.error("Refusing to upgrade with uncommitted changes. Commit or stash first (or --force).");
|
|
970
1787
|
return false;
|
|
@@ -977,9 +1794,9 @@ function runUpgrade(root2, opts) {
|
|
|
977
1794
|
if (gtm(root2, ["migrate"]) !== 0) return false;
|
|
978
1795
|
const valid = gtm(root2, ["validate"]) === 0;
|
|
979
1796
|
const after = pinnedVersion(root2) ?? target;
|
|
980
|
-
const changelog =
|
|
981
|
-
if (
|
|
982
|
-
const notes = changelogBetween(
|
|
1797
|
+
const changelog = join15(root2, "node_modules", ...PACKAGE_NAME.split("/"), "CHANGELOG.md");
|
|
1798
|
+
if (existsSync13(changelog)) {
|
|
1799
|
+
const notes = changelogBetween(readFileSync14(changelog, "utf8"), before, after);
|
|
983
1800
|
console.log(`
|
|
984
1801
|
=== Changes ${before} \u2192 ${after} ===
|
|
985
1802
|
${notes || "(no changelog entries)"}
|
|
@@ -992,15 +1809,19 @@ ${notes || "(no changelog entries)"}
|
|
|
992
1809
|
// src/help.ts
|
|
993
1810
|
var COMMANDS = {
|
|
994
1811
|
init: {
|
|
995
|
-
usage: 'gtm init <project-slug> --name "<Project Name>" [--dir <path>]',
|
|
996
|
-
summary:
|
|
1812
|
+
usage: 'gtm init <project-slug> --name "<Project Name>" [--dir <path>]\ngtm init . --name "<Project Name>" [--slug <project-slug>]',
|
|
1813
|
+
summary: 'scaffold a project repo (new folder, or the current one with ".")',
|
|
997
1814
|
details: [
|
|
998
1815
|
"Creates a complete project repo: project.yaml, context/ templates, campaigns/, tam/, PROJECT.md,",
|
|
999
|
-
"CI workflows, .gtm/ (core kit) and AGENTS.md, pinned to this CLI's version
|
|
1816
|
+
"CI workflows, .gtm/ (core kit) and AGENTS.md, pinned to this CLI's version.",
|
|
1817
|
+
'"." sets up the current folder; the slug comes from the folder name (gtm-project-acme \u2192 acme) or --slug.',
|
|
1818
|
+
"Works in a fresh clone: .git is kept (no git init inside an existing repo), README.md is kept,",
|
|
1819
|
+
".gitignore gets gtm rules appended, CLAUDE.md gets @AGENTS.md. Any other existing gtm file",
|
|
1820
|
+
"(e.g. AGENTS.md, .gtm/, package.json) stops init before anything is written.",
|
|
1000
1821
|
"Push the result to a PRIVATE repo: it will hold project context."
|
|
1001
1822
|
],
|
|
1002
|
-
writes:
|
|
1003
|
-
examples: ['npx @veleads/gtm init
|
|
1823
|
+
writes: 'The target folder (new ./gtm-project-<slug>, --dir, or the current folder with "."). Never overwrites existing files.',
|
|
1824
|
+
examples: ['npx @veleads/gtm init . --name "Acme Corp"', 'npx @veleads/gtm init acme --name "Acme Corp"', 'npx @veleads/gtm init . --name "Acme Corp" --slug acme']
|
|
1004
1825
|
},
|
|
1005
1826
|
"new-campaign": {
|
|
1006
1827
|
usage: "gtm new-campaign <campaign-slug>",
|
|
@@ -1010,16 +1831,18 @@ var COMMANDS = {
|
|
|
1010
1831
|
examples: ["git switch -c campaign/q1-uk-cfo && npx gtm new-campaign q1-uk-cfo"]
|
|
1011
1832
|
},
|
|
1012
1833
|
validate: {
|
|
1013
|
-
usage: "gtm validate [campaign]",
|
|
1834
|
+
usage: "gtm validate [campaign] [--verify-github]",
|
|
1014
1835
|
summary: "check repo against schemas, gates, data rules",
|
|
1015
1836
|
details: [
|
|
1016
1837
|
"Checks project.yaml, context/ and campaigns against the schemas; segment/persona/signal references;",
|
|
1017
1838
|
"gate order (no stage past an unapproved or changed ICP/copy approval); required files; run-log names;",
|
|
1018
1839
|
"no lead data (blocks data files, warns on email addresses); .gtm/ matches the installed core.",
|
|
1840
|
+
"GitHub mode: ICP/copy approvals must come from the approver's PR review (approved_via).",
|
|
1841
|
+
"--verify-github (CI) re-checks each recorded PR approval on GitHub.",
|
|
1019
1842
|
"Exit code 1 if there is any error; warnings never fail."
|
|
1020
1843
|
],
|
|
1021
1844
|
writes: "Nothing (read-only).",
|
|
1022
|
-
examples: ["npx gtm validate", "npx gtm validate q1-uk-cfo"]
|
|
1845
|
+
examples: ["npx gtm validate", "npx gtm validate q1-uk-cfo", "npx gtm validate --verify-github"]
|
|
1023
1846
|
},
|
|
1024
1847
|
status: {
|
|
1025
1848
|
usage: "gtm status",
|
|
@@ -1028,26 +1851,64 @@ var COMMANDS = {
|
|
|
1028
1851
|
writes: "Nothing (read-only).",
|
|
1029
1852
|
examples: ["npx gtm status"]
|
|
1030
1853
|
},
|
|
1854
|
+
icp: {
|
|
1855
|
+
usage: "gtm icp",
|
|
1856
|
+
summary: "show the ICP as jobs see it (core + segment overrides)",
|
|
1857
|
+
details: [
|
|
1858
|
+
"Prints the core profile, then every segment's FINAL filters (core with the segment's fields",
|
|
1859
|
+
"replacing the same core fields), which fields the segment overrides, its personas, and the",
|
|
1860
|
+
"shared exclusions. Use it to review an ICP before sign-off and to check what `gtm job tam` searches."
|
|
1861
|
+
],
|
|
1862
|
+
writes: "Nothing (read-only).",
|
|
1863
|
+
examples: ["npx gtm icp"]
|
|
1864
|
+
},
|
|
1031
1865
|
approve: {
|
|
1032
|
-
usage: 'gtm approve icp --by "<Name (role)>" [--notes ".."] [--date YYYY-MM-DD]\ngtm approve copy <campaign> --by "<Name (role)>" [
|
|
1866
|
+
usage: 'gtm approve icp --pr <number> [--notes ".."]\ngtm approve copy <campaign> --pr <number> [--notes ".."]\ngtm approve icp --by "<Name (role)>" [--notes ".."] [--date YYYY-MM-DD]\ngtm approve copy <campaign> --by "<Name (role)>" [..]',
|
|
1033
1867
|
summary: "record a sign-off and pin a hash of the approved content",
|
|
1034
1868
|
details: [
|
|
1035
|
-
"
|
|
1036
|
-
"
|
|
1037
|
-
"
|
|
1038
|
-
"
|
|
1869
|
+
"GitHub mode (project.yaml owners.approver is an @handle): the approval IS the approver's APPROVED",
|
|
1870
|
+
"review on a pull request. --pr verifies on GitHub that the PR changes the file, the approver approved",
|
|
1871
|
+
"its latest commit, and your local file equals the approved version; then records approved_via.",
|
|
1872
|
+
"Recorded mode (approver is a name): run ONLY after the named human explicitly approved in this session.",
|
|
1873
|
+
"Either way a content hash is pinned: if the ICP or copy changes later, `gtm validate` fails.",
|
|
1874
|
+
"The launch gate is recorded by hand in config.yaml."
|
|
1039
1875
|
],
|
|
1040
1876
|
writes: "context/icp.yaml \u2192 approval (icp), or campaigns/<campaign>/config.yaml \u2192 gates.copy (copy).",
|
|
1041
|
-
examples: [
|
|
1877
|
+
examples: ["npx gtm approve icp --pr 12", "npx gtm approve copy q1-uk-cfo --pr 15", 'npx gtm approve icp --by "Maria Chen (VP Marketing)"']
|
|
1878
|
+
},
|
|
1879
|
+
track: {
|
|
1880
|
+
usage: "gtm track stage <campaign>\ngtm track gate-open <icp|copy|launch> [campaign] [--pr <number>]\ngtm track gate-done <icp|copy|launch> [campaign]",
|
|
1881
|
+
summary: "mirror stages and gates into the project tracker",
|
|
1882
|
+
details: [
|
|
1883
|
+
"Uses project.yaml \u2192 tracker.kind (github | none). GitHub: one issue per campaign (label stage:<stage>)",
|
|
1884
|
+
"and one per gate, assigned to the approver. Re-running updates the same issue.",
|
|
1885
|
+
"The tracker is a mirror only: stage and gates live in the YAML files. gate-done refuses unless the",
|
|
1886
|
+
"files say the gate is approved."
|
|
1887
|
+
],
|
|
1888
|
+
writes: "Issues in the tracker (GitHub: issues, labels, comments). No local files.",
|
|
1889
|
+
examples: ["npx gtm track stage q1-uk-cfo", "npx gtm track gate-open icp --pr 12", "npx gtm track gate-done copy q1-uk-cfo"]
|
|
1890
|
+
},
|
|
1891
|
+
github: {
|
|
1892
|
+
usage: "gtm github setup [--apply]",
|
|
1893
|
+
summary: "prepare the GitHub repo: labels and approval ruleset",
|
|
1894
|
+
details: [
|
|
1895
|
+
"Creates the labels gtm track uses, and a ruleset on the default branch: PR required, review from",
|
|
1896
|
+
"CODEOWNERS required (the approver owns icp.yaml, signals.yaml, copy.md), CI job `check` must pass.",
|
|
1897
|
+
"Without --apply it only prints the plan. Needs admin rights; private repos need a paid GitHub plan",
|
|
1898
|
+
"for rulesets (CI verification via `validate --verify-github` works on any plan)."
|
|
1899
|
+
],
|
|
1900
|
+
writes: "With --apply: labels and a ruleset on GitHub. Without: nothing.",
|
|
1901
|
+
examples: ["npx gtm github setup", "npx gtm github setup --apply"]
|
|
1042
1902
|
},
|
|
1043
1903
|
sync: {
|
|
1044
1904
|
usage: "gtm sync [--check]",
|
|
1045
1905
|
summary: "copy core kit into .gtm/ + AGENTS.md",
|
|
1046
1906
|
details: [
|
|
1047
|
-
"Makes .gtm/ (skills, templates, schemas, provider docs) and AGENTS.md match the installed core
|
|
1907
|
+
"Makes .gtm/ (skills, templates, schemas, guides, provider docs) and AGENTS.md match the installed core,",
|
|
1908
|
+
"and maintains the gtm block in .github/CODEOWNERS from project.yaml \u2192 owners.approver.",
|
|
1048
1909
|
"--check only compares and exits 1 if they differ (CI uses this; never edit .gtm/ by hand)."
|
|
1049
1910
|
],
|
|
1050
|
-
writes: ".gtm/ (replaced entirely)
|
|
1911
|
+
writes: ".gtm/ (replaced entirely), AGENTS.md, the gtm block in .github/CODEOWNERS; creates CLAUDE.md if missing. With --check: nothing.",
|
|
1051
1912
|
examples: ["npx gtm sync", "npx gtm sync --check"]
|
|
1052
1913
|
},
|
|
1053
1914
|
migrate: {
|
|
@@ -1068,13 +1929,16 @@ var COMMANDS = {
|
|
|
1068
1929
|
examples: ["npx gtm upgrade", "npx gtm upgrade 0.3.0"]
|
|
1069
1930
|
},
|
|
1070
1931
|
job: {
|
|
1071
|
-
usage: "gtm job
|
|
1072
|
-
summary: "run a pipeline job
|
|
1932
|
+
usage: "gtm job tam <campaign>",
|
|
1933
|
+
summary: "run a pipeline job on the workers",
|
|
1073
1934
|
details: [
|
|
1074
|
-
"
|
|
1075
|
-
"
|
|
1935
|
+
"tam: builds the TAM for the campaign's segments into the database (needs an approved ICP and",
|
|
1936
|
+
"config.yaml \u2192 tam.source; mock-accounts for a local proof) and prints the funnel, cost and run_id.",
|
|
1937
|
+
"Validates first, sends the resolved config to the workers via Hatchet, and waits for the result.",
|
|
1938
|
+
"Local: `pnpm stack:up` + `pnpm worker` in gtm-core (settings in ~/.gtm/stack.env).",
|
|
1939
|
+
"Planned (exit 1, a stop per AGENTS.md): enrich, suppression:import, signals, qualify, qa, push, activate, results."
|
|
1076
1940
|
],
|
|
1077
|
-
writes: "
|
|
1941
|
+
writes: "Rows in Postgres (runs, accounts, costs). No repo files: copy the printed run-log row into the run log.",
|
|
1078
1942
|
examples: ["npx gtm job tam q1-uk-cfo"]
|
|
1079
1943
|
}
|
|
1080
1944
|
};
|
|
@@ -1123,6 +1987,10 @@ var { values, positionals } = parseArgs({
|
|
|
1123
1987
|
root: { type: "string" },
|
|
1124
1988
|
name: { type: "string" },
|
|
1125
1989
|
dir: { type: "string" },
|
|
1990
|
+
slug: { type: "string" },
|
|
1991
|
+
pr: { type: "string" },
|
|
1992
|
+
apply: { type: "boolean" },
|
|
1993
|
+
"verify-github": { type: "boolean" },
|
|
1126
1994
|
by: { type: "string" },
|
|
1127
1995
|
notes: { type: "string" },
|
|
1128
1996
|
date: { type: "string" },
|
|
@@ -1151,7 +2019,7 @@ ${generalHelp(GTM_VERSION)}`);
|
|
|
1151
2019
|
console.log(text);
|
|
1152
2020
|
return true;
|
|
1153
2021
|
}
|
|
1154
|
-
function run() {
|
|
2022
|
+
async function run() {
|
|
1155
2023
|
if (values.version) {
|
|
1156
2024
|
console.log(GTM_VERSION);
|
|
1157
2025
|
return true;
|
|
@@ -1159,15 +2027,21 @@ function run() {
|
|
|
1159
2027
|
if (values.help) return help(command === "help" ? args[0] : command);
|
|
1160
2028
|
switch (command) {
|
|
1161
2029
|
case "init":
|
|
1162
|
-
return runInit(args[0], { name: values.name, dir: values.dir, cwd });
|
|
2030
|
+
return runInit(args[0], { name: values.name, dir: values.dir, slug: values.slug, cwd });
|
|
1163
2031
|
case "new-campaign":
|
|
1164
2032
|
return runNewCampaign(root, args[0]);
|
|
1165
2033
|
case "validate":
|
|
1166
|
-
return runValidate(root, args[0]);
|
|
2034
|
+
return runValidate(root, args[0], { verifyGithub: values["verify-github"] });
|
|
1167
2035
|
case "status":
|
|
1168
2036
|
return runStatus(root);
|
|
2037
|
+
case "icp":
|
|
2038
|
+
return runIcp(root);
|
|
1169
2039
|
case "approve":
|
|
1170
|
-
return runApprove(root, args, { by: values.by, notes: values.notes, date: values.date });
|
|
2040
|
+
return runApprove(root, args, { by: values.by, notes: values.notes, date: values.date, pr: values.pr });
|
|
2041
|
+
case "track":
|
|
2042
|
+
return runTrack(root, args, { pr: values.pr });
|
|
2043
|
+
case "github":
|
|
2044
|
+
return runGithub(root, args, { apply: values.apply });
|
|
1171
2045
|
case "sync":
|
|
1172
2046
|
return runSync(root, { check: values.check });
|
|
1173
2047
|
case "migrate":
|
|
@@ -1175,7 +2049,7 @@ function run() {
|
|
|
1175
2049
|
case "upgrade":
|
|
1176
2050
|
return runUpgrade(root, { version: args[0], force: values.force });
|
|
1177
2051
|
case "job":
|
|
1178
|
-
return runJob(args
|
|
2052
|
+
return runJob(root, args);
|
|
1179
2053
|
case void 0:
|
|
1180
2054
|
return help(void 0);
|
|
1181
2055
|
case "help":
|
|
@@ -1187,4 +2061,4 @@ ${generalHelp(GTM_VERSION)}`);
|
|
|
1187
2061
|
return false;
|
|
1188
2062
|
}
|
|
1189
2063
|
}
|
|
1190
|
-
process.exit(run() ? 0 : 1);
|
|
2064
|
+
process.exit(await run() ? 0 : 1);
|