fullstackgtm 0.43.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.
- package/CHANGELOG.md +378 -1
- package/README.md +37 -14
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +80 -0
- package/dist/cli/auth.js +500 -0
- package/dist/cli/call.d.ts +1 -0
- package/dist/cli/call.js +373 -0
- package/dist/cli/capabilities.d.ts +30 -0
- package/dist/cli/capabilities.js +197 -0
- package/dist/cli/draft.d.ts +7 -0
- package/dist/cli/draft.js +87 -0
- package/dist/cli/enrich.d.ts +8 -0
- package/dist/cli/enrich.js +788 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +344 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +609 -0
- package/dist/cli/icp.d.ts +7 -0
- package/dist/cli/icp.js +229 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +58 -0
- package/dist/cli/market.d.ts +1 -0
- package/dist/cli/market.js +391 -0
- package/dist/cli/plans.d.ts +5 -0
- package/dist/cli/plans.js +454 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +70 -0
- package/dist/cli/shared.js +331 -0
- package/dist/cli/signals.d.ts +7 -0
- package/dist/cli/signals.js +412 -0
- package/dist/cli/suggest.d.ts +49 -0
- package/dist/cli/suggest.js +135 -0
- package/dist/cli/tam.d.ts +9 -0
- package/dist/cli/tam.js +387 -0
- package/dist/cli/ui.d.ts +121 -0
- package/dist/cli/ui.js +375 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +104 -4556
- package/dist/connector.d.ts +15 -0
- package/dist/connector.js +35 -0
- package/dist/connectors/hubspot.d.ts +3 -1
- package/dist/connectors/hubspot.js +10 -3
- package/dist/connectors/outboxChannel.d.ts +64 -0
- package/dist/connectors/outboxChannel.js +170 -0
- package/dist/connectors/prospectSources.d.ts +22 -0
- package/dist/connectors/prospectSources.js +43 -0
- package/dist/connectors/salesforce.d.ts +3 -1
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/signalSources.d.ts +105 -0
- package/dist/connectors/signalSources.js +264 -0
- package/dist/connectors/theirstack.d.ts +84 -0
- package/dist/connectors/theirstack.js +125 -0
- package/dist/icp.d.ts +52 -4
- package/dist/icp.js +112 -35
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.js +3 -0
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- package/dist/judgeEval.d.ts +7 -0
- package/dist/judgeEval.js +8 -1
- package/dist/marketClassify.d.ts +2 -0
- package/dist/marketClassify.js +7 -1
- package/dist/mcp-bin.js +2 -2
- package/dist/mcp.js +259 -166
- package/dist/runReport.d.ts +26 -0
- package/dist/runReport.js +15 -0
- package/dist/schedule.d.ts +80 -2
- package/dist/schedule.js +272 -5
- package/dist/signals.d.ts +54 -0
- package/dist/signals.js +64 -0
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/tam.d.ts +225 -0
- package/dist/tam.js +470 -0
- package/dist/types.d.ts +11 -0
- package/docs/api.md +91 -11
- package/docs/outbox-format.md +92 -0
- package/docs/recipes.md +37 -0
- package/docs/roadmap-to-1.0.md +31 -1
- package/docs/signal-spool-format.md +175 -0
- package/docs/tam.md +195 -0
- package/llms.txt +83 -11
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +4 -3
- package/src/audit.ts +27 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +549 -0
- package/src/cli/call.ts +398 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +885 -0
- package/src/cli/fix.ts +372 -0
- package/src/cli/help.ts +664 -0
- package/src/cli/icp.ts +265 -0
- package/src/cli/init.ts +65 -0
- package/src/cli/market.ts +423 -0
- package/src/cli/plans.ts +523 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +375 -0
- package/src/cli/signals.ts +434 -0
- package/src/cli/suggest.ts +151 -0
- package/src/cli/tam.ts +435 -0
- package/src/cli/ui.ts +426 -0
- package/src/cli.ts +103 -5170
- package/src/connector.ts +46 -0
- package/src/connectors/hubspot.ts +14 -2
- package/src/connectors/outboxChannel.ts +202 -0
- package/src/connectors/prospectSources.ts +57 -0
- package/src/connectors/salesforce.ts +18 -2
- package/src/connectors/signalSources.ts +314 -0
- package/src/connectors/theirstack.ts +170 -0
- package/src/icp.ts +120 -34
- package/src/index.ts +32 -0
- package/src/init.ts +3 -0
- package/src/judge.ts +7 -0
- package/src/judgeEval.ts +8 -1
- package/src/marketClassify.ts +8 -1
- package/src/mcp-bin.ts +2 -2
- package/src/mcp.ts +130 -57
- package/src/runReport.ts +39 -0
- package/src/schedule.ts +330 -7
- package/src/signals.ts +90 -0
- package/src/spoolFiles.ts +116 -0
- package/src/tam.ts +654 -0
- 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
|
|
@@ -29,7 +30,13 @@ const SCHEDULABLE = {
|
|
|
29
30
|
suggest: null,
|
|
30
31
|
report: null,
|
|
31
32
|
doctor: null,
|
|
32
|
-
enrich
|
|
33
|
+
// `enrich append|refresh` fill blanks; `enrich acquire` creates net-new
|
|
34
|
+
// leads — but ONLY as a needs_approval `create_record` plan (`--save`,
|
|
35
|
+
// required below), never a write. This is what lets `tam populate` chip away
|
|
36
|
+
// at a TAM unattended: each firing queues a fresh lead plan, the meter is
|
|
37
|
+
// charged only at apply, and apply stays `apply --plan-id` (re-checked
|
|
38
|
+
// approved). So scheduled acquire accumulates proposals, never surprise leads.
|
|
39
|
+
enrich: ["append", "refresh", "acquire"],
|
|
33
40
|
market: ["capture", "refresh"],
|
|
34
41
|
// The GTM brain. `signals fetch` is read-only re: CRM (--save persists only
|
|
35
42
|
// the local signal ledger). `icp judge`/`icp eval` are read-only/grade-only
|
|
@@ -41,9 +48,9 @@ const SCHEDULABLE = {
|
|
|
41
48
|
icp: ["judge", "eval"],
|
|
42
49
|
draft: null,
|
|
43
50
|
};
|
|
44
|
-
const ALLOWLIST_SUMMARY = "audit, snapshot, enrich append|refresh,
|
|
45
|
-
"icp judge|eval, draft (stages a plan),
|
|
46
|
-
"plus apply --plan-id <id> (re-checked approved at every firing)";
|
|
51
|
+
const ALLOWLIST_SUMMARY = "audit, snapshot, enrich append|refresh, enrich acquire --save (stages a lead plan), " +
|
|
52
|
+
"market capture|refresh, signals fetch, icp judge|eval, draft (stages a plan), " +
|
|
53
|
+
"suggest, report, doctor — plus apply --plan-id <id> (re-checked approved at every firing)";
|
|
47
54
|
/**
|
|
48
55
|
* Validate that an argv resolves to a schedulable fullstackgtm command.
|
|
49
56
|
* Enforced at `schedule add` time AND re-checked at `schedule run` time (the
|
|
@@ -89,6 +96,14 @@ export function validateSchedulableArgv(argv) {
|
|
|
89
96
|
.map((name) => `${head} ${name}`)
|
|
90
97
|
.join(", ")}.`);
|
|
91
98
|
}
|
|
99
|
+
// `enrich acquire` is schedulable ONLY in its plan-producing form: without
|
|
100
|
+
// --save it's a dry-run that writes nothing AND queues nothing, so an
|
|
101
|
+
// unattended firing would silently no-op. Require --save so a scheduled
|
|
102
|
+
// acquire always leaves a needs_approval plan behind (apply stays gated).
|
|
103
|
+
if (head === "enrich" && sub === "acquire" && !argv.includes("--save")) {
|
|
104
|
+
throw new Error("Scheduled `enrich acquire` must include --save so each firing queues a needs_approval lead plan " +
|
|
105
|
+
"(without it the run produces nothing). Apply stays a separate human gate (`apply --plan-id <id>`).");
|
|
106
|
+
}
|
|
92
107
|
}
|
|
93
108
|
}
|
|
94
109
|
/**
|
|
@@ -578,3 +593,255 @@ export function replaceManagedBlock(existing, profile, block) {
|
|
|
578
593
|
return "";
|
|
579
594
|
return `${merged.join("\n")}\n`;
|
|
580
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 "&";
|
|
686
|
+
case "<":
|
|
687
|
+
return "<";
|
|
688
|
+
case ">":
|
|
689
|
+
return ">";
|
|
690
|
+
case '"':
|
|
691
|
+
return """;
|
|
692
|
+
default:
|
|
693
|
+
return "'";
|
|
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
|
+
}
|
package/dist/signals.d.ts
CHANGED
|
@@ -132,6 +132,41 @@ export declare function buildSignalsFromAts(rawJobs: Array<AtsJob & {
|
|
|
132
132
|
now?: Date;
|
|
133
133
|
priorSignals?: Signal[];
|
|
134
134
|
}): Signal[];
|
|
135
|
+
/**
|
|
136
|
+
* One staged signal row: the platform-agnostic intake shape every NON-job source
|
|
137
|
+
* produces (a source connector's output, or a `--from` JSON row). It carries the
|
|
138
|
+
* evidence anchor (`quote`) but none of the derived fields (`id`, `weight`,
|
|
139
|
+
* `source`) — `stagedRowToSignal` fills those so there is ONE place that gates
|
|
140
|
+
* evidence and stamps identity, whether the row came from a file or an API.
|
|
141
|
+
*/
|
|
142
|
+
export type StagedSignalRow = {
|
|
143
|
+
bucket: SignalBucket;
|
|
144
|
+
accountDomain: string;
|
|
145
|
+
trigger: string;
|
|
146
|
+
/** VERBATIM evidence — required, non-empty. */
|
|
147
|
+
quote: string;
|
|
148
|
+
sourceUrl?: string;
|
|
149
|
+
/** ISO 8601; defaults to run time when absent. */
|
|
150
|
+
firstSeen?: string;
|
|
151
|
+
/** Optional explicit weight; defaults to the bucket's configured weight. */
|
|
152
|
+
weight?: number;
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Validate one loosely-typed staged row and turn it into a `Signal`, applying
|
|
156
|
+
* the verbatim-evidence gate (a row with no quote is rejected, never faked) and
|
|
157
|
+
* the same id/normalization logic the ATS path uses. `errorLabel` lets the
|
|
158
|
+
* caller produce a precise message ("--from f.json: row 3", "serpapi-news row 0")
|
|
159
|
+
* since both the `--from` ingest and the source-connector registry funnel here.
|
|
160
|
+
*
|
|
161
|
+
* Throws on a malformed row; returns the canonical `Signal` on success. Bucket
|
|
162
|
+
* FILTERING (skip rows outside a `--bucket` selection) stays with the caller —
|
|
163
|
+
* this function is per-row validation only.
|
|
164
|
+
*/
|
|
165
|
+
export declare function stagedRowToSignal(entry: Record<string, unknown>, opts: {
|
|
166
|
+
now: Date;
|
|
167
|
+
source: string;
|
|
168
|
+
errorLabel: string;
|
|
169
|
+
}): Signal;
|
|
135
170
|
/**
|
|
136
171
|
* Split candidate signals into fresh vs. deduped against prior signals. A
|
|
137
172
|
* candidate is deduped when a prior signal shares its `dedupKey`
|
|
@@ -160,6 +195,25 @@ export declare function dedupeSignals(candidates: Signal[], priorSignals: Signal
|
|
|
160
195
|
*/
|
|
161
196
|
export declare function computeWeights(config: SignalsConfig, outcomes: SignalOutcome[], signalsById?: Map<string, Signal>): Record<SignalBucket, number>;
|
|
162
197
|
export declare function signalsDir(baseDir?: string): string;
|
|
198
|
+
/**
|
|
199
|
+
* Conventional webhook landing zone: `<signals>/spool`, profile-scoped. A
|
|
200
|
+
* webhook receiver (hosted, or the operator's own glue) appends one JSONL row
|
|
201
|
+
* per event to a `*.jsonl` file here; `signals fetch --connector file` reads the
|
|
202
|
+
* whole directory when given no explicit path. The CLI never writes here — the
|
|
203
|
+
* receiver does — so this is just the agreed-upon location, not a managed store.
|
|
204
|
+
* Per-source files (`rb2b.jsonl`, `hubspot.jsonl`, …) coexist. See
|
|
205
|
+
* docs/signal-spool-format.md.
|
|
206
|
+
*/
|
|
207
|
+
export declare function signalsSpoolDir(baseDir?: string): string;
|
|
208
|
+
/**
|
|
209
|
+
* Conventional outbox: `<signals>/outbox`, profile-scoped — the SEND-side mirror
|
|
210
|
+
* of the spool. `apply --channel outbox` renders each APPROVED drafted opener to
|
|
211
|
+
* a `<channel>.jsonl` file here (one row per touch); a downstream sender (hosted,
|
|
212
|
+
* or the operator's own) drains it. The CLI WRITES governed, approved send
|
|
213
|
+
* intents here but TRANSMITS NOTHING — the "drafts everything, transmits nothing"
|
|
214
|
+
* invariant holds. See docs/outbox-format.md.
|
|
215
|
+
*/
|
|
216
|
+
export declare function signalsOutboxDir(baseDir?: string): string;
|
|
163
217
|
export type SignalRun = {
|
|
164
218
|
id: string;
|
|
165
219
|
runLabel: string;
|
package/dist/signals.js
CHANGED
|
@@ -292,6 +292,47 @@ function withinWindow(iso, now, windowDays) {
|
|
|
292
292
|
return false;
|
|
293
293
|
return now.getTime() - t <= windowDays * DAY_MS;
|
|
294
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Validate one loosely-typed staged row and turn it into a `Signal`, applying
|
|
297
|
+
* the verbatim-evidence gate (a row with no quote is rejected, never faked) and
|
|
298
|
+
* the same id/normalization logic the ATS path uses. `errorLabel` lets the
|
|
299
|
+
* caller produce a precise message ("--from f.json: row 3", "serpapi-news row 0")
|
|
300
|
+
* since both the `--from` ingest and the source-connector registry funnel here.
|
|
301
|
+
*
|
|
302
|
+
* Throws on a malformed row; returns the canonical `Signal` on success. Bucket
|
|
303
|
+
* FILTERING (skip rows outside a `--bucket` selection) stays with the caller —
|
|
304
|
+
* this function is per-row validation only.
|
|
305
|
+
*/
|
|
306
|
+
export function stagedRowToSignal(entry, opts) {
|
|
307
|
+
const { now, source, errorLabel } = opts;
|
|
308
|
+
const bucket = String(entry.bucket ?? "");
|
|
309
|
+
if (!SIGNAL_BUCKETS.includes(bucket)) {
|
|
310
|
+
throw new Error(`${errorLabel} has unknown bucket "${bucket}" (one of ${SIGNAL_BUCKETS.join(", ")}).`);
|
|
311
|
+
}
|
|
312
|
+
const accountDomain = normalizeAccountDomain(String(entry.accountDomain ?? entry.domain ?? ""));
|
|
313
|
+
if (!accountDomain)
|
|
314
|
+
throw new Error(`${errorLabel} is missing accountDomain.`);
|
|
315
|
+
const trigger = String(entry.trigger ?? "").trim();
|
|
316
|
+
if (!trigger)
|
|
317
|
+
throw new Error(`${errorLabel} is missing trigger.`);
|
|
318
|
+
const quote = String(entry.quote ?? "").trim();
|
|
319
|
+
if (!quote)
|
|
320
|
+
throw new Error(`${errorLabel} is missing the verbatim quote (the evidence anchor).`);
|
|
321
|
+
const base = { accountDomain, bucket: bucket, trigger };
|
|
322
|
+
const firstSeen = typeof entry.firstSeen === "string" && entry.firstSeen ? entry.firstSeen : now.toISOString();
|
|
323
|
+
return {
|
|
324
|
+
id: signalId(base),
|
|
325
|
+
accountDomain,
|
|
326
|
+
bucket: bucket,
|
|
327
|
+
trigger,
|
|
328
|
+
quote,
|
|
329
|
+
sourceUrl: String(entry.sourceUrl ?? ""),
|
|
330
|
+
firstSeen,
|
|
331
|
+
weight: typeof entry.weight === "number" ? entry.weight : DEFAULT_SIGNALS_CONFIG.buckets[bucket].weight,
|
|
332
|
+
source,
|
|
333
|
+
judgedBy: null,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
295
336
|
// ---------------------------------------------------------------------------
|
|
296
337
|
// Dedup
|
|
297
338
|
/**
|
|
@@ -392,6 +433,29 @@ function blankCounts() {
|
|
|
392
433
|
export function signalsDir(baseDir) {
|
|
393
434
|
return join(baseDir ?? credentialsDir(), "signals");
|
|
394
435
|
}
|
|
436
|
+
/**
|
|
437
|
+
* Conventional webhook landing zone: `<signals>/spool`, profile-scoped. A
|
|
438
|
+
* webhook receiver (hosted, or the operator's own glue) appends one JSONL row
|
|
439
|
+
* per event to a `*.jsonl` file here; `signals fetch --connector file` reads the
|
|
440
|
+
* whole directory when given no explicit path. The CLI never writes here — the
|
|
441
|
+
* receiver does — so this is just the agreed-upon location, not a managed store.
|
|
442
|
+
* Per-source files (`rb2b.jsonl`, `hubspot.jsonl`, …) coexist. See
|
|
443
|
+
* docs/signal-spool-format.md.
|
|
444
|
+
*/
|
|
445
|
+
export function signalsSpoolDir(baseDir) {
|
|
446
|
+
return join(signalsDir(baseDir), "spool");
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Conventional outbox: `<signals>/outbox`, profile-scoped — the SEND-side mirror
|
|
450
|
+
* of the spool. `apply --channel outbox` renders each APPROVED drafted opener to
|
|
451
|
+
* a `<channel>.jsonl` file here (one row per touch); a downstream sender (hosted,
|
|
452
|
+
* or the operator's own) drains it. The CLI WRITES governed, approved send
|
|
453
|
+
* intents here but TRANSMITS NOTHING — the "drafts everything, transmits nothing"
|
|
454
|
+
* invariant holds. See docs/outbox-format.md.
|
|
455
|
+
*/
|
|
456
|
+
export function signalsOutboxDir(baseDir) {
|
|
457
|
+
return join(signalsDir(baseDir), "outbox");
|
|
458
|
+
}
|
|
395
459
|
export function signalRunId(runLabel) {
|
|
396
460
|
return `sigr_${fnv1a(runLabel)}`;
|
|
397
461
|
}
|
|
@@ -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
|
+
}
|