fullstackgtm 0.23.2 → 0.25.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.
@@ -0,0 +1,87 @@
1
+ ---
2
+ name: fullstackgtm
3
+ description: Govern CRM/GTM data operations through the fullstackgtm CLI — read-only hygiene audits, reviewable dry-run patch plans, deterministic value suggestions, and approval-gated write-back to HubSpot and Salesforce. Use when asked to audit, clean, dedupe, enrich, bulk-update, reassign, or write to a CRM; to gate record creation against duplicates; to parse, score, or link sales call transcripts; to map a competitive category; or to schedule any of the above. Never write to a CRM directly when this skill is available.
4
+ ---
5
+
6
+ # fullstackgtm — plan/apply for your GTM stack
7
+
8
+ Think `terraform plan` for the CRM: you may *read* everything, but every
9
+ proposed change is a typed patch operation — object, field, before, after,
10
+ reason, risk — that a human approves before any provider write happens.
11
+ Connectors: HubSpot (read/write), Salesforce (read/write), Stripe (read-only).
12
+ Requires Node 20+; every command below works zero-install via `npx`.
13
+
14
+ ## Non-negotiable invariants
15
+
16
+ - `audit` and `suggest` never mutate anything. `apply` writes ONLY operation
17
+ ids explicitly passed via `--approve` (or a plan a human approved with
18
+ `plans approve`). Do not attempt to bypass this; surface the plan instead.
19
+ - Operations whose value is a human decision carry `requires_human_*`
20
+ placeholders and are refused without a concrete `--value <opId>=<v>`
21
+ override. Never guess values — chain `suggest` (deterministic, evidence-
22
+ backed) and leave `low`/`create`/`none`-confidence entries to the human.
23
+ - Secrets are never accepted as argv flags: env vars or stdin only
24
+ (`echo "$TOKEN" | fullstackgtm login hubspot`). Set `FSGTM_NO_BROWSER=1`
25
+ in headless environments.
26
+ - Exit codes everywhere: `0` success, `1` error, `2` findings/matches at or
27
+ above the threshold (gate-shaped for scripts).
28
+
29
+ ## Verify the install, then prove the pipeline with zero credentials
30
+
31
+ ```bash
32
+ npx fullstackgtm doctor --json # node.ok: true + package.version
33
+ npx fullstackgtm audit --demo --json # dry-run plan over a seeded messy CRM
34
+ ```
35
+
36
+ ## The governed loop (the core of everything)
37
+
38
+ ```bash
39
+ fullstackgtm audit --provider hubspot --save # read-only → saved plan id
40
+ fullstackgtm suggest --plan-id <id> --provider hubspot --out suggestions.json
41
+ fullstackgtm plans approve <id> --values-from suggestions.json # high-confidence only
42
+ fullstackgtm apply --plan-id <id> --provider hubspot # writes ONLY approved ops
43
+ ```
44
+
45
+ Credentials resolve in order: `--token-env <NAME>` → ambient env
46
+ (`HUBSPOT_ACCESS_TOKEN`; `SALESFORCE_ACCESS_TOKEN` + `SALESFORCE_INSTANCE_URL`;
47
+ `STRIPE_SECRET_KEY`) → stored `fullstackgtm login <provider>` → broker pairing.
48
+ In a sandbox prefer the first two. LLM-powered verbs (`call parse`, `call
49
+ score`, `market classify`) take `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`, or use
50
+ their deterministic/worksheet fallbacks — the CLI never prompts when
51
+ non-interactive.
52
+
53
+ ## Verb map
54
+
55
+ | Verb | What it does |
56
+ | --- | --- |
57
+ | `resolve <contact\|account\|deal>` | Create gate: exit 0 = safe to create, 2 = exists/ambiguous — never blind-create |
58
+ | `dedupe <object> --key <domain\|email\|name>` | One merge op per duplicate group, deterministic survivor; merges are irreversible |
59
+ | `bulk-update <object> --where … --set\|--archive\|--create-task` | Filtered dry-run plan; filter re-verified per record at apply time |
60
+ | `reassign --from <owner> --to <owner>` | Ownership handoff plans per object type |
61
+ | `fix --rule <id>` | audit one rule → suggest → approve at the confidence bar → apply only with `--yes` |
62
+ | `call parse\|score\|link\|plan` | Transcripts → evidence-quoted insights, rubric scorecards, deal linking, governed next-step writes |
63
+ | `enrich append\|refresh\|ingest\|status` | Governed enrichment (Apollo pull / Clay ingest), fill-blanks-only plans |
64
+ | `market capture\|classify\|worksheet\|observe\|fronts\|axes\|overlay\|scale\|report\|refresh` | Competitive category map; evidence quotes verified verbatim against stored captures |
65
+ | `schedule add\|install\|run\|status` | Horizontal cron; read/plan-side allowlist only — scheduling NEVER auto-approves |
66
+ | `plans list\|approve` / `snapshot` / `rules` / `doctor` | Plan lifecycle, raw snapshots, rule registry, machine state |
67
+
68
+ All write-shaped verbs produce plans; none writes outside approve → apply.
69
+ Add `--json` for machine-readable output on any command.
70
+
71
+ ## MCP server (alternative surface, same gates)
72
+
73
+ ```bash
74
+ npx -y -p fullstackgtm -p @modelcontextprotocol/sdk -p zod fullstackgtm-mcp
75
+ ```
76
+
77
+ Tools over stdio: `fullstackgtm_audit` (read-only), `fullstackgtm_rules`,
78
+ `fullstackgtm_suggest`, `fullstackgtm_call_parse`,
79
+ `fullstackgtm_apply` (requires `approvedOperationIds`),
80
+ `fullstackgtm_resolve`, `fullstackgtm_market_worksheet`,
81
+ `fullstackgtm_market_observe`.
82
+
83
+ ## Going deeper
84
+
85
+ - [llms.txt](https://github.com/fullstackgtm/core/blob/main/llms.txt) — the full invariant map per layer (calls, market, write verbs, enrich, schedule)
86
+ - [INSTALL_FOR_AGENTS.md](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md) — deterministic install-and-verify with expected outputs
87
+ - [docs/api.md](https://github.com/fullstackgtm/core/blob/main/docs/api.md) — semver-covered surfaces: canonical model, rule interface, plan/apply contract, connectors, config, CLI, MCP
package/src/cli.ts CHANGED
@@ -101,6 +101,22 @@ import {
101
101
  pullApolloRecords,
102
102
  type ApolloPullKey,
103
103
  } from "./enrichApollo.ts";
104
+ import {
105
+ computeMissedFirings,
106
+ createFileScheduleRunStore,
107
+ createFileScheduleStore,
108
+ nextCronFiring,
109
+ parseCron,
110
+ renderManagedBlock,
111
+ replaceManagedBlock,
112
+ scheduleId,
113
+ systemCrontabIo,
114
+ tokenizeCommand,
115
+ validateSchedulableArgv,
116
+ type ScheduleEntry,
117
+ type ScheduleRunRecord,
118
+ type ScheduleRunTrigger,
119
+ } from "./schedule.ts";
104
120
  import { resolveRecord, type ResolveCandidate } from "./resolve.ts";
105
121
  import { buildBulkUpdatePlan } from "./bulkUpdate.ts";
106
122
  import { buildDedupePlan, type DedupeOptions } from "./dedupe.ts";
@@ -216,6 +232,16 @@ Usage:
216
232
  value) → with --yes, apply through the provider and print a
217
233
  stage-by-stage summary. Without --yes it stops after
218
234
  approval and prints the apply command.
235
+ fullstackgtm schedule add "<command>" --cron "<expr>" [--label <name>] [--provider local]
236
+ fullstackgtm schedule list | remove <id> | enable <id> | disable <id> | run <id>
237
+ fullstackgtm schedule install | uninstall [--provider local]
238
+ fullstackgtm schedule status [<id>] [--runs <n>]
239
+ declare a cadence for any read/plan-side command (audit,
240
+ snapshot, enrich append|refresh, market capture|refresh,
241
+ suggest, report, doctor); install materializes enabled
242
+ entries into a sentinel-delimited managed crontab block.
243
+ Scheduling never auto-approves: apply is schedulable only
244
+ as apply --plan-id <id>, re-checked approved at run time.
219
245
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
220
246
  derive values for requires_human_* placeholders
221
247
  from snapshot evidence, with confidence + reasons
@@ -1731,6 +1757,421 @@ async function enrichStatus(store: EnrichRunStore, rest: string[], configFile: s
1731
1757
  }
1732
1758
  }
