fullstackgtm 0.44.0 → 0.46.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.
Files changed (149) hide show
  1. package/CHANGELOG.md +293 -1
  2. package/INSTALL_FOR_AGENTS.md +13 -11
  3. package/README.md +45 -24
  4. package/dist/audit.d.ts +3 -1
  5. package/dist/audit.js +29 -2
  6. package/dist/backfill.d.ts +86 -0
  7. package/dist/backfill.js +251 -0
  8. package/dist/bin.js +4 -1
  9. package/dist/cli/audit.d.ts +15 -0
  10. package/dist/cli/audit.js +392 -0
  11. package/dist/cli/auth.d.ts +82 -0
  12. package/dist/cli/auth.js +607 -0
  13. package/dist/cli/backfill.d.ts +1 -0
  14. package/dist/cli/backfill.js +125 -0
  15. package/dist/cli/backfillRuns.d.ts +1 -0
  16. package/dist/cli/backfillRuns.js +187 -0
  17. package/dist/cli/call.d.ts +1 -0
  18. package/dist/cli/call.js +387 -0
  19. package/dist/cli/capabilities.d.ts +30 -0
  20. package/dist/cli/capabilities.js +197 -0
  21. package/dist/cli/draft.d.ts +7 -0
  22. package/dist/cli/draft.js +87 -0
  23. package/dist/cli/enrich.d.ts +8 -0
  24. package/dist/cli/enrich.js +806 -0
  25. package/dist/cli/fix.d.ts +33 -0
  26. package/dist/cli/fix.js +346 -0
  27. package/dist/cli/help.d.ts +25 -0
  28. package/dist/cli/help.js +646 -0
  29. package/dist/cli/icp.d.ts +7 -0
  30. package/dist/cli/icp.js +229 -0
  31. package/dist/cli/init.d.ts +7 -0
  32. package/dist/cli/init.js +58 -0
  33. package/dist/cli/market.d.ts +1 -0
  34. package/dist/cli/market.js +391 -0
  35. package/dist/cli/plans.d.ts +5 -0
  36. package/dist/cli/plans.js +456 -0
  37. package/dist/cli/schedule.d.ts +8 -0
  38. package/dist/cli/schedule.js +479 -0
  39. package/dist/cli/shared.d.ts +71 -0
  40. package/dist/cli/shared.js +342 -0
  41. package/dist/cli/signals.d.ts +7 -0
  42. package/dist/cli/signals.js +412 -0
  43. package/dist/cli/suggest.d.ts +49 -0
  44. package/dist/cli/suggest.js +135 -0
  45. package/dist/cli/tam.d.ts +9 -0
  46. package/dist/cli/tam.js +387 -0
  47. package/dist/cli/ui.d.ts +142 -0
  48. package/dist/cli/ui.js +427 -0
  49. package/dist/cli.d.ts +1 -57
  50. package/dist/cli.js +123 -5157
  51. package/dist/connector.d.ts +23 -0
  52. package/dist/connector.js +47 -0
  53. package/dist/connectors/hubspot.d.ts +10 -1
  54. package/dist/connectors/hubspot.js +244 -10
  55. package/dist/connectors/hubspotAuth.js +5 -1
  56. package/dist/connectors/linkedin.js +14 -14
  57. package/dist/connectors/salesforce.d.ts +10 -1
  58. package/dist/connectors/salesforce.js +39 -6
  59. package/dist/connectors/signalSources.js +7 -59
  60. package/dist/connectors/stripe.d.ts +37 -0
  61. package/dist/connectors/stripe.js +103 -31
  62. package/dist/enrich.d.ts +7 -0
  63. package/dist/enrich.js +7 -0
  64. package/dist/health.d.ts +11 -69
  65. package/dist/health.js +4 -134
  66. package/dist/healthScore.d.ts +71 -0
  67. package/dist/healthScore.js +143 -0
  68. package/dist/icp.d.ts +15 -11
  69. package/dist/icp.js +19 -36
  70. package/dist/index.d.ts +3 -1
  71. package/dist/index.js +3 -1
  72. package/dist/judge.d.ts +2 -0
  73. package/dist/judge.js +6 -0
  74. package/dist/llm.d.ts +29 -0
  75. package/dist/llm.js +206 -0
  76. package/dist/market.d.ts +39 -1
  77. package/dist/market.js +116 -44
  78. package/dist/marketClassify.d.ts +11 -1
  79. package/dist/marketClassify.js +17 -2
  80. package/dist/marketTaxonomy.d.ts +6 -1
  81. package/dist/marketTaxonomy.js +4 -2
  82. package/dist/mcp-bin.js +31 -2
  83. package/dist/mcp.js +372 -166
  84. package/dist/progress.d.ts +96 -0
  85. package/dist/progress.js +142 -0
  86. package/dist/runReport.d.ts +24 -0
  87. package/dist/runReport.js +139 -4
  88. package/dist/schedule.d.ts +80 -2
  89. package/dist/schedule.js +254 -1
  90. package/dist/spoolFiles.d.ts +44 -0
  91. package/dist/spoolFiles.js +114 -0
  92. package/dist/types.d.ts +29 -1
  93. package/docs/api.md +75 -8
  94. package/docs/architecture.md +2 -0
  95. package/docs/linkedin-connector-spec.md +1 -1
  96. package/docs/signal-spool-format.md +13 -0
  97. package/llms.txt +18 -9
  98. package/package.json +10 -3
  99. package/skills/fullstackgtm/SKILL.md +2 -1
  100. package/src/audit.ts +27 -1
  101. package/src/backfill.ts +340 -0
  102. package/src/bin.ts +5 -1
  103. package/src/cli/audit.ts +447 -0
  104. package/src/cli/auth.ts +669 -0
  105. package/src/cli/backfill.ts +156 -0
  106. package/src/cli/backfillRuns.ts +198 -0
  107. package/src/cli/call.ts +414 -0
  108. package/src/cli/capabilities.ts +215 -0
  109. package/src/cli/draft.ts +101 -0
  110. package/src/cli/enrich.ts +908 -0
  111. package/src/cli/fix.ts +373 -0
  112. package/src/cli/help.ts +702 -0
  113. package/src/cli/icp.ts +265 -0
  114. package/src/cli/init.ts +65 -0
  115. package/src/cli/market.ts +423 -0
  116. package/src/cli/plans.ts +524 -0
  117. package/src/cli/schedule.ts +526 -0
  118. package/src/cli/shared.ts +392 -0
  119. package/src/cli/signals.ts +434 -0
  120. package/src/cli/suggest.ts +151 -0
  121. package/src/cli/tam.ts +435 -0
  122. package/src/cli/ui.ts +497 -0
  123. package/src/cli.ts +122 -5855
  124. package/src/connector.ts +66 -0
  125. package/src/connectors/hubspot.ts +273 -9
  126. package/src/connectors/hubspotAuth.ts +5 -1
  127. package/src/connectors/linkedin.ts +14 -14
  128. package/src/connectors/salesforce.ts +51 -3
  129. package/src/connectors/signalSources.ts +7 -56
  130. package/src/connectors/stripe.ts +154 -34
  131. package/src/enrich.ts +13 -0
  132. package/src/health.ts +14 -213
  133. package/src/healthScore.ts +223 -0
  134. package/src/icp.ts +19 -35
  135. package/src/index.ts +28 -1
  136. package/src/judge.ts +7 -0
  137. package/src/llm.ts +239 -0
  138. package/src/market.ts +157 -44
  139. package/src/marketClassify.ts +26 -2
  140. package/src/marketTaxonomy.ts +10 -2
  141. package/src/mcp-bin.ts +34 -2
  142. package/src/mcp.ts +270 -63
  143. package/src/progress.ts +197 -0
  144. package/src/runReport.ts +159 -4
  145. package/src/schedule.ts +310 -3
  146. package/src/spoolFiles.ts +116 -0
  147. package/src/types.ts +32 -2
  148. package/docs/dx-punch-list.md +0 -87
  149. package/docs/roadmap-to-1.0.md +0 -211
