fullstackgtm 0.44.0 → 0.45.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 (99) hide show
  1. package/CHANGELOG.md +185 -1
  2. package/README.md +19 -7
  3. package/dist/audit.d.ts +3 -1
  4. package/dist/audit.js +29 -2
  5. package/dist/cli/audit.d.ts +15 -0
  6. package/dist/cli/audit.js +392 -0
  7. package/dist/cli/auth.d.ts +80 -0
  8. package/dist/cli/auth.js +500 -0
  9. package/dist/cli/call.d.ts +1 -0
  10. package/dist/cli/call.js +373 -0
  11. package/dist/cli/capabilities.d.ts +30 -0
  12. package/dist/cli/capabilities.js +197 -0
  13. package/dist/cli/draft.d.ts +7 -0
  14. package/dist/cli/draft.js +87 -0
  15. package/dist/cli/enrich.d.ts +8 -0
  16. package/dist/cli/enrich.js +788 -0
  17. package/dist/cli/fix.d.ts +33 -0
  18. package/dist/cli/fix.js +344 -0
  19. package/dist/cli/help.d.ts +25 -0
  20. package/dist/cli/help.js +609 -0
  21. package/dist/cli/icp.d.ts +7 -0
  22. package/dist/cli/icp.js +229 -0
  23. package/dist/cli/init.d.ts +7 -0
  24. package/dist/cli/init.js +58 -0
  25. package/dist/cli/market.d.ts +1 -0
  26. package/dist/cli/market.js +391 -0
  27. package/dist/cli/plans.d.ts +5 -0
  28. package/dist/cli/plans.js +454 -0
  29. package/dist/cli/schedule.d.ts +8 -0
  30. package/dist/cli/schedule.js +479 -0
  31. package/dist/cli/shared.d.ts +70 -0
  32. package/dist/cli/shared.js +331 -0
  33. package/dist/cli/signals.d.ts +7 -0
  34. package/dist/cli/signals.js +412 -0
  35. package/dist/cli/suggest.d.ts +49 -0
  36. package/dist/cli/suggest.js +135 -0
  37. package/dist/cli/tam.d.ts +9 -0
  38. package/dist/cli/tam.js +387 -0
  39. package/dist/cli/ui.d.ts +121 -0
  40. package/dist/cli/ui.js +375 -0
  41. package/dist/cli.d.ts +1 -57
  42. package/dist/cli.js +100 -5130
  43. package/dist/connector.d.ts +15 -0
  44. package/dist/connector.js +35 -0
  45. package/dist/connectors/hubspot.d.ts +3 -1
  46. package/dist/connectors/hubspot.js +10 -3
  47. package/dist/connectors/salesforce.d.ts +3 -1
  48. package/dist/connectors/salesforce.js +12 -5
  49. package/dist/connectors/signalSources.js +7 -59
  50. package/dist/icp.d.ts +15 -11
  51. package/dist/icp.js +19 -36
  52. package/dist/judge.d.ts +2 -0
  53. package/dist/judge.js +6 -0
  54. package/dist/marketClassify.d.ts +2 -0
  55. package/dist/marketClassify.js +7 -1
  56. package/dist/mcp-bin.js +2 -2
  57. package/dist/mcp.js +259 -166
  58. package/dist/schedule.d.ts +80 -2
  59. package/dist/schedule.js +254 -1
  60. package/dist/spoolFiles.d.ts +44 -0
  61. package/dist/spoolFiles.js +114 -0
  62. package/dist/types.d.ts +11 -0
  63. package/docs/api.md +73 -7
  64. package/docs/signal-spool-format.md +13 -0
  65. package/llms.txt +15 -6
  66. package/package.json +1 -1
  67. package/skills/fullstackgtm/SKILL.md +1 -1
  68. package/src/audit.ts +27 -1
  69. package/src/cli/audit.ts +447 -0
  70. package/src/cli/auth.ts +549 -0
  71. package/src/cli/call.ts +398 -0
  72. package/src/cli/capabilities.ts +215 -0
  73. package/src/cli/draft.ts +101 -0
  74. package/src/cli/enrich.ts +885 -0
  75. package/src/cli/fix.ts +372 -0
  76. package/src/cli/help.ts +664 -0
  77. package/src/cli/icp.ts +265 -0
  78. package/src/cli/init.ts +65 -0
  79. package/src/cli/market.ts +423 -0
  80. package/src/cli/plans.ts +523 -0
  81. package/src/cli/schedule.ts +526 -0
  82. package/src/cli/shared.ts +375 -0
  83. package/src/cli/signals.ts +434 -0
  84. package/src/cli/suggest.ts +151 -0
  85. package/src/cli/tam.ts +435 -0
  86. package/src/cli/ui.ts +426 -0
  87. package/src/cli.ts +99 -5829
  88. package/src/connector.ts +46 -0
  89. package/src/connectors/hubspot.ts +14 -2
  90. package/src/connectors/salesforce.ts +18 -2
  91. package/src/connectors/signalSources.ts +7 -56
  92. package/src/icp.ts +19 -35
  93. package/src/judge.ts +7 -0
  94. package/src/marketClassify.ts +8 -1
  95. package/src/mcp-bin.ts +2 -2
  96. package/src/mcp.ts +130 -57
  97. package/src/schedule.ts +310 -3
  98. package/src/spoolFiles.ts +116 -0
  99. package/src/types.ts +12 -0
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
@@ -387,6 +387,17 @@ export type PatchPlanRun = {
387
387
  status: PatchPlanRunStatus;
388
388
  results: PatchOperationResult[];
389
389
  };
