fullstackgtm 0.25.0 → 0.25.2
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 +85 -0
- package/INSTALL_FOR_AGENTS.md +15 -2
- package/README.md +14 -3
- package/dist/cli.js +17 -2
- package/dist/connectors/hubspot.js +5 -2
- package/dist/connectors/salesforce.js +4 -2
- package/dist/connectors/stripe.js +4 -2
- package/dist/credentials.js +22 -1
- package/dist/enrich.js +24 -2
- package/dist/enrichApollo.js +5 -2
- package/dist/market.d.ts +1 -0
- package/dist/market.js +144 -8
- package/dist/marketReport.d.ts +9 -0
- package/dist/marketReport.js +29 -4
- package/dist/schedule.d.ts +17 -0
- package/dist/schedule.js +83 -2
- package/docs/api.md +28 -2
- package/docs/crm-health-lifecycle.md +11 -6
- package/docs/roadmap-to-1.0.md +27 -0
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +6 -4
- package/src/cli.ts +18 -1
- package/src/connectors/hubspot.ts +5 -2
- package/src/connectors/salesforce.ts +4 -2
- package/src/connectors/stripe.ts +4 -2
- package/src/credentials.ts +24 -0
- package/src/enrich.ts +25 -2
- package/src/enrichApollo.ts +5 -2
- package/src/market.ts +129 -8
- package/src/marketReport.ts +30 -4
- package/src/schedule.ts +92 -2
package/dist/marketReport.js
CHANGED
|
@@ -28,6 +28,22 @@ function escapeHtml(value) {
|
|
|
28
28
|
.replace(/>/g, ">")
|
|
29
29
|
.replace(/"/g, """);
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Serialize JSON for embedding inside an inline <script> block. JSON.stringify
|
|
33
|
+
* does not escape `<`, `>`, `&`, or the U+2028/U+2029 line separators, so a
|
|
34
|
+
* vendor name containing `</script>` (these are untrusted, competitor-authored
|
|
35
|
+
* strings) would close the tag and inject markup. Replacing them with their
|
|
36
|
+
* \uXXXX escapes keeps the parsed value identical while making the breakout
|
|
37
|
+
* sequence unrepresentable in the HTML source.
|
|
38
|
+
*/
|
|
39
|
+
export function safeJsonForScript(value) {
|
|
40
|
+
return JSON.stringify(value)
|
|
41
|
+
.replace(/</g, "\\u003c")
|
|
42
|
+
.replace(/>/g, "\\u003e")
|
|
43
|
+
.replace(/&/g, "\\u0026")
|
|
44
|
+
.replace(/\u2028/g, "\\u2028")
|
|
45
|
+
.replace(/\u2029/g, "\\u2029");
|
|
46
|
+
}
|
|
31
47
|
function buildModel(config, set) {
|
|
32
48
|
const fronts = computeFrontStates(config, set);
|
|
33
49
|
const stateByClaim = new Map(fronts.map((front) => [front.claimId, front.state]));
|
|
@@ -320,7 +336,7 @@ function axisSectionsHtml(config, set) {
|
|
|
320
336
|
<table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
|
|
321
337
|
</div>
|
|
322
338
|
<div class="map-tip" id="map-tip" hidden></div>
|
|
323
|
-
<script type="application/json" id="map-data">${
|
|
339
|
+
<script type="application/json" id="map-data">${safeJsonForScript(tipData)}</script>
|
|
324
340
|
<script>
|
|
325
341
|
(function () {
|
|
326
342
|
var data = JSON.parse(document.getElementById("map-data").textContent);
|
|
@@ -331,7 +347,16 @@ function axisSectionsHtml(config, set) {
|
|
|
331
347
|
function show(v, evt) {
|
|
332
348
|
var d = data[v];
|
|
333
349
|
if (!d) return;
|
|
334
|
-
|
|
350
|
+
// textContent only — vendor names / axis labels are untrusted (competitor-controlled).
|
|
351
|
+
tip.textContent = "";
|
|
352
|
+
var head = document.createElement("b");
|
|
353
|
+
head.textContent = d.n + " · " + d.name;
|
|
354
|
+
tip.appendChild(head);
|
|
355
|
+
d.lines.forEach(function (l) {
|
|
356
|
+
var div = document.createElement("div");
|
|
357
|
+
div.textContent = l;
|
|
358
|
+
tip.appendChild(div);
|
|
359
|
+
});
|
|
335
360
|
tip.hidden = false;
|
|
336
361
|
var box = fig.getBoundingClientRect();
|
|
337
362
|
tip.style.left = Math.min(evt.clientX - box.left + 14, box.width - tip.offsetWidth - 8) + "px";
|
|
@@ -419,7 +444,7 @@ export function marketMapToHtml(config, set) {
|
|
|
419
444
|
const anchorLoud = anchor
|
|
420
445
|
? claimIds.filter((claimId) => model.cell(anchor, claimId)?.intensity === "loud").length
|
|
421
446
|
: 0;
|
|
422
|
-
const anchorNote = anchor ? ` · ${vendorNamesById.get(anchor) ?? anchor} loud on ${anchorLoud}` : "";
|
|
447
|
+
const anchorNote = anchor ? ` · ${e(vendorNamesById.get(anchor) ?? anchor)} loud on ${anchorLoud}` : "";
|
|
423
448
|
return `<details class="claim-group"><summary><b>${e(group.title)}</b> — ${claimIds.length} claim${claimIds.length === 1 ? "" : "s"} <span class="sum-soft">(${e(group.blurb)}${anchorNote})</span></summary>
|
|
424
449
|
<table><thead><tr><th></th>${vendorHeads}<th></th></tr></thead><tbody>${claimIds.map(matrixRow).join("")}</tbody></table>
|
|
425
450
|
</details>`;
|
|
@@ -475,7 +500,7 @@ export function marketMapToHtml(config, set) {
|
|
|
475
500
|
const obs = model.cell(vendor.id, claimId);
|
|
476
501
|
if (!obs || obs.evidence.length === 0)
|
|
477
502
|
return [];
|
|
478
|
-
return obs.evidence.map((evidence) => `<div class="ev"><span class="ev-head">${e(claimId)} · ${obs.intensity.toUpperCase()} (${obs.confidence})</span>` +
|
|
503
|
+
return obs.evidence.map((evidence) => `<div class="ev"><span class="ev-head">${e(claimId)} · ${e(obs.intensity.toUpperCase())} (${e(String(obs.confidence ?? ""))})</span>` +
|
|
479
504
|
`<blockquote>“${e(evidence.text)}”</blockquote>` +
|
|
480
505
|
`<span class="ev-src">${e(String(evidence.metadata?.url ?? ""))} · capture ${e(String(evidence.metadata?.captureHash ?? "").slice(0, 12))}</span></div>`);
|
|
481
506
|
});
|
package/dist/schedule.d.ts
CHANGED
|
@@ -53,6 +53,23 @@ export declare function scheduleId(label: string, cron: string, argv: string[],
|
|
|
53
53
|
* in-process into the CLI router, never through a shell).
|
|
54
54
|
*/
|
|
55
55
|
export declare function validateSchedulableArgv(argv: string[]): void;
|
|
56
|
+
/**
|
|
57
|
+
* A schedule label is free text the operator chooses, but it is later
|
|
58
|
+
* interpolated into a crontab comment line by `renderManagedBlock`. A newline
|
|
59
|
+
* (or carriage return) would break out of the comment and inject an arbitrary
|
|
60
|
+
* crontab entry on `schedule install`. Reject control characters at the entry
|
|
61
|
+
* point so a label can never carry a second line; `renderManagedBlock` also
|
|
62
|
+
* strips them defensively in case a hand-edited schedules.json slips one past.
|
|
63
|
+
*/
|
|
64
|
+
export declare function assertSingleLineLabel(label: string): void;
|
|
65
|
+
/**
|
|
66
|
+
* True if the string contains any line-breaking or control character. Covers
|
|
67
|
+
* C0 controls + DEL, plus the Unicode separators a non-cron parser might honor
|
|
68
|
+
* (NEL U+0085, LS U+2028, PS U+2029, VT U+000B, FF U+000C) — defense-in-depth
|
|
69
|
+
* for the future modal/aws scaffold renderers whose target formats may treat
|
|
70
|
+
* those as line breaks.
|
|
71
|
+
*/
|
|
72
|
+
export declare function hasControlChar(value: string): boolean;
|
|
56
73
|
/**
|
|
57
74
|
* Split a `schedule add "<command>"` string into argv, honoring single and
|
|
58
75
|
* double quotes (no escapes, no expansion — this is tokenization, not shell).
|
package/dist/schedule.js
CHANGED
|
@@ -77,6 +77,68 @@ export function validateSchedulableArgv(argv) {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* A schedule label is free text the operator chooses, but it is later
|
|
82
|
+
* interpolated into a crontab comment line by `renderManagedBlock`. A newline
|
|
83
|
+
* (or carriage return) would break out of the comment and inject an arbitrary
|
|
84
|
+
* crontab entry on `schedule install`. Reject control characters at the entry
|
|
85
|
+
* point so a label can never carry a second line; `renderManagedBlock` also
|
|
86
|
+
* strips them defensively in case a hand-edited schedules.json slips one past.
|
|
87
|
+
*/
|
|
88
|
+
export function assertSingleLineLabel(label) {
|
|
89
|
+
if (hasControlChar(label)) {
|
|
90
|
+
throw new Error("A schedule --label cannot contain newlines or control characters " +
|
|
91
|
+
"(they would inject lines into the managed crontab block). Use a plain single-line name.");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* True if the string contains any line-breaking or control character. Covers
|
|
96
|
+
* C0 controls + DEL, plus the Unicode separators a non-cron parser might honor
|
|
97
|
+
* (NEL U+0085, LS U+2028, PS U+2029, VT U+000B, FF U+000C) — defense-in-depth
|
|
98
|
+
* for the future modal/aws scaffold renderers whose target formats may treat
|
|
99
|
+
* those as line breaks.
|
|
100
|
+
*/
|
|
101
|
+
export function hasControlChar(value) {
|
|
102
|
+
for (let i = 0; i < value.length; i++) {
|
|
103
|
+
const code = value.charCodeAt(i);
|
|
104
|
+
if (code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029)
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
/** Collapse any control/separator character to a space — last-resort guard at render time. */
|
|
110
|
+
function sanitizeCrontabComment(value) {
|
|
111
|
+
let out = "";
|
|
112
|
+
for (const ch of value) {
|
|
113
|
+
const code = ch.charCodeAt(0);
|
|
114
|
+
out += code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029 ? " " : ch;
|
|
115
|
+
}
|
|
116
|
+
return out.replace(/ {2,}/g, " ").trim();
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Validate every field of an entry that `renderManagedBlock` interpolates into
|
|
120
|
+
* the crontab — not just the label. The EXECUTABLE line embeds `cron` and `id`
|
|
121
|
+
* raw, and `schedule install` renders entries straight from schedules.json, so
|
|
122
|
+
* a hand-edited (or otherwise tampered) entry with a newline in cron/id/profile
|
|
123
|
+
* would inject a live crontab line. Refuse to render a tampered entry rather
|
|
124
|
+
* than emit it. (Well-formed entries never trip this: cron is parser-validated,
|
|
125
|
+
* id is an fnv1a hex hash, label is guarded at add-time.)
|
|
126
|
+
*/
|
|
127
|
+
function assertRenderableEntry(profile, entry) {
|
|
128
|
+
const fields = [
|
|
129
|
+
["profile", profile],
|
|
130
|
+
["cron", entry.cron],
|
|
131
|
+
["id", entry.id],
|
|
132
|
+
["label", entry.label],
|
|
133
|
+
...entry.argv.map((token, i) => [`argv[${i}]`, token]),
|
|
134
|
+
];
|
|
135
|
+
for (const [name, value] of fields) {
|
|
136
|
+
if (hasControlChar(value)) {
|
|
137
|
+
throw new Error(`Refusing to render schedule entry ${entry.id}: its ${name} contains a newline or control character. ` +
|
|
138
|
+
"The schedules.json store has been tampered with or corrupted — repair it before installing.");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
80
142
|
/**
|
|
81
143
|
* Split a `schedule add "<command>"` string into argv, honoring single and
|
|
82
144
|
* double quotes (no escapes, no expansion — this is tokenization, not shell).
|
|
@@ -124,7 +186,13 @@ const CRON_FIELD_SPECS = [
|
|
|
124
186
|
{ name: "day-of-week", min: 0, max: 7 },
|
|
125
187
|
];
|
|
126
188
|
export function parseCron(expression) {
|
|
127
|
-
|
|
189
|
+
// Reject non-ASCII whitespace and control chars: JS \s splits on U+00A0,
|
|
190
|
+
// U+3000, etc., but Vixie cron's field separator is only space/tab. A source
|
|
191
|
+
// carrying them would parse here yet be misparsed or rejected by `crontab -`.
|
|
192
|
+
if (hasControlChar(expression) || /[^\x20-\x7e]/.test(expression)) {
|
|
193
|
+
throw new Error(`Invalid cron expression "${expression}": only ASCII characters, space, and tab are allowed.`);
|
|
194
|
+
}
|
|
195
|
+
const fields = expression.trim().split(/[ \t]+/);
|
|
128
196
|
if (fields.length !== 5) {
|
|
129
197
|
throw new Error(`Invalid cron expression "${expression}": expected 5 fields ` +
|
|
130
198
|
`(minute hour day-of-month month day-of-week), got ${fields.length}.`);
|
|
@@ -432,13 +500,26 @@ export function crontabSentinels(profile) {
|
|
|
432
500
|
* but fullstackgtm dispatch (no arbitrary shell, ever).
|
|
433
501
|
*/
|
|
434
502
|
export function renderManagedBlock(profile, entries, cliInvocation) {
|
|
503
|
+
// cliInvocation is spliced raw into the executable line; it is built from
|
|
504
|
+
// process.execPath, the script path, and FSGTM_HOME (cli.ts), so a newline in
|
|
505
|
+
// FSGTM_HOME would inject a crontab line. Validate it like the entry fields —
|
|
506
|
+
// single-quote shell-escaping does NOT defend cron's line parser.
|
|
507
|
+
if (hasControlChar(cliInvocation)) {
|
|
508
|
+
throw new Error("Refusing to render the managed crontab: the resolved CLI invocation (node path, script path, " +
|
|
509
|
+
"or FSGTM_HOME) contains a newline or control character. Check $FSGTM_HOME.");
|
|
510
|
+
}
|
|
435
511
|
const { open, close } = crontabSentinels(profile);
|
|
436
512
|
const lines = [
|
|
437
513
|
open,
|
|
438
514
|
"# Managed by `fullstackgtm schedule install` — replaced wholesale on re-install; do not edit.",
|
|
439
515
|
];
|
|
440
516
|
for (const entry of entries) {
|
|
441
|
-
|
|
517
|
+
// Refuse to render any entry whose interpolated fields carry a control char
|
|
518
|
+
// — the executable line below embeds cron/id raw, so a tampered store could
|
|
519
|
+
// otherwise inject a live crontab line. The comment line is additionally
|
|
520
|
+
// sanitized so a benign-but-messy label can't break it.
|
|
521
|
+
assertRenderableEntry(profile, entry);
|
|
522
|
+
lines.push(sanitizeCrontabComment(`# ${entry.label} (${entry.id}): ${entry.argv.join(" ")}`));
|
|
442
523
|
lines.push(`${entry.cron} ${cliInvocation} schedule run ${entry.id} --profile ${profile} --trigger cron`);
|
|
443
524
|
}
|
|
444
525
|
lines.push(close);
|
package/docs/api.md
CHANGED
|
@@ -21,7 +21,7 @@ release.
|
|
|
21
21
|
- `GtmAuditRule` — `{ id, title, description, category?, evaluate(context) }`; the public extension point.
|
|
22
22
|
- `GtmRuleContext` — `{ snapshot, policy, index }` with the prebuilt O(n) `GtmSnapshotIndex`.
|
|
23
23
|
- `auditSnapshot(snapshot, policy?, rules?)` → `PatchPlan`.
|
|
24
|
-
- `builtinAuditRules` (
|
|
24
|
+
- `builtinAuditRules` (12 rules) plus each rule exported individually.
|
|
25
25
|
- **Determinism guarantee**: identical inputs produce identical findings and operations with identical ids (`auditFindingId`, `patchOperationId` are stable hashes of rule + record).
|
|
26
26
|
|
|
27
27
|
## Patch plans and application
|
|
@@ -62,7 +62,9 @@ Commands: `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `merge`, `p
|
|
|
62
62
|
`bulk-update`, `dedupe`, `reassign`, `fix`,
|
|
63
63
|
`market` (`init` / `capture` / `classify` / `worksheet` / `observe` / `fronts` /
|
|
64
64
|
`axes` / `overlay` / `scale` / `report` / `refresh`),
|
|
65
|
-
`enrich` (`append` / `refresh` / `ingest` / `status`),
|
|
65
|
+
`enrich` (`append` / `refresh` / `ingest` / `status`),
|
|
66
|
+
`schedule` (`add` / `list` / `remove` / `enable` / `disable` / `run` /
|
|
67
|
+
`install` / `uninstall` / `status`), `rules`, `profiles`, `doctor`.
|
|
66
68
|
Exit codes: `0` success · `1` error · `2` findings/regressions at the requested gate
|
|
67
69
|
(`--fail-on`, `--fail-on-new-findings`). `--json` everywhere; JSON output shapes are stable.
|
|
68
70
|
|
|
@@ -115,6 +117,30 @@ dependency-free CSV intake; the Apollo client (`createApolloClient`,
|
|
|
115
117
|
`pullApolloRecords`, 429-aware with `Retry-After`) is the first `api`-kind
|
|
116
118
|
source.
|
|
117
119
|
|
|
120
|
+
## Schedule
|
|
121
|
+
|
|
122
|
+
The horizontal scheduler: a declarative schedule-entry store, a
|
|
123
|
+
dependency-free 5-field cron parser, and the read/plan-side `SCHEDULABLE`
|
|
124
|
+
allowlist. `validateSchedulableArgv` enforces the allowlist at `schedule add`
|
|
125
|
+
time and re-checks it at run time (`tokenizeCommand` splits the quoted command
|
|
126
|
+
string — tokenization, never shell). `apply` is schedulable only as
|
|
127
|
+
`apply --plan-id <id>`, with the plan's `approved` status re-checked at every
|
|
128
|
+
firing — an unapproved plan records a `plan_not_approved` no-op run
|
|
129
|
+
(`ScheduleRunRecord.noopReason`) instead of executing.
|
|
130
|
+
|
|
131
|
+
- Entries: `ScheduleEntry` (`ScheduleProvider` is `"local"` for now),
|
|
132
|
+
`scheduleId`, `ScheduleStore` / `createFileScheduleStore`.
|
|
133
|
+
- Run history: `ScheduleRunRecord` (`ScheduleRunTrigger`: `cron` | `manual`),
|
|
134
|
+
`ScheduleRunStore` / `createFileScheduleRunStore`, with `schedulesPath` /
|
|
135
|
+
`scheduleRunsDir` for the profile-scoped file layout.
|
|
136
|
+
- Cron: `parseCron` → `CronExpression`, `cronMatches`, `nextCronFiring`,
|
|
137
|
+
`expectedFirings`, `computeMissedFirings` (status and missed-firing
|
|
138
|
+
visibility — local cron has no catch-up).
|
|
139
|
+
- Local provider: `schedule install` renders enabled entries into a
|
|
140
|
+
sentinel-managed crontab block — `crontabSentinels`, `renderManagedBlock`,
|
|
141
|
+
`replaceManagedBlock`, and `systemCrontabIo` behind the injectable
|
|
142
|
+
`CrontabIo` seam (tests never touch a real crontab).
|
|
143
|
+
|
|
118
144
|
## Market map
|
|
119
145
|
|
|
120
146
|
Newer surface (0.16–0.23); shapes are settling toward the 1.0 contract. A live
|
|
@@ -78,18 +78,21 @@ values win, **merges cannot be undone**, and a record stops merging after
|
|
|
78
78
|
250 cumulative merges. Salesforce merge is SOAP/Apex only (no REST), only
|
|
79
79
|
Lead/Contact/Account/Case, max 3 records per call.
|
|
80
80
|
|
|
81
|
-
**The gap:** our three duplicate rules
|
|
82
|
-
`duplicate-
|
|
83
|
-
|
|
81
|
+
**The gap (closed in 0.12):** our three duplicate rules
|
|
82
|
+
(`duplicate-account-domain`, `duplicate-contact-email`,
|
|
83
|
+
`duplicate-open-deal`) used to detect groups but emit only merge-review
|
|
84
|
+
*tasks* — detection without remediation.
|
|
84
85
|
|
|
85
|
-
**
|
|
86
|
+
**Shipped (0.12):** a `merge_records` operation type —
|
|
86
87
|
`requires_human_survivor_selection` placeholder, survivor heuristics in
|
|
87
88
|
`suggest` (ordered, evidence-based: most engagements → oldest → most
|
|
88
89
|
complete, each with a written reason), high risk, approval required, with
|
|
89
90
|
the irreversibility called out in the plan text. The dry-run plan is the
|
|
90
91
|
preview every commercial tool charges for; the pre-apply snapshot is the
|
|
91
92
|
loser-record archive. HubSpot first; Salesforce merge documented as
|
|
92
|
-
unsupported until an Apex path justifies itself.
|
|
93
|
+
unsupported until an Apex path justifies itself. 0.23 added `dedupe` as a
|
|
94
|
+
first-class verb over the same operation type (groups → one governed merge
|
|
95
|
+
per group, deterministic survivor).
|
|
93
96
|
|
|
94
97
|
## D — Delete/Archive: the exit ramp
|
|
95
98
|
|
|
@@ -131,5 +134,7 @@ Lessons from auditing our own apply path:
|
|
|
131
134
|
| 0.11.1 | Fix our own faucet: resolve-first `create:` + plan-scoped dedup, HubSpot association-aware CAS for `link_record`, domain normalization in `duplicate-account-domain`, `create_task` idempotency token |
|
|
132
135
|
| 0.12 (shipped) | `merge_records` (HubSpot contacts/companies/deals) + survivor suggestions capped at low confidence; the three duplicate rules emit governed merges instead of review tasks |
|
|
133
136
|
| 0.15 (shipped) | `resolve` gate (CLI/lib/MCP, gate exit codes), provenance capture (`hs_object_source*` → `RecordProvenance`) + attribution in duplicate findings, self-stamped creates |
|
|
134
|
-
| 0.16 | prevention-posture checks (native duplicate rules active? unique-value properties defined?)
|
|
137
|
+
| 0.16 (shipped the market map instead) | prevention-posture checks (native duplicate rules active? unique-value properties defined?) and live targeted resolve lookups were slated here but did not ship — they remain future work; 0.16 went to the market map layer |
|
|
138
|
+
| 0.23 (shipped) | `dedupe <object> --key <domain\|email\|name>` — the Remediate layer as a first-class verb: duplicate groups by normalized identity key, one governed `merge_records` per group, deterministic survivor (`richest`/`oldest`) |
|
|
139
|
+
| 0.24 (shipped) | schedule layer — recurring Detect: the nightly watch recipe becomes a declared cadence (`schedule add "audit --provider hubspot --save" --cron "0 2 * * *"`); read/plan-side allowlist only, scheduling never auto-approves |
|
|
135
140
|
| docs | The nightly watch recipe (existing flags, documented as CRM CI) |
|
package/docs/roadmap-to-1.0.md
CHANGED
|
@@ -106,6 +106,33 @@ The original thesis: GTM data disagrees across systems.
|
|
|
106
106
|
- Docs site with the operating-model registry as browsable reference.
|
|
107
107
|
- Performance pass: streaming snapshots for very large orgs.
|
|
108
108
|
|
|
109
|
+
## 0.10 → 0.25 — the layers, as shipped
|
|
110
|
+
|
|
111
|
+
The plan above ended at the freeze; what shipped next grew the surface
|
|
112
|
+
outward, one layer per release, each consolidating before the next expanded:
|
|
113
|
+
|
|
114
|
+
- **0.11** — the suggest chain: deterministic placeholder values with
|
|
115
|
+
confidence + reasons, `plans approve --values-from`.
|
|
116
|
+
- **0.12** — governed merge: `merge_records` (HubSpot contacts / companies /
|
|
117
|
+
deals), survivor suggestions capped at low confidence.
|
|
118
|
+
- **0.13–0.14** — call intelligence: `call parse|score|link|plan`, LLM
|
|
119
|
+
extraction behind the bring-your-own-key seam, deterministic baseline,
|
|
120
|
+
provenance-marked insights.
|
|
121
|
+
- **0.15** — the Prevent layer: the `resolve` create gate plus record-source
|
|
122
|
+
provenance and attribution.
|
|
123
|
+
- **0.16–0.22** — the market map: content-addressed captures, classification
|
|
124
|
+
with mechanical span verification, front states and drift, axis discovery,
|
|
125
|
+
overlay directives, scale estimation, the field report.
|
|
126
|
+
- **0.19 / 0.23** — governed write verbs (`bulk-update`, then `dedupe`,
|
|
127
|
+
`reassign`, `fix`) and the enrich layer (Apollo / Clay, fill-blanks-only
|
|
128
|
+
plans).
|
|
129
|
+
- **0.24** — the schedule layer: horizontal cron, read/plan-side allowlist,
|
|
130
|
+
scheduling never auto-approves.
|
|
131
|
+
- **0.25** — agent skill distribution (`npx skills add fullstackgtm/core`).
|
|
132
|
+
|
|
133
|
+
The known-gaps list below predates these layers and has been re-verified
|
|
134
|
+
against the 0.25 surface: still accurate, still open.
|
|
135
|
+
|
|
109
136
|
## Known real-portal gaps to close before 1.0
|
|
110
137
|
|
|
111
138
|
Found by exercising the published package as a fresh RevOps user with a real
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.2",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM",
|
|
@@ -48,7 +48,8 @@ Credentials resolve in order: `--token-env <NAME>` → ambient env
|
|
|
48
48
|
In a sandbox prefer the first two. LLM-powered verbs (`call parse`, `call
|
|
49
49
|
score`, `market classify`) take `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`, or use
|
|
50
50
|
their deterministic/worksheet fallbacks — the CLI never prompts when
|
|
51
|
-
non-interactive.
|
|
51
|
+
non-interactive. `--profile <name>` (or `FULLSTACKGTM_PROFILE`) scopes
|
|
52
|
+
credentials AND stored plans per client org.
|
|
52
53
|
|
|
53
54
|
## Verb map
|
|
54
55
|
|
|
@@ -61,9 +62,10 @@ non-interactive.
|
|
|
61
62
|
| `fix --rule <id>` | audit one rule → suggest → approve at the confidence bar → apply only with `--yes` |
|
|
62
63
|
| `call parse\|score\|link\|plan` | Transcripts → evidence-quoted insights, rubric scorecards, deal linking, governed next-step writes |
|
|
63
64
|
| `enrich append\|refresh\|ingest\|status` | Governed enrichment (Apollo pull / Clay ingest), fill-blanks-only plans |
|
|
64
|
-
| `market capture\|classify\|worksheet\|observe\|fronts\|axes\|overlay\|scale\|report\|refresh` | Competitive category map; evidence quotes verified verbatim against stored captures |
|
|
65
|
-
| `schedule add\|
|
|
66
|
-
| `
|
|
65
|
+
| `market init\|capture\|classify\|worksheet\|observe\|fronts\|axes\|overlay\|scale\|report\|refresh` | Competitive category map; evidence quotes verified verbatim against stored captures |
|
|
66
|
+
| `schedule add\|list\|remove\|enable\|disable\|run\|install\|uninstall\|status` | Horizontal cron; read/plan-side allowlist only — scheduling NEVER auto-approves |
|
|
67
|
+
| `report` | Client-ready audit deliverable (markdown or self-contained HTML) |
|
|
68
|
+
| `plans list\|show\|approve\|reject` / `snapshot` / `rules` / `doctor` | Plan lifecycle, raw snapshots, rule registry, machine state |
|
|
67
69
|
|
|
68
70
|
All write-shaped verbs produce plans; none writes outside approve → apply.
|
|
69
71
|
Add `--json` for machine-readable output on any command.
|
package/src/cli.ts
CHANGED
|
@@ -109,6 +109,8 @@ import {
|
|
|
109
109
|
parseCron,
|
|
110
110
|
renderManagedBlock,
|
|
111
111
|
replaceManagedBlock,
|
|
112
|
+
assertSingleLineLabel,
|
|
113
|
+
hasControlChar,
|
|
112
114
|
scheduleId,
|
|
113
115
|
systemCrontabIo,
|
|
114
116
|
tokenizeCommand,
|
|
@@ -1831,6 +1833,7 @@ trigger: manual. status shows next firing and surfaces missed firings
|
|
|
1831
1833
|
const label =
|
|
1832
1834
|
option(rest, "--label") ??
|
|
1833
1835
|
argv.filter((arg) => !arg.startsWith("--")).slice(0, 2).join("-").replace(/[^\w.-]+/g, "-");
|
|
1836
|
+
assertSingleLineLabel(label);
|
|
1834
1837
|
const entry: ScheduleEntry = {
|
|
1835
1838
|
id: scheduleId(label, cron.source, argv, createdAt),
|
|
1836
1839
|
label,
|
|
@@ -2052,12 +2055,26 @@ function scheduleCliInvocation(): string {
|
|
|
2052
2055
|
if (!script || !existsSync(script)) {
|
|
2053
2056
|
throw new Error("Cannot resolve the fullstackgtm entry point for crontab lines (process.argv[1] is missing).");
|
|
2054
2057
|
}
|
|
2058
|
+
// A newline/control char in any of these flows verbatim into the crontab
|
|
2059
|
+
// executable line; single-quote escaping defends the shell, not cron's line
|
|
2060
|
+
// parser. Refuse early with a clear message (renderManagedBlock re-checks).
|
|
2061
|
+
for (const [name, value] of [
|
|
2062
|
+
["FSGTM_HOME", process.env.FSGTM_HOME],
|
|
2063
|
+
["the node executable path", process.execPath],
|
|
2064
|
+
["the CLI script path", script],
|
|
2065
|
+
] as const) {
|
|
2066
|
+
if (value && hasControlChar(value)) {
|
|
2067
|
+
throw new Error(`Cannot install schedules: ${name} contains a newline or control character.`);
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2055
2070
|
const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
2056
2071
|
const parts = [quote(process.execPath)];
|
|
2057
2072
|
if (script.endsWith(".ts")) parts.push("--experimental-strip-types");
|
|
2058
2073
|
parts.push(quote(script));
|
|
2059
2074
|
const home = process.env.FSGTM_HOME ? `FSGTM_HOME=${quote(process.env.FSGTM_HOME)} ` : "";
|
|
2060
|
-
|
|
2075
|
+
// cron treats an unescaped `%` in the command field as a newline/stdin split.
|
|
2076
|
+
// Escape it as `\%` so a stray `%` in a path can't truncate the managed line.
|
|
2077
|
+
return (home + parts.join(" ")).replace(/%/g, "\\%");
|
|
2061
2078
|
}
|
|
2062
2079
|
|
|
2063
2080
|
/**
|
|
@@ -77,8 +77,11 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
77
77
|
throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
|
|
78
78
|
}
|
|
79
79
|
if (!response.ok) {
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
// Status line only — HubSpot 4xx bodies echo submitted property values
|
|
81
|
+
// (contact emails, company domains) and the request payload, and these
|
|
82
|
+
// errors are persisted into scheduled-run records. Never interpolate it.
|
|
83
|
+
await response.text().catch(() => undefined);
|
|
84
|
+
throw new Error(`HubSpot API error ${response.status}. Check the token scopes and request.`);
|
|
82
85
|
}
|
|
83
86
|
// DELETE and some association writes return 204 with an empty body.
|
|
84
87
|
const text = await response.text();
|
|
@@ -88,8 +88,10 @@ export function createSalesforceConnector(
|
|
|
88
88
|
);
|
|
89
89
|
}
|
|
90
90
|
if (!response.ok) {
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
// Status line only — the body echoes submitted field values and the
|
|
92
|
+
// request, and these errors are persisted into scheduled-run records.
|
|
93
|
+
await response.text().catch(() => undefined);
|
|
94
|
+
throw new Error(`Salesforce API error ${response.status}. Check the token and request.`);
|
|
93
95
|
}
|
|
94
96
|
// Salesforce PATCH returns 204 No Content on success.
|
|
95
97
|
const text = await response.text();
|
package/src/connectors/stripe.ts
CHANGED
|
@@ -46,8 +46,10 @@ export function createStripeConnector(options: StripeConnectorOptions): GtmConne
|
|
|
46
46
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
47
47
|
});
|
|
48
48
|
if (!response.ok) {
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// Status line only — the body can echo request details bound to a live
|
|
50
|
+
// billing key, and these errors land in scheduled-run records.
|
|
51
|
+
await response.text().catch(() => undefined);
|
|
52
|
+
throw new Error(`Stripe API error ${response.status}. Check the restricted key and request.`);
|
|
51
53
|
}
|
|
52
54
|
return response.json();
|
|
53
55
|
}
|
package/src/credentials.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
mkdirSync,
|
|
5
5
|
readdirSync,
|
|
6
6
|
readFileSync,
|
|
7
|
+
statSync,
|
|
7
8
|
unlinkSync,
|
|
8
9
|
writeFileSync,
|
|
9
10
|
} from "node:fs";
|
|
@@ -143,8 +144,31 @@ export function writeSecureFile(path: string, contents: string) {
|
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The 0600/0700 guarantee was write-only: a credentials.json inherited at
|
|
149
|
+
* looser permissions (a restored backup, a file created by another tool, a
|
|
150
|
+
* cloned home) was read and trusted regardless of its actual mode. Enforce the
|
|
151
|
+
* mode on read too — re-tighten to 0600 and warn once — so a world-readable
|
|
152
|
+
* credential store can't sit there silently leaking the token to other users.
|
|
153
|
+
*/
|
|
154
|
+
function enforceCredentialFileMode(path: string): void {
|
|
155
|
+
try {
|
|
156
|
+
const mode = statSync(path).mode & 0o777;
|
|
157
|
+
if ((mode & 0o077) !== 0) {
|
|
158
|
+
chmodSync(path, 0o600);
|
|
159
|
+
console.error(
|
|
160
|
+
`fullstackgtm: tightened ${path} from ${mode.toString(8).padStart(3, "0")} to 600 ` +
|
|
161
|
+
"(it was readable or writable by other users).",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Missing file or non-POSIX filesystem: nothing to enforce.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
146
169
|
function readFile(): CredentialsFile {
|
|
147
170
|
try {
|
|
171
|
+
enforceCredentialFileMode(credentialsPath());
|
|
148
172
|
const parsed = JSON.parse(readFileSync(credentialsPath(), "utf8"));
|
|
149
173
|
if (parsed && typeof parsed === "object" && parsed.version === 1 && parsed.providers) {
|
|
150
174
|
return parsed as CredentialsFile;
|
package/src/enrich.ts
CHANGED
|
@@ -394,6 +394,29 @@ function valueToString(value: unknown): string {
|
|
|
394
394
|
return "";
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
+
/**
|
|
398
|
+
* CSV/formula-injection neutralization for string values destined for a CRM
|
|
399
|
+
* write. Third-party export rows (Clay CSV, webhook JSON) can contain cells
|
|
400
|
+
* like `=cmd|'/c calc'!A1` or `@SUM(...)`; written verbatim to a CRM field they
|
|
401
|
+
* lie dormant until someone exports the CRM to CSV and opens it in a spreadsheet,
|
|
402
|
+
* where the leading `= + - @` (or a leading tab/CR) makes the client execute it.
|
|
403
|
+
* We prefix a single apostrophe — the spreadsheet-standard escape that renders
|
|
404
|
+
* the cell as literal text. Numeric values bypass this (they're written as
|
|
405
|
+
* numbers, not strings), so signed numbers keep full fidelity; a phone number
|
|
406
|
+
* supplied as a string and starting with `+` gains a leading `'`, which the
|
|
407
|
+
* human sees in the approved diff. Applied only at the write path, never to
|
|
408
|
+
* match keys.
|
|
409
|
+
*/
|
|
410
|
+
function neutralizeFormulaInjection(value: string): string {
|
|
411
|
+
if (value && /^[=+\-@\t\r]/.test(value)) return `'${value}`;
|
|
412
|
+
return value;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** valueToString for a value that will be written to a CRM field. */
|
|
416
|
+
function writeSafeString(value: unknown): string {
|
|
417
|
+
return neutralizeFormulaInjection(valueToString(value));
|
|
418
|
+
}
|
|
419
|
+
|
|
397
420
|
// ---------------------------------------------------------------------------
|
|
398
421
|
// Matching: ordered keys, unique-hit-wins, zero-hits-next-key,
|
|
399
422
|
// multi-hit → onAmbiguous. Ambiguity is surfaced, never resolved by coin flip.
|
|
@@ -708,7 +731,7 @@ export function buildEnrichPlan(options: BuildEnrichPlanOptions): EnrichPlanResu
|
|
|
708
731
|
operation: "set_field",
|
|
709
732
|
field: canonicalField,
|
|
710
733
|
beforeValue: currentValue ?? null,
|
|
711
|
-
afterValue: typeof sourceValue === "number" ? sourceValue :
|
|
734
|
+
afterValue: typeof sourceValue === "number" ? sourceValue : writeSafeString(sourceValue),
|
|
712
735
|
reason:
|
|
713
736
|
`${source} ${record.objectType} "${describeSourceRecord(record)}" (matched by ` +
|
|
714
737
|
`${outcome.matchedKey}) reports a changed value for ${canonicalField}.`,
|
|
@@ -726,7 +749,7 @@ export function buildEnrichPlan(options: BuildEnrichPlanOptions): EnrichPlanResu
|
|
|
726
749
|
if (isEmptyValue(sourceValue)) continue;
|
|
727
750
|
if (!isEmptyValue(currentValue)) continue;
|
|
728
751
|
emittedForRecord = true;
|
|
729
|
-
const afterValue = typeof sourceValue === "number" ? sourceValue :
|
|
752
|
+
const afterValue = typeof sourceValue === "number" ? sourceValue : writeSafeString(sourceValue);
|
|
730
753
|
operations.push({
|
|
731
754
|
id: `op_enr_${fnv1a(`${source}:${record.objectType}:${outcome.recordId}:${canonicalField}`)}`,
|
|
732
755
|
objectType: canonicalObjectType(record.objectType),
|
package/src/enrichApollo.ts
CHANGED
|
@@ -78,9 +78,12 @@ export function createApolloClient(options: ApolloClientOptions): ApolloClient {
|
|
|
78
78
|
}
|
|
79
79
|
if (response.status === 404) return null;
|
|
80
80
|
if (!response.ok) {
|
|
81
|
-
|
|
81
|
+
// Status line only — never interpolate the response body. It can echo
|
|
82
|
+
// the submitted query (contact emails / company domains) or the API key,
|
|
83
|
+
// and these errors are persisted verbatim into scheduled-run records.
|
|
84
|
+
await response.text().catch(() => undefined);
|
|
82
85
|
const exhausted = response.status === 429 ? ` (rate limited; ${maxRetries} retries exhausted)` : "";
|
|
83
|
-
throw new Error(`Apollo API error ${response.status}${exhausted}
|
|
86
|
+
throw new Error(`Apollo API error ${response.status}${exhausted}. Check the API key and request.`);
|
|
84
87
|
}
|
|
85
88
|
const text = await response.text();
|
|
86
89
|
return text ? (JSON.parse(text) as Record<string, unknown>) : null;
|