package/dist/schedule.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { mkdirSync, readdirSync, readFileSync } from "node:fs";
2
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.js";
5
6
  // Mirrors stableHash in rules.ts (FNV-1a); duplicated to keep schedule.ts
@@ -592,3 +593,255 @@ export function replaceManagedBlock(existing, profile, block) {
592
593
  return "";
593
594
  return `${merged.join("\n")}\n`;
594
595
  }
596
+ /**
597
+ * Compile a cron expression to StartCalendarInterval dicts. Full-range fields
598
+ * (`*`, but also spellings like `0-59`) are omitted (launchd wildcard); the
599
+ * rest cross-product, capped so a dense expression cannot render a megabyte
600
+ * plist. Vixie day semantics: when day-of-month AND day-of-week are both
601
+ * restricted, either may match — launchd ANDs keys within one dict, so that
602
+ * becomes the union of a Day dict set and a Weekday dict set. A date matching
603
+ * both sets makes launchd trigger twice in the same minute; the run entry
604
+ * point drops the second via isDuplicateCronFiring.
605
+ */
606
+ export function cronToCalendarIntervals(cron, cap = 512) {
607
+ const full = (values, min, max) => values.length === max - min + 1;
608
+ const minutes = full(cron.minute, 0, 59) ? [undefined] : cron.minute;
609
+ const hours = full(cron.hour, 0, 23) ? [undefined] : cron.hour;
610
+ const months = full(cron.month, 1, 12) ? [undefined] : cron.month;
611
+ const domRestricted = cron.dayOfMonthRestricted && !full(cron.dayOfMonth, 1, 31);
612
+ const dowRestricted = cron.dayOfWeekRestricted && !full(cron.dayOfWeek, 0, 6);
613
+ const daySlots = [];
614
+ if (domRestricted)
615
+ for (const day of cron.dayOfMonth)
616
+ daySlots.push({ Day: day });
617
+ if (dowRestricted)
618
+ for (const weekday of cron.dayOfWeek)
619
+ daySlots.push({ Weekday: weekday });
620
+ if (daySlots.length === 0)
621
+ daySlots.push({});
622
+ const count = minutes.length * hours.length * daySlots.length * months.length;
623
+ if (count > cap) {
624
+ throw new Error(`Cron expression "${cron.source}" expands to ${count} launchd calendar intervals (cap ${cap}). ` +
625
+ "Simplify the expression or split it into separate schedules.");
626
+ }
627
+ const intervals = [];
628
+ for (const month of months) {
629
+ for (const daySlot of daySlots) {
630
+ for (const hour of hours) {
631
+ for (const minute of minutes) {
632
+ const dict = {};
633
+ if (minute !== undefined)
634
+ dict.Minute = minute;
635
+ if (hour !== undefined)
636
+ dict.Hour = hour;
637
+ if (daySlot.Day !== undefined)
638
+ dict.Day = daySlot.Day;
639
+ if (daySlot.Weekday !== undefined)
640
+ dict.Weekday = daySlot.Weekday;
641
+ if (month !== undefined)
642
+ dict.Month = month;
643
+ intervals.push(dict);
644
+ }
645
+ }
646
+ }
647
+ }
648
+ return intervals;
649
+ }
650
+ /**
651
+ * True when a cron-triggered run was already recorded in `firedAt`'s minute.
652
+ * The `schedule run` entry point checks this for trigger:cron so a launchd
653
+ * double-trigger (dom+dow OR corner above) records a duplicate_firing no-op
654
+ * instead of running the command twice. Manual runs are never deduplicated.
655
+ */
656
+ export function isDuplicateCronFiring(runs, firedAt) {
657
+ const minute = new Date(firedAt);
658
+ minute.setSeconds(0, 0);
659
+ return runs.some((run) => {
660
+ if (run.trigger !== "cron")
661
+ return false;
662
+ const fired = new Date(run.firedAt);
663
+ fired.setSeconds(0, 0);
664
+ return fired.getTime() === minute.getTime();
665
+ });
666
+ }
667
+ /** Reverse-DNS namespace for one profile's agents; install/uninstall own this prefix wholesale. */
668
+ export function launchdAgentPrefix(profile) {
669
+ if (!/^[A-Za-z0-9._-]+$/.test(profile)) {
670
+ throw new Error(`Cannot build a launchd label from profile "${profile}" — profile names in LaunchAgent labels ` +
671
+ "must be alphanumeric with dot/dash/underscore.");
672
+ }
673
+ return `com.fullstackgtm.${profile}.`;
674
+ }
675
+ export function launchdLabel(profile, scheduleEntryId) {
676
+ if (!/^[A-Za-z0-9._-]+$/.test(scheduleEntryId)) {
677
+ throw new Error(`Cannot build a launchd label from schedule id "${scheduleEntryId}".`);
678
+ }
679
+ return `${launchdAgentPrefix(profile)}${scheduleEntryId}`;
680
+ }
681
+ function xmlEscape(value) {
682
+ return value.replace(/[&<>"']/g, (char) => {
683
+ switch (char) {
684
+ case "&":
685
+ return "&amp;";
686
+ case "<":
687
+ return "&lt;";
688
+ case ">":
689
+ return "&gt;";
690
+ case '"':
691
+ return "&quot;";
692
+ default:
693
+ return "&apos;";
694
+ }
695
+ });
696
+ }
697
+ /**
698
+ * Render one LaunchAgent plist. ProgramArguments is an argv array — launchd
699
+ * execs it directly, no shell, so there is no quoting/injection surface at
700
+ * all (values are XML-escaped for the document, nothing more).
701
+ */
702
+ export function renderLaunchdPlist(options) {
703
+ const lines = [
704
+ '<?xml version="1.0" encoding="UTF-8"?>',
705
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
706
+ '<plist version="1.0">',
707
+ "<dict>",
708
+ "\t<key>Label</key>",
709
+ `\t<string>${xmlEscape(options.label)}</string>`,
710
+ "\t<key>ProgramArguments</key>",
711
+ "\t<array>",
712
+ ...options.programArguments.map((arg) => `\t\t<string>${xmlEscape(arg)}</string>`),
713
+ "\t</array>",
714
+ "\t<key>StartCalendarInterval</key>",
715
+ "\t<array>",
716
+ ];
717
+ const keyOrder = ["Minute", "Hour", "Day", "Weekday", "Month"];
718
+ for (const interval of options.calendarIntervals) {
719
+ lines.push("\t\t<dict>");
720
+ for (const key of keyOrder) {
721
+ const value = interval[key];
722
+ if (value === undefined)
723
+ continue;
724
+ lines.push(`\t\t\t<key>${key}</key>`, `\t\t\t<integer>${value}</integer>`);
725
+ }
726
+ lines.push("\t\t</dict>");
727
+ }
728
+ lines.push("\t</array>");
729
+ if (options.environment && Object.keys(options.environment).length > 0) {
730
+ lines.push("\t<key>EnvironmentVariables</key>", "\t<dict>");
731
+ for (const [name, value] of Object.entries(options.environment)) {
732
+ lines.push(`\t\t<key>${xmlEscape(name)}</key>`, `\t\t<string>${xmlEscape(value)}</string>`);
733
+ }
734
+ lines.push("\t</dict>");
735
+ }
736
+ if (options.standardOutPath) {
737
+ lines.push("\t<key>StandardOutPath</key>", `\t<string>${xmlEscape(options.standardOutPath)}</string>`);
738
+ }
739
+ if (options.standardErrorPath) {
740
+ lines.push("\t<key>StandardErrorPath</key>", `\t<string>${xmlEscape(options.standardErrorPath)}</string>`);
741
+ }
742
+ lines.push("</dict>", "</plist>");
743
+ return `${lines.join("\n")}\n`;
744
+ }
745
+ /**
746
+ * The real ~/Library/LaunchAgents + launchctl. Everything above takes a
747
+ * LaunchdIo so tests inject fakes and never touch the user's agents.
748
+ */
749
+ export function systemLaunchdIo(agentsDirOverride) {
750
+ const agentsDir = agentsDirOverride ?? join(homedir(), "Library", "LaunchAgents");
751
+ const domain = () => {
752
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
753
+ if (uid === null)
754
+ throw new Error("launchd scheduling requires a POSIX user id (macOS only).");
755
+ return `gui/${uid}`;
756
+ };
757
+ const launchctl = (args) => {
758
+ const result = spawnSync("launchctl", args, { encoding: "utf8" });
759
+ if (result.error)
760
+ throw new Error(`Cannot run \`launchctl\`: ${result.error.message}`);
761
+ return result;
762
+ };
763
+ return {
764
+ list() {
765
+ try {
766
+ return readdirSync(agentsDir).filter((name) => name.endsWith(".plist"));
767
+ }
768
+ catch {
769
+ return [];
770
+ }
771
+ },
772
+ write(fileName, content) {
773
+ mkdirSync(agentsDir, { recursive: true });
774
+ // 0644 like every LaunchAgent: launchd refuses group/world-WRITABLE
775
+ // plists, and this file holds a dispatch line, not a secret.
776
+ writeFileSync(join(agentsDir, fileName), content, { mode: 0o644 });
777
+ },
778
+ remove(fileName) {
779
+ rmSync(join(agentsDir, fileName), { force: true });
780
+ },
781
+ bootstrap(fileName) {
782
+ const result = launchctl(["bootstrap", domain(), join(agentsDir, fileName)]);
783
+ if (result.status !== 0) {
784
+ throw new Error(`\`launchctl bootstrap\` failed for ${fileName}: ${(result.stderr ?? "").trim() || `exit ${result.status}`}`);
785
+ }
786
+ },
787
+ bootout(label) {
788
+ launchctl(["bootout", `${domain()}/${label}`]);
789
+ },
790
+ };
791
+ }
792
+ /**
793
+ * Materialize enabled entries as one LaunchAgent each, replacing the
794
+ * profile's com.fullstackgtm.<profile>.* fleet wholesale (stale agents are
795
+ * booted out and deleted; plists outside the prefix are never touched).
796
+ * `cliArgv` is the argv-array analog of the crontab invocation line.
797
+ */
798
+ export function installLaunchdAgents(profile, entries, cliArgv, io, options = {}) {
799
+ for (const token of cliArgv) {
800
+ if (hasControlChar(token)) {
801
+ throw new Error("Refusing to render LaunchAgents: the resolved CLI invocation (node path or script path) " +
802
+ "contains a newline or control character.");
803
+ }
804
+ }
805
+ const removed = uninstallLaunchdAgents(profile, io);
806
+ const installed = [];
807
+ for (const entry of entries) {
808
+ assertRenderableEntry(profile, entry);
809
+ let intervals;
810
+ try {
811
+ intervals = cronToCalendarIntervals(parseCron(entry.cron));
812
+ }
813
+ catch (error) {
814
+ throw new Error(`Schedule ${entry.id} ("${entry.label}") cannot materialize as a LaunchAgent: ` +
815
+ `${error instanceof Error ? error.message : String(error)}`);
816
+ }
817
+ const label = launchdLabel(profile, entry.id);
818
+ const logPath = options.logDir ? join(options.logDir, `${label}.log`) : undefined;
819
+ const plist = renderLaunchdPlist({
820
+ label,
821
+ programArguments: [...cliArgv, "schedule", "run", entry.id, "--profile", profile, "--trigger", "cron"],
822
+ calendarIntervals: intervals,
823
+ environment: options.environment,
824
+ standardOutPath: logPath,
825
+ standardErrorPath: logPath,
826
+ });
827
+ const fileName = `${label}.plist`;
828
+ io.write(fileName, plist);
829
+ io.bootstrap(fileName);
830
+ installed.push(label);
831
+ }
832
+ return { installed, removed };
833
+ }
834
+ /** Boot out and delete every agent in the profile's prefix; returns the removed labels. */
835
+ export function uninstallLaunchdAgents(profile, io) {
836
+ const prefix = launchdAgentPrefix(profile);
837
+ const removed = [];
838
+ for (const name of io.list()) {
839
+ if (!name.startsWith(prefix))
840
+ continue;
841
+ const label = name.slice(0, -".plist".length);
842
+ io.bootout(label);
843
+ io.remove(name);
844
+ removed.push(label);
845
+ }
846
+ return removed;
847
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared reader for the spool container convention (docs/signal-spool-format.md):
3
+ * newline-delimited JSON — one object per line, append-only — with a JSON array
4
+ * accepted in a `.json` file for hand-staged convenience, and a DIRECTORY
5
+ * meaning "every `*.jsonl` / `*.json` file in it, name-sorted" so multiple
6
+ * receivers can each append their own file.
7
+ *
8
+ * The container is the convention; the row SCHEMA stays with each caller:
9
+ * `signals fetch --from` validates staged signal rows, `enrich ingest` stages
10
+ * enrichment rows, `market observe --from` validates observation-set envelopes.
11
+ * This module only parses objects out of files — it never interprets them.
12
+ *
13
+ * Extracted from the `file` signal-source connector (the Phase-2 webhook
14
+ * landing-zone reader), which now delegates here; error-message text is
15
+ * parameterized by `label` so existing connector errors are unchanged.
16
+ */
17
+ /** Spool files in a landing-zone directory: `*.jsonl` / `*.json`, name-sorted. */
18
+ export declare function spoolFilesIn(dir: string): string[];
19
+ /** Parse a JSON-array spool file into rows. `label` prefixes error messages. */
20
+ export declare function parseSpoolArray(raw: string, path: string, label: string): Record<string, unknown>[];
21
+ /** Parse a JSONL spool file into rows. `label` prefixes error messages. */
22
+ export declare function parseSpoolJsonl(raw: string, path: string, label: string): Record<string, unknown>[];
23
+ /** Parse one spool file's text — JSON array if it opens with `[`, else JSONL. */
24
+ export declare function parseSpoolText(raw: string, path: string, label: string): Record<string, unknown>[];
25
+ /**
26
+ * Should an explicitly named intake path be read with spool semantics?
27
+ * True for a directory (a landing zone) or a `*.jsonl` file. Plain `.json` /
28
+ * `.csv` files keep each verb's original single-file handling, so existing
29
+ * invocations parse exactly as before.
30
+ */
31
+ export declare function isSpoolPath(absPath: string): boolean;
32
+ /** A parsed spool row plus where it came from (for error labels). */
33
+ export type SpoolRow = {
34
+ file: string;
35
+ index: number;
36
+ row: Record<string, unknown>;
37
+ };
38
+ /**
39
+ * Read an explicitly named spool path — a `.jsonl` file or a directory of
40
+ * `*.jsonl` / `*.json` files. Unlike the webhook `file` connector (where a
41
+ * missing spool is an empty source), the user named this path: a missing or
42
+ * unreadable file is an error, and a malformed row throws with file + line.
43
+ */
44
+ export declare function readSpoolPath(absPath: string, label: string): SpoolRow[];
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Shared reader for the spool container convention (docs/signal-spool-format.md):
3
+ * newline-delimited JSON — one object per line, append-only — with a JSON array
4
+ * accepted in a `.json` file for hand-staged convenience, and a DIRECTORY
5
+ * meaning "every `*.jsonl` / `*.json` file in it, name-sorted" so multiple
6
+ * receivers can each append their own file.
7
+ *
8
+ * The container is the convention; the row SCHEMA stays with each caller:
9
+ * `signals fetch --from` validates staged signal rows, `enrich ingest` stages
10
+ * enrichment rows, `market observe --from` validates observation-set envelopes.
11
+ * This module only parses objects out of files — it never interprets them.
12
+ *
13
+ * Extracted from the `file` signal-source connector (the Phase-2 webhook
14
+ * landing-zone reader), which now delegates here; error-message text is
15
+ * parameterized by `label` so existing connector errors are unchanged.
16
+ */
17
+ import { readFileSync, readdirSync, statSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ /** Spool files in a landing-zone directory: `*.jsonl` / `*.json`, name-sorted. */
20
+ export function spoolFilesIn(dir) {
21
+ let names;
22
+ try {
23
+ names = readdirSync(dir);
24
+ }
25
+ catch {
26
+ return [];
27
+ }
28
+ return names
29
+ .filter((name) => name.endsWith(".jsonl") || name.endsWith(".json"))
30
+ .sort()
31
+ .map((name) => join(dir, name));
32
+ }
33
+ /** Parse a JSON-array spool file into rows. `label` prefixes error messages. */
34
+ export function parseSpoolArray(raw, path, label) {
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(raw);
38
+ }
39
+ catch (error) {
40
+ throw new Error(`${label} ${path}: not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
41
+ }
42
+ if (!Array.isArray(parsed))
43
+ throw new Error(`${label} ${path}: expected a JSON array of staged signal rows.`);
44
+ return parsed.map((entry, index) => {
45
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
46
+ throw new Error(`${label} ${path}: row ${index} is not an object.`);
47
+ }
48
+ return entry;
49
+ });
50
+ }
51
+ /** Parse a JSONL spool file into rows. `label` prefixes error messages. */
52
+ export function parseSpoolJsonl(raw, path, label) {
53
+ const out = [];
54
+ const lines = raw.split("\n");
55
+ lines.forEach((line, index) => {
56
+ const t = line.trim();
57
+ if (!t)
58
+ return;
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(t);
62
+ }
63
+ catch (error) {
64
+ throw new Error(`${label} ${path}: line ${index} is not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
65
+ }
66
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
67
+ throw new Error(`${label} ${path}: line ${index} is not a JSON object.`);
68
+ }
69
+ out.push(parsed);
70
+ });
71
+ return out;
72
+ }
73
+ /** Parse one spool file's text — JSON array if it opens with `[`, else JSONL. */
74
+ export function parseSpoolText(raw, path, label) {
75
+ const trimmed = raw.trim();
76
+ if (!trimmed)
77
+ return [];
78
+ return trimmed.startsWith("[") ? parseSpoolArray(trimmed, path, label) : parseSpoolJsonl(trimmed, path, label);
79
+ }
80
+ /**
81
+ * Should an explicitly named intake path be read with spool semantics?
82
+ * True for a directory (a landing zone) or a `*.jsonl` file. Plain `.json` /
83
+ * `.csv` files keep each verb's original single-file handling, so existing
84
+ * invocations parse exactly as before.
85
+ */
86
+ export function isSpoolPath(absPath) {
87
+ try {
88
+ if (statSync(absPath).isDirectory())
89
+ return true;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ return absPath.endsWith(".jsonl");
95
+ }
96
+ /**
97
+ * Read an explicitly named spool path — a `.jsonl` file or a directory of
98
+ * `*.jsonl` / `*.json` files. Unlike the webhook `file` connector (where a
99
+ * missing spool is an empty source), the user named this path: a missing or
100
+ * unreadable file is an error, and a malformed row throws with file + line.
101
+ */
102
+ export function readSpoolPath(absPath, label) {
103
+ const isDir = statSync(absPath).isDirectory();
104
+ const files = isDir ? spoolFilesIn(absPath) : [absPath];
105
+ if (isDir && files.length === 0) {
106
+ throw new Error(`${label} ${absPath}: directory contains no *.jsonl / *.json spool files.`);
107
+ }
108
+ const rows = [];
109
+ for (const file of files) {
110
+ const raw = readFileSync(file, "utf8");
111
+ parseSpoolText(raw, file, label).forEach((row, index) => rows.push({ file, index, row }));
112
+ }
113
+ return rows;
114
+ }
package/dist/types.d.ts CHANGED
@@ -15,7 +15,8 @@ export type PatchOperationType = "set_field" | "clear_field" | "link_record" | "
15
15
  * The afterValue of a `create_record` operation. The connector re-resolves on
16
16
  * `matchKey`/`matchValue` at apply time and creates only on a confirmed miss.
17
17
  * `estCostUsd` is the acquire meter's per-record charge, recorded against the
18
- * budget on a successful create.
18
+ * budget on a successful create. Emitted by `enrich acquire` (metered) and
19
+ * `backfill stripe` (closed-won deals from paid invoices, unmetered).
19
20
  */
20
21
  export type CreateRecordPayload = {
21
22
  properties: Record<string, string>;
@@ -39,6 +40,22 @@ export type CreateRecordPayload = {
39
40
  ownerId?: string;
40
41
  /** Audit label for how the owner was chosen (e.g. "fixed", "territory:0"). */
41
42
  assignedBy?: string;
43
+ /**
44
+ * Deal creates only: provider-neutral stage sentinel. "closed_won" tells the
45
+ * connector to resolve the target pipeline's REAL closed-won stage id from
46
+ * the provider's pipeline metadata at apply time (HubSpot: stage with
47
+ * `metadata.isClosed` true and probability 1) — never by substring-guessing
48
+ * a stage name. If no such stage is resolvable the operation is skipped.
49
+ * Plain deal properties (amount, closedate, dealname, …) travel in
50
+ * `properties` as provider property names, like every other create.
51
+ */
52
+ dealStage?: "closed_won";
53
+ /**
54
+ * Deal creates only: which pipeline to create the deal in — a pipeline id
55
+ * or a case-insensitive pipeline label. Absent = the portal's default
56
+ * pipeline (lowest displayOrder).
57
+ */
58
+ dealPipeline?: string;
42
59
  };
43
60
  export type AuditFindingSeverity = "info" | "warning" | "critical";
44
61
  /**
@@ -387,6 +404,17 @@ export type PatchPlanRun = {
387
404
  status: PatchPlanRunStatus;
388
405
  results: PatchOperationResult[];
389
406
  };
407
+ /**
408
+ * Per-page progress during a snapshot pull, emitted by connectors that accept
409
+ * an `onProgress` option. `fetched` is the running total for the object type
410
+ * currently streaming (it resets when the pull moves on to the next type).
411
+ * Presentation-only: a throwing callback must never fail a pull, so emitters
412
+ * call it best-effort.
413
+ */
414
+ export type SnapshotProgress = {
415
+ objectType: "user" | "account" | "contact" | "deal";
416
+ fetched: number;
417
+ };
390
418
  /**
391
419
  * The provider contract. Reads produce a canonical snapshot; writes accept a
392
420
  * single patch operation and report the outcome. Connectors without
package/docs/api.md CHANGED
@@ -47,9 +47,10 @@ release.
47
47
  for any op that isn't batch-safe (grouped, value-overridden, company-
48
48
  associated, conflicted). HubSpot: `search IN` + `batch/create`; Salesforce:
49
49
  SOQL `IN` + Composite sObject Collections (`allOrNone:false`).
50
- - `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `create_record` (resolve-first net-new contact/company create — re-checks the dedupe key at apply, never double-creates), `archive_record`, `merge_records` (HubSpot v3 merge — pairwise, irreversible; survivor must belong to the duplicate group). (The Salesforce connector implements the same operation set, with the platform-specific constraints noted below.)
50
+ - `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `create_record` (resolve-first net-new contact/company/**deal** create — re-checks the dedupe key at apply, never double-creates; a deal create requires `dealStage: "closed_won"`, resolved to the target pipeline's real closed-won stage id from pipeline metadata, and its dedupe key is a custom deal property ensured on demand), `archive_record`, `merge_records` (HubSpot v3 merge — pairwise, irreversible; survivor must belong to the duplicate group). (The Salesforce connector implements the same operation set, with the platform-specific constraints noted below.)
51
51
  - `createSalesforceConnector(options)` — read/write/readField/fetchChanges; probabilities normalized to 0..1. `applyOperation` implements every operation type, with two platform constraints: `merge_records` covers **Accounts and Contacts** only (Salesforce exposes no Opportunity merge), and `create_record` resolve-first creates **contacts/accounts** (SOQL search on the match key, create only on a confirmed miss; stamps the canonical `ownerId` onto `OwnerId`).
52
52
  - `createStripeConnector(options)` — read-only billing by design (`applyOperation` returns `skipped`); email domains are the cross-system merge keys. Implements `fetchChanges` (incremental via `created[gte]`).
53
+ - `fetchStripePaidInvoices(options, { sinceIso? })` — paginated `status=paid` invoice reads (`created[gte]` incremental, cents→major, paid-date extraction); feeds `buildStripeBackfillPlan(invoices, snapshot, opts)` (`src/backfill.ts`), which proposes one closed-won deal `create_record` per paid invoice, matched to CRM accounts by billing-email domain then exact name — an unmatched customer gets an explicit proposed account `create_record` in the same plan (freemail domains never used as company domain; `createMissingAccounts: false` restores report-only). Surfaced as the `backfill stripe` verb (plan-only; `--save` → approve → `apply`).
53
54
 
54
55
  ## Multi-system operations
55
56
 
@@ -75,10 +76,41 @@ Commands: `init`, `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `me
75
76
  `icp` (`interview` / `set` / `show` / `judge` / `eval`),
76
77
  `signals` (`fetch` / `list` / `outcome` / `weights`), `draft`,
77
78
  `schedule` (`add` / `list` / `remove` / `enable` / `disable` / `run` /
78
- `install` / `uninstall` / `status`), `rules`, `profiles`, `doctor`.
79
+ `install` / `uninstall` / `status`), `rules`, `profiles`, `doctor`,
80
+ `capabilities`, `robot-docs`.
79
81
  Exit codes: `0` success · `1` error · `2` findings/regressions at the requested gate
80
82
  (`--fail-on`, `--fail-on-new-findings`). `--json` everywhere; JSON output shapes are stable.
81
83
 
84
+ Agent discovery: `capabilities` prints the machine-readable CLI contract —
85
+ the command inventory with read-only vs write-shaped access (derived from the
86
+ help table, not a parallel list), this exit-code contract, and safety
87
+ defaults. `<command> --help --json` (and `help <command> --json`) prints
88
+ per-command help as JSON. Unknown commands with `--json` return a structured
89
+ `UNKNOWN_COMMAND` envelope with a did-you-mean hint; the plain-text path
90
+ prints the same hint plus pointers to `--help` and `capabilities --json`.
91
+ `robot-docs` prints the packaged agent guide (skills/fullstackgtm/SKILL.md).
92
+
93
+ Intent inference: a `--flag` that is documented nowhere in the help reference
94
+ but is within one edit of a documented flag (lowercased, `_`→`-`) is treated
95
+ as a typo — exit 1 with `Did you mean` and the exact corrected command
96
+ (structured `UNKNOWN_FLAG` envelope under `--json`). GNU-style `--flag=value`
97
+ spellings of documented flags get the space-separated correction this CLI
98
+ parses. Corrections are suggest-only, never auto-executed. Unknown flags with
99
+ no near-miss are ignored, as before. Unknown `--provider` values and subverb
100
+ typos on the multi-verb commands (`enrich apend` → `enrich append`) get
101
+ nearest-match suggestions; a flag-shaped first token is diagnosed as
102
+ flag-before-command.
103
+
104
+ Triage: `doctor --json` returns environment status plus a `workspace` slice
105
+ (`healthScore`, `scoreDelta`, `lastAuditAt`, `auditCount`, `pendingPlans`)
106
+ and state-aware `nextSteps` — when a plan awaits approval, the exact
107
+ `plans show <id>` → `plans approve <id> --operations <ids|all>` →
108
+ `apply --plan-id <id> --provider <name>` chain. One call orients an agent.
109
+
110
+ Reproducibility: with `SOURCE_DATE_EPOCH` set (seconds since the epoch), the
111
+ audit plan's `createdAt` is pinned and `audit --demo --json` is
112
+ byte-deterministic across re-runs.
113
+
82
114
  ### Flag lexicon: `--provider` vs `--source` vs `--connector` vs `--channel`
83
115
 
84
116
  Four flags name "where data comes from / goes to"; they are not interchangeable:
@@ -90,6 +122,25 @@ Four flags name "where data comes from / goes to"; they are not interchangeable:
90
122
  | `--connector` | A signal-intake source connector on `signals fetch`: pulls candidate signals from a connected platform or the local webhook spool into the signal ledger. | `file`, `serpapi-news`, `hubspot-forms` |
91
123
  | `--channel` | Where a plan's output is delivered. On `apply`, the delivery terminus — a plan applies to `--provider <crm>` *or* `--channel outbox` (render approved openers to a local outbox file; transmits nothing), never both. On `draft`, which outreach channel the opener is drafted for (shapes the emitted op). | `apply`: `outbox` · `draft`: `email`, `linkedin`, `task` |
92
124
 
125
+ ### Standardized flag aliases
126
+
127
+ Additive; every legacy flag keeps working:
128
+
129
+ - `--dry-run` — accepted on the plan-spine verbs (`audit`, `suggest`,
130
+ `bulk-update`, `dedupe`, `reassign`, `fix`, `call plan`,
131
+ `enrich append`/`refresh`/`acquire`, `signals fetch`, `icp judge`, `draft`,
132
+ `tam status`, `market overlay`). These verbs are already preview-by-default
133
+ (plans are dry-run; nothing writes to a CRM outside approve → apply), so the
134
+ flag asserts that default rather than changing it: it additionally suppresses
135
+ `--save` (nothing is persisted to the plan store / run ledgers; a stderr note
136
+ is printed when both are passed) and locks `fix` to its stop-before-apply
137
+ path even when `--yes` is present. Omitting `--dry-run` changes nothing.
138
+ (`tam accounts` had `--dry-run` before this convention — there it prices the
139
+ credit pull; same spirit, spend nothing.)
140
+ - `--confirm` — accepted wherever an ad-hoc confirmation flag already gates a
141
+ write stage: alias for `fix --yes` (`tam accounts` already used `--confirm`
142
+ for its spend guard). `--dry-run` beats `--confirm`/`--yes`.
143
+
93
144
  Credential resolution ladder: explicit `--token-env` → ambient env
94
145
  (`HUBSPOT_ACCESS_TOKEN`, `SALESFORCE_ACCESS_TOKEN`+`SALESFORCE_INSTANCE_URL`,
95
146
  `STRIPE_SECRET_KEY`) → stored login (`~/.fullstackgtm`, `FSGTM_HOME` override)
@@ -145,6 +196,13 @@ dependency-free CSV intake; the Apollo client (`createApolloClient`,
145
196
  `pullApolloRecords`, 429-aware with `Retry-After`) is the first `api`-kind
146
197
  source.
147
198
 
199
+ Staged-file intake follows one container convention (`spoolFiles.ts`, shared
200
+ with `signals fetch --from` and `market observe --from`): `enrich ingest`
201
+ accepts — in addition to its original `.csv` / `.json` forms, which parse
202
+ exactly as before — a `*.jsonl` spool file (one JSON row per line) or a
203
+ directory of `*.jsonl` / `*.json` spool files (name-sorted), the Phase-2
204
+ webhook landing-zone format (docs/signal-spool-format.md).
205
+
148
206
  ## Acquire (net-new lead generation)
149
207
 
150
208
  `buildAcquirePlan` turns sourced-but-unmatched prospects into `create_record`
@@ -226,10 +284,17 @@ firing — an unapproved plan records a `plan_not_approved` no-op run
226
284
  - Cron: `parseCron` → `CronExpression`, `cronMatches`, `nextCronFiring`,
227
285
  `expectedFirings`, `computeMissedFirings` (status and missed-firing
228
286
  visibility — local cron has no catch-up).
229
- - Local provider: `schedule install` renders enabled entries into a
230
- sentinel-managed crontab block `crontabSentinels`, `renderManagedBlock`,
287
+ - Local provider: `schedule install` materializes enabled entries into the
288
+ platform timer. crontab (Linux default, `--timer crontab`): a
289
+ sentinel-managed block — `crontabSentinels`, `renderManagedBlock`,
231
290
  `replaceManagedBlock`, and `systemCrontabIo` behind the injectable
232
- `CrontabIo` seam (tests never touch a real crontab).
291
+ `CrontabIo` seam (tests never touch a real crontab). launchd (macOS
292
+ default, no Full Disk Access needed): one LaunchAgent per entry —
293
+ `cronToCalendarIntervals` (→ `LaunchdCalendarInterval[]`),
294
+ `renderLaunchdPlist`, `launchdLabel` / `launchdAgentPrefix`,
295
+ `installLaunchdAgents` / `uninstallLaunchdAgents`, and `systemLaunchdIo`
296
+ behind the injectable `LaunchdIo` seam (tests never touch launchctl);
297
+ `isDuplicateCronFiring` backs the `duplicate_firing` run no-op.
233
298
 
234
299
  ## Market map
235
300
 
@@ -261,9 +326,11 @@ the stored capture it cites before a set is accepted; failed captures read as
261
326
 
262
327
  ## MCP
263
328
 
264
- Tools: `fullstackgtm_audit`, `fullstackgtm_rules`, `fullstackgtm_apply`
265
- (requires explicit `approvedOperationIds`), `fullstackgtm_suggest`,
266
- `fullstackgtm_call_parse`, `fullstackgtm_resolve`,
329
+ Tools: `fullstackgtm_capabilities` (the machine-readable server contract —
330
+ tool inventory with per-tool CRM-write flags, derived from the server's own
331
+ registration table), `fullstackgtm_audit`, `fullstackgtm_rules`,
332
+ `fullstackgtm_apply` (requires explicit `approvedOperationIds`),
333
+ `fullstackgtm_suggest`, `fullstackgtm_call_parse`, `fullstackgtm_resolve`,
267
334
  `fullstackgtm_market_worksheet`, `fullstackgtm_market_observe` (validates,
268
335
  verifies quoted spans against the stored captures, appends, returns front
269
336
  states). Input schemas are stable.
@@ -72,6 +72,8 @@ provider ──fetchSnapshot()──► CanonicalGtmSnapshot
72
72
  - `market*.ts` (`market.ts`, `marketClassify.ts`, `marketAxes.ts`,
73
73
  `marketOverlay.ts`, `marketScale.ts`, `marketReport.ts`) — the competitive
74
74
  market-map layer; classifications are verbatim-verified against captures.
75
+ - `backfill.ts` — Stripe paid invoices → proposed closed-won deal creates
76
+ (domain-then-name account matching; unmatched reported, never auto-created).
75
77
  - `enrich.ts` + `enrichApollo.ts` — third-party data enrichment (fill-blanks),
76
78
  plus `buildAcquirePlan` / `builtinAcquirePreset` for net-new lead generation.
77
79
  - `icp.ts` — the Ideal Customer Profile artifact: per-provider discovery-filter
@@ -75,7 +75,7 @@ unstable base. Therefore:
75
75
  fake.
76
76
  2. **After the acquire workstream commits/lands:** wire `enrich acquire --source
77
77
  linkedin` end-to-end and add the dry-run integration test.
78
- 3. **Live validation** needs a HeyReach trial API key (Ryan provides) — the fake
78
+ 3. **Live validation** needs a HeyReach trial API key supplied by the operator — the fake
79
79
  covers everything up to the real network call.
80
80
 
81
81
  ## Phase 2 (future, recorded for continuity)
@@ -154,6 +154,19 @@ connector produces, so both paths dedup against each other.
154
154
  | `quote` | `"Submitted \"<form name>\" — <email>"` |
155
155
  | `firstSeen` | submission timestamp (ISO 8601) |
156
156
 
157
+ ## One container convention across staged intake
158
+
159
+ The spool CONTAINER (JSONL one-object-per-line; JSON array accepted; a
160
+ directory means every `*.jsonl` / `*.json` file in it, name-sorted) is shared
161
+ by every staged-file intake path, not just `--connector file`:
162
+
163
+ - `signals fetch --from <path>` — rows are staged signal rows (this doc's table);
164
+ - `enrich ingest <path> --source <id>` — rows are that source's enrichment rows;
165
+ - `market observe --from <path>` — each object is one observation-set envelope.
166
+
167
+ Each verb keeps its original single-file `.json` / `.csv` handling unchanged;
168
+ the row schema stays the verb's own. Shared reader: `src/spoolFiles.ts`.
169
+
157
170
  ## See also
158
171
 
159
172
  - `docs/api.md` — the `signals` verb and the `SignalSourceConnector` contract.