390
+ /**
391
+ * Per-page progress during a snapshot pull, emitted by connectors that accept
392
+ * an `onProgress` option. `fetched` is the running total for the object type
393
+ * currently streaming (it resets when the pull moves on to the next type).
394
+ * Presentation-only: a throwing callback must never fail a pull, so emitters
395
+ * call it best-effort.
396
+ */
397
+ export type SnapshotProgress = {
398
+ objectType: "user" | "account" | "contact" | "deal";
399
+ fetched: number;
400
+ };
390
401
  /**
391
402
  * The provider contract. Reads produce a canonical snapshot; writes accept a
392
403
  * single patch operation and report the outcome. Connectors without
package/docs/api.md CHANGED
@@ -75,10 +75,41 @@ Commands: `init`, `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `me
75
75
  `icp` (`interview` / `set` / `show` / `judge` / `eval`),
76
76
  `signals` (`fetch` / `list` / `outcome` / `weights`), `draft`,
77
77
  `schedule` (`add` / `list` / `remove` / `enable` / `disable` / `run` /
78
- `install` / `uninstall` / `status`), `rules`, `profiles`, `doctor`.
78
+ `install` / `uninstall` / `status`), `rules`, `profiles`, `doctor`,
79
+ `capabilities`, `robot-docs`.
79
80
  Exit codes: `0` success · `1` error · `2` findings/regressions at the requested gate
80
81
  (`--fail-on`, `--fail-on-new-findings`). `--json` everywhere; JSON output shapes are stable.
81
82
 
83
+ Agent discovery: `capabilities` prints the machine-readable CLI contract —
84
+ the command inventory with read-only vs write-shaped access (derived from the
85
+ help table, not a parallel list), this exit-code contract, and safety
86
+ defaults. `<command> --help --json` (and `help <command> --json`) prints
87
+ per-command help as JSON. Unknown commands with `--json` return a structured
88
+ `UNKNOWN_COMMAND` envelope with a did-you-mean hint; the plain-text path
89
+ prints the same hint plus pointers to `--help` and `capabilities --json`.
90
+ `robot-docs` prints the packaged agent guide (skills/fullstackgtm/SKILL.md).
91
+
92
+ Intent inference: a `--flag` that is documented nowhere in the help reference
93
+ but is within one edit of a documented flag (lowercased, `_`→`-`) is treated
94
+ as a typo — exit 1 with `Did you mean` and the exact corrected command
95
+ (structured `UNKNOWN_FLAG` envelope under `--json`). GNU-style `--flag=value`
96
+ spellings of documented flags get the space-separated correction this CLI
97
+ parses. Corrections are suggest-only, never auto-executed. Unknown flags with
98
+ no near-miss are ignored, as before. Unknown `--provider` values and subverb
99
+ typos on the multi-verb commands (`enrich apend` → `enrich append`) get
100
+ nearest-match suggestions; a flag-shaped first token is diagnosed as
101
+ flag-before-command.
102
+
103
+ Triage: `doctor --json` returns environment status plus a `workspace` slice
104
+ (`healthScore`, `scoreDelta`, `lastAuditAt`, `auditCount`, `pendingPlans`)
105
+ and state-aware `nextSteps` — when a plan awaits approval, the exact
106
+ `plans show <id>` → `plans approve <id> --operations <ids|all>` →
107
+ `apply --plan-id <id> --provider <name>` chain. One call orients an agent.
108
+
109
+ Reproducibility: with `SOURCE_DATE_EPOCH` set (seconds since the epoch), the
110
+ audit plan's `createdAt` is pinned and `audit --demo --json` is
111
+ byte-deterministic across re-runs.
112
+
82
113
  ### Flag lexicon: `--provider` vs `--source` vs `--connector` vs `--channel`
83
114
 
84
115
  Four flags name "where data comes from / goes to"; they are not interchangeable:
@@ -90,6 +121,25 @@ Four flags name "where data comes from / goes to"; they are not interchangeable:
90
121
  | `--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
122
  | `--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
123
 
124
+ ### Standardized flag aliases
125
+
126
+ Additive; every legacy flag keeps working:
127
+
128
+ - `--dry-run` — accepted on the plan-spine verbs (`audit`, `suggest`,
129
+ `bulk-update`, `dedupe`, `reassign`, `fix`, `call plan`,
130
+ `enrich append`/`refresh`/`acquire`, `signals fetch`, `icp judge`, `draft`,
131
+ `tam status`, `market overlay`). These verbs are already preview-by-default
132
+ (plans are dry-run; nothing writes to a CRM outside approve → apply), so the
133
+ flag asserts that default rather than changing it: it additionally suppresses
134
+ `--save` (nothing is persisted to the plan store / run ledgers; a stderr note
135
+ is printed when both are passed) and locks `fix` to its stop-before-apply
136
+ path even when `--yes` is present. Omitting `--dry-run` changes nothing.
137
+ (`tam accounts` had `--dry-run` before this convention — there it prices the
138
+ credit pull; same spirit, spend nothing.)
139
+ - `--confirm` — accepted wherever an ad-hoc confirmation flag already gates a
140
+ write stage: alias for `fix --yes` (`tam accounts` already used `--confirm`
141
+ for its spend guard). `--dry-run` beats `--confirm`/`--yes`.
142
+
93
143
  Credential resolution ladder: explicit `--token-env` → ambient env
94
144
  (`HUBSPOT_ACCESS_TOKEN`, `SALESFORCE_ACCESS_TOKEN`+`SALESFORCE_INSTANCE_URL`,
95
145
  `STRIPE_SECRET_KEY`) → stored login (`~/.fullstackgtm`, `FSGTM_HOME` override)
@@ -145,6 +195,13 @@ dependency-free CSV intake; the Apollo client (`createApolloClient`,
145
195
  `pullApolloRecords`, 429-aware with `Retry-After`) is the first `api`-kind
146
196
  source.
147
197
 
198
+ Staged-file intake follows one container convention (`spoolFiles.ts`, shared
199
+ with `signals fetch --from` and `market observe --from`): `enrich ingest`
200
+ accepts — in addition to its original `.csv` / `.json` forms, which parse
201
+ exactly as before — a `*.jsonl` spool file (one JSON row per line) or a
202
+ directory of `*.jsonl` / `*.json` spool files (name-sorted), the Phase-2
203
+ webhook landing-zone format (docs/signal-spool-format.md).
204
+
148
205
  ## Acquire (net-new lead generation)
149
206
 
150
207
  `buildAcquirePlan` turns sourced-but-unmatched prospects into `create_record`
@@ -226,10 +283,17 @@ firing — an unapproved plan records a `plan_not_approved` no-op run
226
283
  - Cron: `parseCron` → `CronExpression`, `cronMatches`, `nextCronFiring`,
227
284
  `expectedFirings`, `computeMissedFirings` (status and missed-firing
228
285
  visibility — local cron has no catch-up).
229
- - Local provider: `schedule install` renders enabled entries into a
230
- sentinel-managed crontab block `crontabSentinels`, `renderManagedBlock`,
286
+ - Local provider: `schedule install` materializes enabled entries into the
287
+ platform timer. crontab (Linux default, `--timer crontab`): a
288
+ sentinel-managed block — `crontabSentinels`, `renderManagedBlock`,
231
289
  `replaceManagedBlock`, and `systemCrontabIo` behind the injectable
232
- `CrontabIo` seam (tests never touch a real crontab).
290
+ `CrontabIo` seam (tests never touch a real crontab). launchd (macOS
291
+ default, no Full Disk Access needed): one LaunchAgent per entry —
292
+ `cronToCalendarIntervals` (→ `LaunchdCalendarInterval[]`),
293
+ `renderLaunchdPlist`, `launchdLabel` / `launchdAgentPrefix`,
294
+ `installLaunchdAgents` / `uninstallLaunchdAgents`, and `systemLaunchdIo`
295
+ behind the injectable `LaunchdIo` seam (tests never touch launchctl);
296
+ `isDuplicateCronFiring` backs the `duplicate_firing` run no-op.
233
297
 
234
298
  ## Market map
235
299
 
@@ -261,9 +325,11 @@ the stored capture it cites before a set is accepted; failed captures read as
261
325
 
262
326
  ## MCP
263
327
 
264
- Tools: `fullstackgtm_audit`, `fullstackgtm_rules`, `fullstackgtm_apply`
265
- (requires explicit `approvedOperationIds`), `fullstackgtm_suggest`,
266
- `fullstackgtm_call_parse`, `fullstackgtm_resolve`,
328
+ Tools: `fullstackgtm_capabilities` (the machine-readable server contract —
329
+ tool inventory with per-tool CRM-write flags, derived from the server's own
330
+ registration table), `fullstackgtm_audit`, `fullstackgtm_rules`,
331
+ `fullstackgtm_apply` (requires explicit `approvedOperationIds`),
332
+ `fullstackgtm_suggest`, `fullstackgtm_call_parse`, `fullstackgtm_resolve`,
267
333
  `fullstackgtm_market_worksheet`, `fullstackgtm_market_observe` (validates,
268
334
  verifies quoted spans against the stored captures, appends, returns front
269
335
  states). Input schemas are stable.
@@ -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.
package/llms.txt CHANGED
@@ -211,17 +211,26 @@ owns cron logic. `add "<command>" --cron "<expr>"` validates the command
211
211
  against the read/plan-side allowlist (audit, snapshot, enrich append|refresh,
212
212
  enrich acquire --save, market capture|refresh, signals fetch, icp judge|eval,
213
213
  draft, suggest, report, doctor) and the 5-field cron
214
- expression, but touches no crontab; `install` materializes enabled entries
215
- into a sentinel-delimited managed block (`# >>> fullstackgtm <profile> >>>`)
216
- replaced wholesale on re-install, lines outside it untouched; `uninstall`
217
- removes only the block. Scheduling never auto-approves: `apply` is
214
+ expression, but touches no system timer; `install` materializes enabled
215
+ entries into the platform timer (`--timer` defaults launchd on macOS,
216
+ crontab elsewhere). crontab: a sentinel-delimited managed block
217
+ (`# >>> fullstackgtm <profile> >>>`) replaced wholesale on re-install, lines
218
+ outside it untouched. launchd: one LaunchAgent plist per entry
219
+ (`com.fullstackgtm.<profile>.<id>` in `~/Library/LaunchAgents`), the fleet
220
+ replaced wholesale, foreign plists untouched — chosen because macOS gates
221
+ `crontab` writes behind Full Disk Access (unpromptable TCC), while
222
+ LaunchAgents need no permission and coalesce firings missed during sleep
223
+ into one run on wake. `uninstall` removes only what install created.
224
+ Scheduling never auto-approves: `apply` is
218
225
  schedulable ONLY as `apply --plan-id <id>` and every firing re-checks the
219
226
  plan is approved — unapproved plans record a `plan_not_approved` no-op run,
220
227
  no flag relaxes this. `schedule run <id>` is the single provider entry point
221
- (in-process dispatch, never shell); run records (exit code, output tail,
228
+ (in-process dispatch, never shell; a second cron trigger in the same minute
229
+ records a `duplicate_firing` no-op — launchd double-triggers the Vixie
230
+ dom+dow OR corner); run records (exit code, output tail,
222
231
  artifacts: plan ids / enrich run labels, trigger cron|manual) land under
223
232
  `<home>/schedule/runs/<id>/`. `status` shows next firing and surfaces missed
224
- firings (visibility only — local cron has no catch-up). Providers beyond
233
+ firings (visibility only — cron has no catch-up). Providers beyond
225
234
  `local` are not yet implemented (future: codegen scaffolds, same contract).
226
235
 
227
236
  ## Key invariants (engagement workspace / health)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
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 LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
@@ -79,7 +79,7 @@ Add `--json` for machine-readable output on any command.
79
79
  ## MCP server (alternative surface, same gates)
80
80
 
81
81
  ```bash
82
- npx -y -p fullstackgtm -p @modelcontextprotocol/sdk -p zod fullstackgtm-mcp
82
+ npx -y fullstackgtm-mcp
83
83
  ```
84
84
 
85
85
  Tools over stdio: `fullstackgtm_audit` (read-only), `fullstackgtm_rules`,