1733
1759
 
1760
+ /**
1761
+ * The schedule layer: declarative cadences for read/plan-side commands,
1762
+ * materialized through a provider (MVP: the user crontab), with an
1763
+ * append-only run history. The governance invariant — scheduling never
1764
+ * auto-approves — is enforced at `add` time and re-checked at `run` time.
1765
+ * Spec: docs/schedule.md (monorepo).
1766
+ */
1767
+ async function scheduleCommand(args: string[]) {
1768
+ const [subcommand, ...rest] = args;
1769
+
1770
+ // Catch --help BEFORE any store read, credential access, or crontab
1771
+ // round-trip (the enrich/market early-catch pattern — `schedule install
1772
+ // --help` must never touch the crontab).
1773
+ if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
1774
+ console.log(`Usage:
1775
+ schedule add "<fullstackgtm command>" --cron "<expr>" [--label <name>] [--provider local]
1776
+ schedule list [--json]
1777
+ schedule remove <id>
1778
+ schedule enable <id> | disable <id>
1779
+ schedule run <id> execute now; the single entry point every provider's timer calls
1780
+ schedule install [--provider local] render enabled entries into the managed crontab block
1781
+ schedule uninstall [--provider local] remove the managed block (nothing else)
1782
+ schedule status [<id>] [--runs <n>] [--json]
1783
+
1784
+ add validates the command against the schedulable allowlist — read/plan-side
1785
+ only: audit, snapshot, enrich append|refresh, market capture|refresh,
1786
+ suggest, report, doctor — and parses the 5-field cron expression (*, lists,
1787
+ ranges, steps), but touches NO crontab: entries are declarative until install
1788
+ materializes them (the plan/apply split — declare, then deliberately
1789
+ activate).
1790
+
1791
+ Scheduling never auto-approves. apply is schedulable ONLY as
1792
+ \`apply --plan-id <id>\`, and every firing re-checks the plan's status is
1793
+ approved — an unapproved plan records a plan_not_approved no-op run instead
1794
+ of executing. There is no flag that relaxes this. Unattended runs accumulate
1795
+ proposals (plans, run records, reports), never surprise CRM writes.
1796
+
1797
+ install renders all enabled entries into a sentinel-delimited block
1798
+ (# >>> fullstackgtm <profile> >>> ... # <<< fullstackgtm <profile> <<<) in
1799
+ the user crontab; each line invokes \`schedule run <id> --profile <profile>\`
1800
+ and nothing else. Re-install replaces the block wholesale and never touches
1801
+ lines outside the sentinels. Providers other than local (modal, aws) are not
1802
+ yet implemented — they will be scaffold generators calling the same
1803
+ \`schedule run\` contract.
1804
+
1805
+ run executes the command in-process and records the outcome (exit code,
1806
+ output tail, artifacts: plan ids / enrich run labels) under the profile's
1807
+ schedule/runs/<id>/; the exit code propagates. Manual runs record
1808
+ trigger: manual. status shows next firing and surfaces missed firings
1809
+ (laptop asleep = cron skips silently; visibility only, no catch-up).`);
1810
+ return;
1811
+ }
1812
+
1813
+ const store = createFileScheduleStore();
1814
+ const runStore = createFileScheduleRunStore();
1815
+
1816
+ if (subcommand === "add") {
1817
+ const commandText = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
1818
+ if (!commandText) {
1819
+ throw new Error('Usage: fullstackgtm schedule add "<fullstackgtm command>" --cron "<expr>" [--label <name>] [--provider local]');
1820
+ }
1821
+ const cronText = option(rest, "--cron");
1822
+ if (!cronText) throw new Error('schedule add requires --cron "<minute hour day-of-month month day-of-week>"');
1823
+ requireLocalScheduleProvider(option(rest, "--provider") ?? "local");
1824
+
1825
+ let argv = tokenizeCommand(commandText);
1826
+ if (argv[0] === "fullstackgtm") argv = argv.slice(1);
1827
+ validateSchedulableArgv(argv);
1828
+ const cron = parseCron(cronText);
1829
+ const next = nextCronFiring(cron, new Date()); // also rejects never-firing expressions
1830
+ const createdAt = new Date().toISOString();
1831
+ const label =
1832
+ option(rest, "--label") ??
1833
+ argv.filter((arg) => !arg.startsWith("--")).slice(0, 2).join("-").replace(/[^\w.-]+/g, "-");
1834
+ const entry: ScheduleEntry = {
1835
+ id: scheduleId(label, cron.source, argv, createdAt),
1836
+ label,
1837
+ argv,
1838
+ cron: cron.source,
1839
+ provider: "local",
1840
+ enabled: true,
1841
+ createdAt,
1842
+ };
1843
+ await store.add(entry);
1844
+ console.log(`Added ${entry.id} "${label}": \`${argv.join(" ")}\` at \`${cron.source}\` (next firing: ${next.toISOString()}).`);
1845
+ console.log("Declarative until materialized — activate with `fullstackgtm schedule install`.");
1846
+ return;
1847
+ }
1848
+
1849
+ if (subcommand === "list") {
1850
+ const entries = await store.list();
1851
+ const now = new Date();
1852
+ const withNext = entries.map((entry) => ({
1853
+ ...entry,
1854
+ nextFiring: entry.enabled ? safeNextFiring(entry.cron, now) : null,
1855
+ }));
1856
+ if (rest.includes("--json")) {
1857
+ console.log(JSON.stringify(withNext, null, 2));
1858
+ return;
1859
+ }
1860
+ if (entries.length === 0) {
1861
+ console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
1862
+ return;
1863
+ }
1864
+ for (const entry of withNext) {
1865
+ console.log(
1866
+ `${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron.padEnd(14)} next ${entry.nextFiring ?? "—"} ${entry.label}: ${entry.argv.join(" ")}`,
1867
+ );
1868
+ }
1869
+ console.log("\nEntries are declarative — `schedule install` materializes enabled ones into the crontab.");
1870
+ return;
1871
+ }
1872
+
1873
+ if (subcommand === "remove") {
1874
+ const id = positionalArg(rest, "Usage: fullstackgtm schedule remove <id>");
1875
+ if (!(await store.remove(id))) throw new Error(`No schedule with id ${id}.`);
1876
+ console.log(`Removed ${id}. Run \`fullstackgtm schedule install\` to update the crontab block.`);
1877
+ return;
1878
+ }
1879
+
1880
+ if (subcommand === "enable" || subcommand === "disable") {
1881
+ const id = positionalArg(rest, `Usage: fullstackgtm schedule ${subcommand} <id>`);
1882
+ const entry = await store.setEnabled(id, subcommand === "enable");
1883
+ console.log(
1884
+ `${subcommand === "enable" ? "Enabled" : "Disabled"} ${entry.id} "${entry.label}". ` +
1885
+ "Run `fullstackgtm schedule install` to update the crontab block.",
1886
+ );
1887
+ return;
1888
+ }
1889
+
1890
+ if (subcommand === "run") {
1891
+ const id = positionalArg(rest, "Usage: fullstackgtm schedule run <id>");
1892
+ const entry = await store.get(id);
1893
+ if (!entry) throw new Error(`No schedule with id ${id} in profile "${activeProfile()}".`);
1894
+ if (!entry.enabled) {
1895
+ throw new Error(`Schedule ${id} is disabled — enable it with \`fullstackgtm schedule enable ${id}\`.`);
1896
+ }
1897
+ const trigger = (option(rest, "--trigger") ?? "manual") as ScheduleRunTrigger;
1898
+ if (trigger !== "cron" && trigger !== "manual") {
1899
+ throw new Error("--trigger must be cron or manual");
1900
+ }
1901
+ const run = await executeScheduledRun(entry, trigger);
1902
+ if (run.exitCode !== 0) process.exitCode = run.exitCode;
1903
+ return;
1904
+ }
1905
+
1906
+ if (subcommand === "install" || subcommand === "uninstall") {
1907
+ requireLocalScheduleProvider(option(rest, "--provider") ?? "local");
1908
+ const profile = activeProfile();
1909
+ const io = systemCrontabIo();
1910
+ const existing = io.read();
1911
+ if (subcommand === "uninstall") {
1912
+ io.write(replaceManagedBlock(existing, profile, null));
1913
+ console.log(`Removed the fullstackgtm managed crontab block for profile "${profile}" (lines outside the sentinels untouched).`);
1914
+ return;
1915
+ }
1916
+ const enabled = (await store.list()).filter((entry) => entry.enabled && entry.provider === "local");
1917
+ if (enabled.length === 0) {
1918
+ io.write(replaceManagedBlock(existing, profile, null));
1919
+ console.log(`No enabled schedules for profile "${profile}" — removed the managed block.`);
1920
+ return;
1921
+ }
1922
+ const block = renderManagedBlock(profile, enabled, scheduleCliInvocation());
1923
+ io.write(replaceManagedBlock(existing, profile, block));
1924
+ console.log(
1925
+ `Installed ${enabled.length} schedule(s) into the managed crontab block for profile "${profile}". ` +
1926
+ "Re-running install replaces the block wholesale; `schedule uninstall` removes it.",
1927
+ );
1928
+ return;
1929
+ }
1930
+
1931
+ if (subcommand === "status") {
1932
+ const id = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
1933
+ const entries = await store.list();
1934
+ const selected = id ? entries.filter((entry) => entry.id === id) : entries;
1935
+ if (id && selected.length === 0) throw new Error(`No schedule with id ${id}.`);
1936
+ if (selected.length === 0) {
1937
+ console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
1938
+ return;
1939
+ }
1940
+ const showRuns = numericOption(rest, "--runs");
1941
+ const now = new Date();
1942
+ const report = [] as Array<Record<string, unknown>>;
1943
+ for (const entry of selected) {
1944
+ const runs = await runStore.list(entry.id);
1945
+ const last = runs.length > 0 ? runs[runs.length - 1] : null;
1946
+ let streak = 0;
1947
+ for (let index = runs.length - 1; index >= 0; index -= 1) {
1948
+ if ((runs[index].exitCode === 0) !== (last!.exitCode === 0)) break;
1949
+ streak += 1;
1950
+ }
1951
+ const missed = computeMissedFirings(entry, runs, now);
1952
+ report.push({
1953
+ id: entry.id,
1954
+ label: entry.label,
1955
+ argv: entry.argv,
1956
+ cron: entry.cron,
1957
+ enabled: entry.enabled,
1958
+ nextFiring: entry.enabled ? safeNextFiring(entry.cron, now) : null,
1959
+ lastRun: last
1960
+ ? {
1961
+ firedAt: last.firedAt,
1962
+ trigger: last.trigger,
1963
+ exitCode: last.exitCode,
1964
+ noopReason: last.noopReason,
1965
+ artifacts: last.artifacts,
1966
+ }
1967
+ : null,
1968
+ streak: last ? { outcome: last.exitCode === 0 ? "success" : "failure", length: streak } : null,
1969
+ missedFirings: missed.missed.map((firing) => firing.toISOString()),
1970
+ missedFiringsCapped: missed.capped,
1971
+ runs: showRuns !== undefined ? runs.slice(-showRuns) : undefined,
1972
+ });
1973
+ }
1974
+ if (rest.includes("--json")) {
1975
+ console.log(JSON.stringify(report, null, 2));
1976
+ return;
1977
+ }
1978
+ for (const entry of report) {
1979
+ const last = entry.lastRun as { firedAt: string; trigger: string; exitCode: number; noopReason?: string; artifacts: { planIds: string[]; runLabels: string[] } } | null;
1980
+ const streak = entry.streak as { outcome: string; length: number } | null;
1981
+ const missed = entry.missedFirings as string[];
1982
+ console.log(`${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron} ${entry.label}: ${(entry.argv as string[]).join(" ")}`);
1983
+ console.log(` next: ${entry.nextFiring ?? "— (disabled)"}`);
1984
+ if (last) {
1985
+ const artifacts = [
1986
+ ...last.artifacts.planIds.map((planId) => `plan ${planId}`),
1987
+ ...last.artifacts.runLabels.map((runLabel) => `run ${runLabel}`),
1988
+ ];
1989
+ console.log(
1990
+ ` last: ${last.firedAt} (${last.trigger}) exit ${last.exitCode}` +
1991
+ (last.noopReason ? ` — no-op: ${last.noopReason}` : "") +
1992
+ (artifacts.length ? ` — ${artifacts.join(", ")}` : ""),
1993
+ );
1994
+ console.log(` streak: ${streak!.length} ${streak!.outcome}(s)`);
1995
+ } else {
1996
+ console.log(" last: never fired");
1997
+ }
1998
+ if (missed.length > 0) {
1999
+ console.log(
2000
+ ` missed: ${missed.length}${entry.missedFiringsCapped ? "+" : ""} expected firing(s) with no run record ` +
2001
+ `(latest: ${missed[missed.length - 1]}) — local cron has no catch-up; this is visibility only`,
2002
+ );
2003
+ }
2004
+ const runs = entry.runs as ScheduleRunRecord[] | undefined;
2005
+ if (runs) {
2006
+ for (const run of runs) {
2007
+ console.log(` ${run.firedAt} ${run.trigger.padEnd(6)} exit ${run.exitCode}${run.noopReason ? ` no-op: ${run.noopReason}` : ""}`);
2008
+ }
2009
+ }
2010
+ }
2011
+ return;
2012
+ }
2013
+
2014
+ throw new Error(
2015
+ `Unknown schedule subcommand: ${subcommand} (try: add, list, remove, enable, disable, run, install, uninstall, status)`,
2016
+ );
2017
+ }
2018
+
2019
+ function positionalArg(rest: string[], usage: string): string {
2020
+ const value = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
2021
+ if (!value) throw new Error(usage);
2022
+ return value;
2023
+ }
2024
+
2025
+ function safeNextFiring(cron: string, now: Date): string | null {
2026
+ try {
2027
+ return nextCronFiring(parseCron(cron), now).toISOString();
2028
+ } catch {
2029
+ return null;
2030
+ }
2031
+ }
2032
+
2033
+ /** The provider seam: local ships now; modal/aws arrive as scaffold generators. */
2034
+ function requireLocalScheduleProvider(provider: string) {
2035
+ if (provider === "local") return;
2036
+ if (provider === "modal" || provider === "aws") {
2037
+ throw new Error(
2038
+ `Provider "${provider}" is not yet implemented — it will be a scaffold generator that emits a ` +
2039
+ "deploy-ready artifact whose runner calls the same `schedule run <id>` contract. MVP provider: local.",
2040
+ );
2041
+ }
2042
+ throw new Error(`Unknown provider "${provider}" — not yet implemented. MVP provider: local.`);
2043
+ }
2044
+
2045
+ /**
2046
+ * Resolve how cron should invoke this CLI: the current node binary plus the
2047
+ * entry script (source checkouts need --experimental-strip-types), with
2048
+ * FSGTM_HOME pinned when set so the cron environment sees the same store.
2049
+ */
2050
+ function scheduleCliInvocation(): string {
2051
+ const script = process.argv[1] ? resolve(process.argv[1]) : null;
2052
+ if (!script || !existsSync(script)) {
2053
+ throw new Error("Cannot resolve the fullstackgtm entry point for crontab lines (process.argv[1] is missing).");
2054
+ }
2055
+ const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;
2056
+ const parts = [quote(process.execPath)];
2057
+ if (script.endsWith(".ts")) parts.push("--experimental-strip-types");
2058
+ parts.push(quote(script));
2059
+ const home = process.env.FSGTM_HOME ? `FSGTM_HOME=${quote(process.env.FSGTM_HOME)} ` : "";
2060
+ return home + parts.join(" ");
2061
+ }
2062
+
2063
+ /**
2064
+ * The single provider entry point: execute the scheduled command in-process
2065
+ * (dispatch into the CLI router — never a shell), capture the output tail and
2066
+ * exit code as a run record, and hand the exit code back to the caller. Cron
2067
+ * calls it, cloud providers will call it, and a human running it manually
2068
+ * produces the same record (trigger: manual).
2069
+ *
2070
+ * Governance re-checks happen here, not just at `add` time: the allowlist is
2071
+ * re-validated (schedules.json is user-editable), and a scheduled apply
2072
+ * hard-checks the plan is approved — an unapproved plan records a
2073
+ * plan_not_approved no-op run and does NOT execute. Scheduling never
2074
+ * auto-approves.
2075
+ */
2076
+ async function executeScheduledRun(
2077
+ entry: ScheduleEntry,
2078
+ trigger: ScheduleRunTrigger,
2079
+ ): Promise<ScheduleRunRecord> {
2080
+ const runStore = createFileScheduleRunStore();
2081
+ const firedAt = new Date().toISOString();
2082
+ const noop = async (
2083
+ reason: NonNullable<ScheduleRunRecord["noopReason"]>,
2084
+ exitCode: number,
2085
+ message: string,
2086
+ ): Promise<ScheduleRunRecord> => {
2087
+ const run: ScheduleRunRecord = {
2088
+ scheduleId: entry.id,
2089
+ firedAt,
2090
+ completedAt: new Date().toISOString(),
2091
+ trigger,
2092
+ exitCode,
2093
+ outputTail: message,
2094
+ artifacts: { planIds: [], runLabels: [] },
2095
+ noopReason: reason,
2096
+ };
2097
+ await runStore.record(run);
2098
+ console.error(message);
2099
+ return run;
2100
+ };
2101
+
2102
+ try {
2103
+ validateSchedulableArgv(entry.argv);
2104
+ } catch (error) {
2105
+ return noop("not_schedulable", 1, error instanceof Error ? error.message : String(error));
2106
+ }
2107
+ if (entry.argv[0] === "apply") {
2108
+ const planId = option(entry.argv.slice(1), "--plan-id")!;
2109
+ const stored = await createFilePlanStore().get(planId);
2110
+ const status = stored ? stored.status : "missing";
2111
+ if (status !== "approved") {
2112
+ // The governance gate holding is not a machinery failure: exit 0, with
2113
+ // the refusal recorded on the run for `schedule status` to surface.
2114
+ return noop(
2115
+ "plan_not_approved",
2116
+ 0,
2117
+ `Plan ${planId} is ${status}, not approved — scheduled apply refuses to execute. ` +
2118
+ `Scheduling never auto-approves; review and approve with \`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`,
2119
+ );
2120
+ }
2121
+ }
2122
+
2123
+ // Artifact attribution by store diff: anything new in the plan store or the
2124
+ // enrich run store after the command ran was produced by this firing.
2125
+ const planStore = createFilePlanStore();
2126
+ const plansBefore = new Set((await planStore.list()).map((stored) => stored.plan.id));
2127
+ const enrichStore = createFileEnrichRunStore();
2128
+ const enrichBefore = new Set((await enrichStore.list()).map((run) => run.runLabel));
2129
+
2130
+ const lines: string[] = [];
2131
+ const originalLog = console.log;
2132
+ const originalError = console.error;
2133
+ const tee =
2134
+ (original: (...args: unknown[]) => void) =>
2135
+ (...args: unknown[]) => {
2136
+ for (const line of args.map(String).join(" ").split("\n")) lines.push(line);
2137
+ original(...args);
2138
+ };
2139
+ console.log = tee(originalLog);
2140
+ console.error = tee(originalError);
2141
+ const priorExitCode = process.exitCode;
2142
+ process.exitCode = undefined;
2143
+ let exitCode = 0;
2144
+ try {
2145
+ await runCli([...entry.argv]);
2146
+ exitCode = typeof process.exitCode === "number" ? process.exitCode : 0;
2147
+ } catch (error) {
2148
+ exitCode = 1;
2149
+ lines.push(error instanceof Error ? error.message : String(error));
2150
+ } finally {
2151
+ console.log = originalLog;
2152
+ console.error = originalError;
2153
+ process.exitCode = priorExitCode;
2154
+ }
2155
+
2156
+ const planIds = (await planStore.list())
2157
+ .map((stored) => stored.plan.id)
2158
+ .filter((planId) => !plansBefore.has(planId));
2159
+ const runLabels = (await enrichStore.list())
2160
+ .map((run) => run.runLabel)
2161
+ .filter((runLabel) => !enrichBefore.has(runLabel));
2162
+ const run: ScheduleRunRecord = {
2163
+ scheduleId: entry.id,
2164
+ firedAt,
2165
+ completedAt: new Date().toISOString(),
2166
+ trigger,
2167
+ exitCode,
2168
+ outputTail: lines.slice(-50).join("\n"),
2169
+ artifacts: { planIds, runLabels },
2170
+ };
2171
+ await runStore.record(run);
2172
+ return run;
2173
+ }
2174
+
1734
2175
  /**
1735
2176
  * The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
1736
2177
  * ambiguous — do NOT blind-create), exit 1 = error. Built for sync jobs and
@@ -2806,8 +3247,8 @@ export async function runCli(argv: string[]) {
2806
3247
  }
2807
3248
  // Commands without bespoke help fall back to the top-level usage on --help
2808
3249
  // instead of executing (audit used to silently run the sample audit).
2809
- // call/market/enrich/bulk-update print their own richer help.
2810
- if (!["call", "market", "enrich", "bulk-update"].includes(command) && (args.includes("--help") || args.includes("-h"))) {
3250
+ // call/market/enrich/bulk-update/schedule print their own richer help.
3251
+ if (!["call", "market", "enrich", "bulk-update", "schedule"].includes(command) && (args.includes("--help") || args.includes("-h"))) {
2811
3252
  console.log(usage());
2812
3253
  return;
2813
3254
  }
@@ -2876,6 +3317,10 @@ export async function runCli(argv: string[]) {
2876
3317
  await enrichCommand(args);
2877
3318
  return;
2878
3319
  }
3320
+ if (command === "schedule") {
3321
+ await scheduleCommand(args);
3322
+ return;
3323
+ }
2879
3324
  if (command === "profiles") {
2880
3325
  profilesCommand(args);
2881
3326
  return;
package/src/index.ts CHANGED
@@ -254,6 +254,32 @@ export {
254
254
  type VendorScale,
255
255
  } from "./marketScale.ts";
256
256
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
257
+ export {
258
+ computeMissedFirings,
259
+ createFileScheduleRunStore,
260
+ createFileScheduleStore,
261
+ cronMatches,
262
+ crontabSentinels,
263
+ expectedFirings,
264
+ nextCronFiring,
265
+ parseCron,
266
+ renderManagedBlock,
267
+ replaceManagedBlock,
268
+ scheduleId,
269
+ scheduleRunsDir,
270
+ schedulesPath,
271
+ systemCrontabIo,
272
+ tokenizeCommand,
273
+ validateSchedulableArgv,
274
+ type CronExpression,
275
+ type CrontabIo,
276
+ type ScheduleEntry,
277
+ type ScheduleProvider,
278
+ type ScheduleRunRecord,
279
+ type ScheduleRunStore,
280
+ type ScheduleRunTrigger,
281
+ type ScheduleStore,
282
+ } from "./schedule.ts";
257
283
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
258
284
  export type {
259
285
  ApprovalStatus,