fullstackgtm 0.23.1 → 0.24.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 CHANGED
@@ -5,6 +5,70 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.24.0] — 2026-06-12
9
+
10
+ ### Added
11
+
12
+ - **The schedule layer (MVP)** — the horizontal scheduler (spec:
13
+ docs/schedule.md in the monorepo): declare once that a CLI command should
14
+ run on a cadence, materialize the timers through a provider, and keep an
15
+ append-only run history. No feature namespace owns cron logic.
16
+ - The governance invariant: **scheduling never auto-approves.** The
17
+ schedulable allowlist is read/plan-side only (`audit`, `snapshot`,
18
+ `enrich append|refresh`, `market capture|refresh`, `suggest`, `report`,
19
+ `doctor`) — unattended runs accumulate proposals, never CRM writes.
20
+ `apply` is schedulable ONLY as `apply --plan-id <id>`, and every firing
21
+ re-checks the plan's status is `approved`: an unapproved plan records a
22
+ `plan_not_approved` no-op run instead of executing, with no relaxing
23
+ flag. Argv must resolve to a known fullstackgtm command — validated at
24
+ `add` time AND re-checked at run time; arbitrary shell is not
25
+ schedulable (execution dispatches in-process, never through a shell).
26
+ - `schedule add "<command>" --cron "<expr>" [--label <name>]
27
+ [--provider local]` — allowlist + cron validation, then a declarative
28
+ entry in `~/.fullstackgtm/profiles/<profile>/schedules.json`; nothing
29
+ touches the crontab until `install` (the plan/apply split: declare,
30
+ then deliberately activate). Plus `list`, `remove`, `enable|disable`.
31
+ - `schedule run <id>` — the single entry point every provider's timer
32
+ calls (and a human can call: `trigger: manual` vs `cron`). Executes the
33
+ command in-process, records exit code, ~50-line output tail, and
34
+ artifacts (plan ids / enrich run labels, attributed by store diff)
35
+ under `<profile>/schedule/runs/<id>/`, and propagates the exit code.
36
+ - `schedule install|uninstall [--provider local]` — renders enabled
37
+ entries into a sentinel-delimited managed crontab block
38
+ (`# >>> fullstackgtm <profile> >>>` … `# <<< fullstackgtm <profile> <<<`);
39
+ re-install replaces the block wholesale and never touches lines outside
40
+ the sentinels; uninstall removes only the block. Crontab access is an
41
+ injected seam (`CrontabIo`) — tests never read or write a real crontab.
42
+ Providers beyond `local` (`modal`, `aws`) are refused as "not yet
43
+ implemented"; they arrive later as scaffold generators calling the same
44
+ `schedule run <id>` contract.
45
+ - `schedule status [<id>] [--runs <n>]` — next firing per the cron
46
+ expression, last run + success/failure streak + artifacts, and missed
47
+ firings (expected-vs-actual since the last run record; visibility only,
48
+ no catch-up — local cron skips silently when the laptop sleeps).
49
+ - Minimal dependency-free 5-field cron parser: `*`, lists, ranges, steps,
50
+ day-of-week 7=Sunday, vixie day-of-month/day-of-week OR semantics;
51
+ clear validation errors at `add` time (including expressions that never
52
+ fire), next-firing computation for `status`.
53
+
54
+ ## [0.23.2] — 2026-06-12
55
+
56
+ Documentation catch-up: the README, llms.txt, and docs/api.md now cover
57
+ everything that shipped 0.19–0.23. (No code changes.)
58
+
59
+ ### Fixed
60
+
61
+ - README: new "Routine maintenance as governed verbs" section (`bulk-update`,
62
+ `dedupe`, `reassign`, `fix`) and a market-map paragraph for `market overlay`
63
+ (directives from your own ground truth) and `market scale` (citable,
64
+ calibrated size estimates).
65
+ - llms.txt: governed-write-verbs invariants block; overlay/scale invariants
66
+ added to the market-map block.
67
+ - docs/api.md: CLI command list completed (write verbs, `enrich`, market
68
+ `overlay`/`scale`); new "Governed write verbs" and "Enrich" export
69
+ sections; market-map section updated for 0.16–0.23 exports incl.
70
+ `computeDirectives`/`directivesToPlan` and `computeScaleIndex`.
71
+
8
72
  ## [0.23.1] — 2026-06-12
9
73
 
10
74
  ### Fixed
package/README.md CHANGED
@@ -109,6 +109,22 @@ npx fullstackgtm report --provider hubspot --client "Acme" --out acme-health.htm
109
109
 
110
110
  `report` renders the same audit as a deliverable — severity counts up front, a prose summary, per-rule detail with example records, and next steps — as markdown or self-contained HTML (printable, emailable, no external assets).
111
111
 
112
+ ## Routine maintenance as governed verbs: bulk-update, dedupe, reassign, fix
113
+
114
+ The maintenance work RevOps actually does in bulk — backfills, book transfers, duplicate sweeps, "just fix everything that rule found" — gets first-class verbs. Each one *builds a plan*; nothing executes without the same approve → apply gauntlet as everything else.
115
+
116
+ ```bash
117
+ fullstackgtm bulk-update deal --where "stage=closedwon" --where "amount:empty" \
118
+ --set amount=from:account.annualrevenue --save # per-record derived values; empty sources skipped, never guessed
119
+ fullstackgtm dedupe account --key domain --keep richest --save # one merge_records op per duplicate group
120
+ fullstackgtm reassign --from 411 --to 902 --except-deal-stage closing --save # ownership handoff playbook
121
+ fullstackgtm fix --rule missing-deal-owner --provider hubspot --yes # audit one rule → suggest → approve → apply, one command
122
+ ```
123
+
124
+ `bulk-update` filters the snapshot (`=`, `!=`, `~` substring, `:empty`/`:notempty`, `|` any-of, relational pseudo-fields like `account.domain` or `openDealStages`) into a dry-run patch plan — and **the full filter is re-verified per record at apply time**, with mid-apply rechecks, so a record that stopped matching between audit and apply is skipped, not clobbered. Equality filters double as preconditions; `--require` adds explicit ones; `--guard` asserts cross-record conditions; `--max-operations` caps blast radius. `--set field=from:<sourceField>` derives values per record; `--archive` refuses records whose identity key (account domain, contact email) is shared with another record — that's a duplicate, and duplicates are merged with `dedupe`, not archived around.
125
+
126
+ `dedupe` finds duplicate groups by normalized identity key and emits one `merge_records` operation per group with a deterministic survivor (`richest` = most populated fields, ties to lowest id; `oldest`). Merges stay irreversible-and-therefore-low-confidence-capped on approval, exactly like merge suggestions from the audit. `reassign` is the ownership-handoff playbook: one plan per object type, extra scoping account-lifted to deals and contacts, and `--except-deal-stage` excludes both deals in that stage and every record whose account has an open deal in it. `fix` is the one-shot composite for a single rule: audit → save → suggest → approve suggestion-backed operations at the confidence bar → with `--yes`, apply and print the stage-by-stage summary; without it, stop after approval and print the apply command.
127
+
112
128
  ## The market map: the category, observed
113
129
 
114
130
  Your CRM records what happened in your own deals; nothing records the shape of the category you sell into. The **market map** does: vendors and a claim taxonomy live in a reviewable `market.config.json`, vendor pages are captured into a content-addressed cache, every vendor × claim cell gets a messaging-intensity reading (LOUD / QUIET / ABSENT — with UNOBSERVABLE for failed captures, never a fake absence), and deterministic front states fall out per claim: open, contested, owned, saturated.
@@ -126,6 +142,8 @@ The discipline matches the rest of the tool. Intensity readings are *proposals*
126
142
 
127
143
  `market axes` is for earning a strategic 2×2 instead of asserting one: PCA over the intensity matrix (PC1 = the category's own primary axis, PC2 = the most differentiating direction orthogonal to it), triangulation of your configured axes against the data, and an orthogonality screen that flags two axes that are secretly one. Axes are claim-scoring rubrics in the config; the report renders the primary pair as the strategic map. Captures and observations are profile-scoped (`~/.fullstackgtm/market/<category>`), so one client's category intel never bleeds into another's.
128
144
 
145
+ Two more derivations close the loop from map to action. `market overlay --snapshot <crm.json> [--calls <files>]` joins the observation store against your own ground truth — which claims and vendors actually come up in your deals and call transcripts (deterministic word-boundary matching, no LLM) — and emits OCCUPY / PROMOTE / URGENT / RETREAT directives, each carrying at least one observation and one CRM stat with its sample size; below the evidence thresholds the honest answer is *no directive*. `--save` turns directives into approval-gated `create_task` operations through the normal plan chain. `market scale` estimates each vendor's size from **citable** signals you record in the config (G2 review counts, LinkedIn headcount, revenue claims — each with source URL, verbatim quote, and caveat): every signal is converted into revenue space first, calibrated within the vendor set and stratified by ACV band, then combined as a weighted geometric mean with the uncertainty spread and calibration table disclosed. The report's strategic-map bubbles become area-proportional to estimated revenue share — captioned "citable but NOT audited" — and without signals, dots are uniform and the caption says size carries no meaning.
146
+
129
147
  ## Governed enrichment: a diff you approve before third-party data touches your CRM
130
148
 
131
149
  Every enrichment vendor ships fire-and-forget writeback. The **enrich layer** inverts that: declare once which fields come from which source under which conflict policy (`enrich.config.json` — sources, ordered match keys, field mappings, policy), then `enrich append` fills the gaps and `enrich refresh` keeps them current — with every write passing through the normal dry-run → approval → apply contract, and every value traceable to the source payload that produced it.
@@ -141,6 +159,24 @@ fullstackgtm enrich status --runs # last run per sourc
141
159
 
142
160
  Matching is deterministic: ordered keys, unique hit wins, zero hits falls through to the next key, and multiple hits are never guessed away — they skip (recorded with candidate ids) or flow into the existing `suggest` → `plans approve` chain as `requires_human_record_selection` placeholders. The MVP conflict policy is `never`: enrich only fills blank fields, and `refresh` only re-touches fields its own run-store ledger proves it stamped (per-record/per-field `enrichedAt`, profile-scoped, never written into your portal as custom properties). The `system-only` and `always` rungs of the ladder are phase 2 and are refused explicitly, not silently accepted. Recurring execution belongs to the scheduler — enrich owns no cron logic.
143
161
 
162
+ ## Schedules: declare a cadence once, keep the governance contract under automation
163
+
164
+ Everything the CLI produces is accurate the moment it runs and silently stale afterward. The **schedule layer** is the horizontal fix — any read/plan-side command on a cron cadence, one component, every verb (no feature owns its own cron logic):
165
+
166
+ ```bash
167
+ fullstackgtm schedule add "enrich refresh --source apollo --save" --cron "0 6 * * 1" --label weekly-apollo
168
+ fullstackgtm schedule add "audit --provider hubspot --save" --cron "0 2 * * *" # nightly drift baseline
169
+ fullstackgtm schedule list # declarative entries; nothing runs yet
170
+ fullstackgtm schedule install # materialize enabled entries into a managed crontab block
171
+ fullstackgtm schedule run <id> # execute now; same run record a cron firing produces
172
+ fullstackgtm schedule status --runs 5 # last runs, exit codes, artifacts, next + missed firings
173
+ fullstackgtm schedule uninstall # remove the managed block, touch nothing else
174
+ ```
175
+
176
+ **Scheduling never auto-approves.** Schedulable commands are read/plan-side only — `audit`, `snapshot`, `enrich append|refresh`, `market capture|refresh`, `suggest`, `report`, `doctor` — so unattended runs accumulate *proposals* (plans in the queue, run records, reports), never CRM writes. `apply` is schedulable only as `apply --plan-id <id>`, and every firing re-checks the plan's status is approved: an unapproved plan records a `plan_not_approved` no-op run instead of executing, and no flag relaxes this. Arbitrary shell is not schedulable — an entry's argv must resolve to a known fullstackgtm command (validated at `add` time and re-checked at run time), and the crontab line you audit is always `fullstackgtm schedule run <id>` and nothing else.
177
+
178
+ `install` renders enabled entries into a sentinel-delimited block (`# >>> fullstackgtm <profile> >>>` … `# <<< fullstackgtm <profile> <<<`) in your user crontab; re-install replaces the block wholesale and never touches lines outside it. Honest limitation: local cron has no catch-up — a laptop asleep at firing time means a missed run. `schedule status` surfaces missed firings by comparing expected-vs-actual run history, so the gap is at least visible. Entries are provider-agnostic; cloud providers (Modal, AWS) arrive as scaffold generators that call the same `schedule run <id>` contract, and are refused as "not yet implemented" until then.
179
+
144
180
  ### Working across organizations
145
181
 
146
182
  Consultants and fractional operators hold credentials for several CRMs at once. A profile scopes stored logins *and* stored plans to one organization:
package/dist/cli.js CHANGED
@@ -27,6 +27,7 @@ import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
27
27
  import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
28
28
  import { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
29
29
  import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, } from "./enrichApollo.js";
30
+ import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, scheduleId, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, } from "./schedule.js";
30
31
  import { resolveRecord } from "./resolve.js";
31
32
  import { buildBulkUpdatePlan } from "./bulkUpdate.js";
32
33
  import { buildDedupePlan } from "./dedupe.js";
@@ -134,6 +135,16 @@ Usage:
134
135
  value) → with --yes, apply through the provider and print a
135
136
  stage-by-stage summary. Without --yes it stops after
136
137
  approval and prints the apply command.
138
+ fullstackgtm schedule add "<command>" --cron "<expr>" [--label <name>] [--provider local]
139
+ fullstackgtm schedule list | remove <id> | enable <id> | disable <id> | run <id>
140
+ fullstackgtm schedule install | uninstall [--provider local]
141
+ fullstackgtm schedule status [<id>] [--runs <n>]
142
+ declare a cadence for any read/plan-side command (audit,
143
+ snapshot, enrich append|refresh, market capture|refresh,
144
+ suggest, report, doctor); install materializes enabled
145
+ entries into a sentinel-delimited managed crontab block.
146
+ Scheduling never auto-approves: apply is schedulable only
147
+ as apply --plan-id <id>, re-checked approved at run time.
137
148
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
138
149
  derive values for requires_human_* placeholders
139
150
  from snapshot evidence, with confidence + reasons
@@ -1532,6 +1543,388 @@ async function enrichStatus(store, rest, configFile) {
1532
1543
  }
1533
1544
  }
1534
1545
  }
1546
+ /**
1547
+ * The schedule layer: declarative cadences for read/plan-side commands,
1548
+ * materialized through a provider (MVP: the user crontab), with an
1549
+ * append-only run history. The governance invariant — scheduling never
1550
+ * auto-approves — is enforced at `add` time and re-checked at `run` time.
1551
+ * Spec: docs/schedule.md (monorepo).
1552
+ */
1553
+ async function scheduleCommand(args) {
1554
+ const [subcommand, ...rest] = args;
1555
+ // Catch --help BEFORE any store read, credential access, or crontab
1556
+ // round-trip (the enrich/market early-catch pattern — `schedule install
1557
+ // --help` must never touch the crontab).
1558
+ if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
1559
+ console.log(`Usage:
1560
+ schedule add "<fullstackgtm command>" --cron "<expr>" [--label <name>] [--provider local]
1561
+ schedule list [--json]
1562
+ schedule remove <id>
1563
+ schedule enable <id> | disable <id>
1564
+ schedule run <id> execute now; the single entry point every provider's timer calls
1565
+ schedule install [--provider local] render enabled entries into the managed crontab block
1566
+ schedule uninstall [--provider local] remove the managed block (nothing else)
1567
+ schedule status [<id>] [--runs <n>] [--json]
1568
+
1569
+ add validates the command against the schedulable allowlist — read/plan-side
1570
+ only: audit, snapshot, enrich append|refresh, market capture|refresh,
1571
+ suggest, report, doctor — and parses the 5-field cron expression (*, lists,
1572
+ ranges, steps), but touches NO crontab: entries are declarative until install
1573
+ materializes them (the plan/apply split — declare, then deliberately
1574
+ activate).
1575
+
1576
+ Scheduling never auto-approves. apply is schedulable ONLY as
1577
+ \`apply --plan-id <id>\`, and every firing re-checks the plan's status is
1578
+ approved — an unapproved plan records a plan_not_approved no-op run instead
1579
+ of executing. There is no flag that relaxes this. Unattended runs accumulate
1580
+ proposals (plans, run records, reports), never surprise CRM writes.
1581
+
1582
+ install renders all enabled entries into a sentinel-delimited block
1583
+ (# >>> fullstackgtm <profile> >>> ... # <<< fullstackgtm <profile> <<<) in
1584
+ the user crontab; each line invokes \`schedule run <id> --profile <profile>\`
1585
+ and nothing else. Re-install replaces the block wholesale and never touches
1586
+ lines outside the sentinels. Providers other than local (modal, aws) are not
1587
+ yet implemented — they will be scaffold generators calling the same
1588
+ \`schedule run\` contract.
1589
+
1590
+ run executes the command in-process and records the outcome (exit code,
1591
+ output tail, artifacts: plan ids / enrich run labels) under the profile's
1592
+ schedule/runs/<id>/; the exit code propagates. Manual runs record
1593
+ trigger: manual. status shows next firing and surfaces missed firings
1594
+ (laptop asleep = cron skips silently; visibility only, no catch-up).`);
1595
+ return;
1596
+ }
1597
+ const store = createFileScheduleStore();
1598
+ const runStore = createFileScheduleRunStore();
1599
+ if (subcommand === "add") {
1600
+ const commandText = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
1601
+ if (!commandText) {
1602
+ throw new Error('Usage: fullstackgtm schedule add "<fullstackgtm command>" --cron "<expr>" [--label <name>] [--provider local]');
1603
+ }
1604
+ const cronText = option(rest, "--cron");
1605
+ if (!cronText)
1606
+ throw new Error('schedule add requires --cron "<minute hour day-of-month month day-of-week>"');
1607
+ requireLocalScheduleProvider(option(rest, "--provider") ?? "local");
1608
+ let argv = tokenizeCommand(commandText);
1609
+ if (argv[0] === "fullstackgtm")
1610
+ argv = argv.slice(1);
1611
+ validateSchedulableArgv(argv);
1612
+ const cron = parseCron(cronText);
1613
+ const next = nextCronFiring(cron, new Date()); // also rejects never-firing expressions
1614
+ const createdAt = new Date().toISOString();
1615
+ const label = option(rest, "--label") ??
1616
+ argv.filter((arg) => !arg.startsWith("--")).slice(0, 2).join("-").replace(/[^\w.-]+/g, "-");
1617
+ const entry = {
1618
+ id: scheduleId(label, cron.source, argv, createdAt),
1619
+ label,
1620
+ argv,
1621
+ cron: cron.source,
1622
+ provider: "local",
1623
+ enabled: true,
1624
+ createdAt,
1625
+ };
1626
+ await store.add(entry);
1627
+ console.log(`Added ${entry.id} "${label}": \`${argv.join(" ")}\` at \`${cron.source}\` (next firing: ${next.toISOString()}).`);
1628
+ console.log("Declarative until materialized — activate with `fullstackgtm schedule install`.");
1629
+ return;
1630
+ }
1631
+ if (subcommand === "list") {
1632
+ const entries = await store.list();
1633
+ const now = new Date();
1634
+ const withNext = entries.map((entry) => ({
1635
+ ...entry,
1636
+ nextFiring: entry.enabled ? safeNextFiring(entry.cron, now) : null,
1637
+ }));
1638
+ if (rest.includes("--json")) {
1639
+ console.log(JSON.stringify(withNext, null, 2));
1640
+ return;
1641
+ }
1642
+ if (entries.length === 0) {
1643
+ console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
1644
+ return;
1645
+ }
1646
+ for (const entry of withNext) {
1647
+ console.log(`${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron.padEnd(14)} next ${entry.nextFiring ?? "—"} ${entry.label}: ${entry.argv.join(" ")}`);
1648
+ }
1649
+ console.log("\nEntries are declarative — `schedule install` materializes enabled ones into the crontab.");
1650
+ return;
1651
+ }
1652
+ if (subcommand === "remove") {
1653
+ const id = positionalArg(rest, "Usage: fullstackgtm schedule remove <id>");
1654
+ if (!(await store.remove(id)))
1655
+ throw new Error(`No schedule with id ${id}.`);
1656
+ console.log(`Removed ${id}. Run \`fullstackgtm schedule install\` to update the crontab block.`);
1657
+ return;
1658
+ }
1659
+ if (subcommand === "enable" || subcommand === "disable") {
1660
+ const id = positionalArg(rest, `Usage: fullstackgtm schedule ${subcommand} <id>`);
1661
+ const entry = await store.setEnabled(id, subcommand === "enable");
1662
+ console.log(`${subcommand === "enable" ? "Enabled" : "Disabled"} ${entry.id} "${entry.label}". ` +
1663
+ "Run `fullstackgtm schedule install` to update the crontab block.");
1664
+ return;
1665
+ }
1666
+ if (subcommand === "run") {
1667
+ const id = positionalArg(rest, "Usage: fullstackgtm schedule run <id>");
1668
+ const entry = await store.get(id);
1669
+ if (!entry)
1670
+ throw new Error(`No schedule with id ${id} in profile "${activeProfile()}".`);
1671
+ if (!entry.enabled) {
1672
+ throw new Error(`Schedule ${id} is disabled — enable it with \`fullstackgtm schedule enable ${id}\`.`);
1673
+ }
1674
+ const trigger = (option(rest, "--trigger") ?? "manual");
1675
+ if (trigger !== "cron" && trigger !== "manual") {
1676
+ throw new Error("--trigger must be cron or manual");
1677
+ }
1678
+ const run = await executeScheduledRun(entry, trigger);
1679
+ if (run.exitCode !== 0)
1680
+ process.exitCode = run.exitCode;
1681
+ return;
1682
+ }
1683
+ if (subcommand === "install" || subcommand === "uninstall") {
1684
+ requireLocalScheduleProvider(option(rest, "--provider") ?? "local");
1685
+ const profile = activeProfile();
1686
+ const io = systemCrontabIo();
1687
+ const existing = io.read();
1688
+ if (subcommand === "uninstall") {
1689
+ io.write(replaceManagedBlock(existing, profile, null));
1690
+ console.log(`Removed the fullstackgtm managed crontab block for profile "${profile}" (lines outside the sentinels untouched).`);
1691
+ return;
1692
+ }
1693
+ const enabled = (await store.list()).filter((entry) => entry.enabled && entry.provider === "local");
1694
+ if (enabled.length === 0) {
1695
+ io.write(replaceManagedBlock(existing, profile, null));
1696
+ console.log(`No enabled schedules for profile "${profile}" — removed the managed block.`);
1697
+ return;
1698
+ }
1699
+ const block = renderManagedBlock(profile, enabled, scheduleCliInvocation());
1700
+ io.write(replaceManagedBlock(existing, profile, block));
1701
+ console.log(`Installed ${enabled.length} schedule(s) into the managed crontab block for profile "${profile}". ` +
1702
+ "Re-running install replaces the block wholesale; `schedule uninstall` removes it.");
1703
+ return;
1704
+ }
1705
+ if (subcommand === "status") {
1706
+ const id = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
1707
+ const entries = await store.list();
1708
+ const selected = id ? entries.filter((entry) => entry.id === id) : entries;
1709
+ if (id && selected.length === 0)
1710
+ throw new Error(`No schedule with id ${id}.`);
1711
+ if (selected.length === 0) {
1712
+ console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
1713
+ return;
1714
+ }
1715
+ const showRuns = numericOption(rest, "--runs");
1716
+ const now = new Date();
1717
+ const report = [];
1718
+ for (const entry of selected) {
1719
+ const runs = await runStore.list(entry.id);
1720
+ const last = runs.length > 0 ? runs[runs.length - 1] : null;
1721
+ let streak = 0;
1722
+ for (let index = runs.length - 1; index >= 0; index -= 1) {
1723
+ if ((runs[index].exitCode === 0) !== (last.exitCode === 0))
1724
+ break;
1725
+ streak += 1;
1726
+ }
1727
+ const missed = computeMissedFirings(entry, runs, now);
1728
+ report.push({
1729
+ id: entry.id,
1730
+ label: entry.label,
1731
+ argv: entry.argv,
1732
+ cron: entry.cron,
1733
+ enabled: entry.enabled,
1734
+ nextFiring: entry.enabled ? safeNextFiring(entry.cron, now) : null,
1735
+ lastRun: last
1736
+ ? {
1737
+ firedAt: last.firedAt,
1738
+ trigger: last.trigger,
1739
+ exitCode: last.exitCode,
1740
+ noopReason: last.noopReason,
1741
+ artifacts: last.artifacts,
1742
+ }
1743
+ : null,
1744
+ streak: last ? { outcome: last.exitCode === 0 ? "success" : "failure", length: streak } : null,
1745
+ missedFirings: missed.missed.map((firing) => firing.toISOString()),
1746
+ missedFiringsCapped: missed.capped,
1747
+ runs: showRuns !== undefined ? runs.slice(-showRuns) : undefined,
1748
+ });
1749
+ }
1750
+ if (rest.includes("--json")) {
1751
+ console.log(JSON.stringify(report, null, 2));
1752
+ return;
1753
+ }
1754
+ for (const entry of report) {
1755
+ const last = entry.lastRun;
1756
+ const streak = entry.streak;
1757
+ const missed = entry.missedFirings;
1758
+ console.log(`${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron} ${entry.label}: ${entry.argv.join(" ")}`);
1759
+ console.log(` next: ${entry.nextFiring ?? "— (disabled)"}`);
1760
+ if (last) {
1761
+ const artifacts = [
1762
+ ...last.artifacts.planIds.map((planId) => `plan ${planId}`),
1763
+ ...last.artifacts.runLabels.map((runLabel) => `run ${runLabel}`),
1764
+ ];
1765
+ console.log(` last: ${last.firedAt} (${last.trigger}) exit ${last.exitCode}` +
1766
+ (last.noopReason ? ` — no-op: ${last.noopReason}` : "") +
1767
+ (artifacts.length ? ` — ${artifacts.join(", ")}` : ""));
1768
+ console.log(` streak: ${streak.length} ${streak.outcome}(s)`);
1769
+ }
1770
+ else {
1771
+ console.log(" last: never fired");
1772
+ }
1773
+ if (missed.length > 0) {
1774
+ console.log(` missed: ${missed.length}${entry.missedFiringsCapped ? "+" : ""} expected firing(s) with no run record ` +
1775
+ `(latest: ${missed[missed.length - 1]}) — local cron has no catch-up; this is visibility only`);
1776
+ }
1777
+ const runs = entry.runs;
1778
+ if (runs) {
1779
+ for (const run of runs) {
1780
+ console.log(` ${run.firedAt} ${run.trigger.padEnd(6)} exit ${run.exitCode}${run.noopReason ? ` no-op: ${run.noopReason}` : ""}`);
1781
+ }
1782
+ }
1783
+ }
1784
+ return;
1785
+ }
1786
+ throw new Error(`Unknown schedule subcommand: ${subcommand} (try: add, list, remove, enable, disable, run, install, uninstall, status)`);
1787
+ }
1788
+ function positionalArg(rest, usage) {
1789
+ const value = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
1790
+ if (!value)
1791
+ throw new Error(usage);
1792
+ return value;
1793
+ }
1794
+ function safeNextFiring(cron, now) {
1795
+ try {
1796
+ return nextCronFiring(parseCron(cron), now).toISOString();
1797
+ }
1798
+ catch {
1799
+ return null;
1800
+ }
1801
+ }
1802
+ /** The provider seam: local ships now; modal/aws arrive as scaffold generators. */
1803
+ function requireLocalScheduleProvider(provider) {
1804
+ if (provider === "local")
1805
+ return;
1806
+ if (provider === "modal" || provider === "aws") {
1807
+ throw new Error(`Provider "${provider}" is not yet implemented — it will be a scaffold generator that emits a ` +
1808
+ "deploy-ready artifact whose runner calls the same `schedule run <id>` contract. MVP provider: local.");
1809
+ }
1810
+ throw new Error(`Unknown provider "${provider}" — not yet implemented. MVP provider: local.`);
1811
+ }
1812
+ /**
1813
+ * Resolve how cron should invoke this CLI: the current node binary plus the
1814
+ * entry script (source checkouts need --experimental-strip-types), with
1815
+ * FSGTM_HOME pinned when set so the cron environment sees the same store.
1816
+ */
1817
+ function scheduleCliInvocation() {
1818
+ const script = process.argv[1] ? resolve(process.argv[1]) : null;
1819
+ if (!script || !existsSync(script)) {
1820
+ throw new Error("Cannot resolve the fullstackgtm entry point for crontab lines (process.argv[1] is missing).");
1821
+ }
1822
+ const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
1823
+ const parts = [quote(process.execPath)];
1824
+ if (script.endsWith(".ts"))
1825
+ parts.push("--experimental-strip-types");
1826
+ parts.push(quote(script));
1827
+ const home = process.env.FSGTM_HOME ? `FSGTM_HOME=${quote(process.env.FSGTM_HOME)} ` : "";
1828
+ return home + parts.join(" ");
1829
+ }
1830
+ /**
1831
+ * The single provider entry point: execute the scheduled command in-process
1832
+ * (dispatch into the CLI router — never a shell), capture the output tail and
1833
+ * exit code as a run record, and hand the exit code back to the caller. Cron
1834
+ * calls it, cloud providers will call it, and a human running it manually
1835
+ * produces the same record (trigger: manual).
1836
+ *
1837
+ * Governance re-checks happen here, not just at `add` time: the allowlist is
1838
+ * re-validated (schedules.json is user-editable), and a scheduled apply
1839
+ * hard-checks the plan is approved — an unapproved plan records a
1840
+ * plan_not_approved no-op run and does NOT execute. Scheduling never
1841
+ * auto-approves.
1842
+ */
1843
+ async function executeScheduledRun(entry, trigger) {
1844
+ const runStore = createFileScheduleRunStore();
1845
+ const firedAt = new Date().toISOString();
1846
+ const noop = async (reason, exitCode, message) => {
1847
+ const run = {
1848
+ scheduleId: entry.id,
1849
+ firedAt,
1850
+ completedAt: new Date().toISOString(),
1851
+ trigger,
1852
+ exitCode,
1853
+ outputTail: message,
1854
+ artifacts: { planIds: [], runLabels: [] },
1855
+ noopReason: reason,
1856
+ };
1857
+ await runStore.record(run);
1858
+ console.error(message);
1859
+ return run;
1860
+ };
1861
+ try {
1862
+ validateSchedulableArgv(entry.argv);
1863
+ }
1864
+ catch (error) {
1865
+ return noop("not_schedulable", 1, error instanceof Error ? error.message : String(error));
1866
+ }
1867
+ if (entry.argv[0] === "apply") {
1868
+ const planId = option(entry.argv.slice(1), "--plan-id");
1869
+ const stored = await createFilePlanStore().get(planId);
1870
+ const status = stored ? stored.status : "missing";
1871
+ if (status !== "approved") {
1872
+ // The governance gate holding is not a machinery failure: exit 0, with
1873
+ // the refusal recorded on the run for `schedule status` to surface.
1874
+ return noop("plan_not_approved", 0, `Plan ${planId} is ${status}, not approved — scheduled apply refuses to execute. ` +
1875
+ `Scheduling never auto-approves; review and approve with \`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`);
1876
+ }
1877
+ }
1878
+ // Artifact attribution by store diff: anything new in the plan store or the
1879
+ // enrich run store after the command ran was produced by this firing.
1880
+ const planStore = createFilePlanStore();
1881
+ const plansBefore = new Set((await planStore.list()).map((stored) => stored.plan.id));
1882
+ const enrichStore = createFileEnrichRunStore();
1883
+ const enrichBefore = new Set((await enrichStore.list()).map((run) => run.runLabel));
1884
+ const lines = [];
1885
+ const originalLog = console.log;
1886
+ const originalError = console.error;
1887
+ const tee = (original) => (...args) => {
1888
+ for (const line of args.map(String).join(" ").split("\n"))
1889
+ lines.push(line);
1890
+ original(...args);
1891
+ };
1892
+ console.log = tee(originalLog);
1893
+ console.error = tee(originalError);
1894
+ const priorExitCode = process.exitCode;
1895
+ process.exitCode = undefined;
1896
+ let exitCode = 0;
1897
+ try {
1898
+ await runCli([...entry.argv]);
1899
+ exitCode = typeof process.exitCode === "number" ? process.exitCode : 0;
1900
+ }
1901
+ catch (error) {
1902
+ exitCode = 1;
1903
+ lines.push(error instanceof Error ? error.message : String(error));
1904
+ }
1905
+ finally {
1906
+ console.log = originalLog;
1907
+ console.error = originalError;
1908
+ process.exitCode = priorExitCode;
1909
+ }
1910
+ const planIds = (await planStore.list())
1911
+ .map((stored) => stored.plan.id)
1912
+ .filter((planId) => !plansBefore.has(planId));
1913
+ const runLabels = (await enrichStore.list())
1914
+ .map((run) => run.runLabel)
1915
+ .filter((runLabel) => !enrichBefore.has(runLabel));
1916
+ const run = {
1917
+ scheduleId: entry.id,
1918
+ firedAt,
1919
+ completedAt: new Date().toISOString(),
1920
+ trigger,
1921
+ exitCode,
1922
+ outputTail: lines.slice(-50).join("\n"),
1923
+ artifacts: { planIds, runLabels },
1924
+ };
1925
+ await runStore.record(run);
1926
+ return run;
1927
+ }
1535
1928
  /**
1536
1929
  * The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
1537
1930
  * ambiguous — do NOT blind-create), exit 1 = error. Built for sync jobs and
@@ -2506,8 +2899,8 @@ export async function runCli(argv) {
2506
2899
  }
2507
2900
  // Commands without bespoke help fall back to the top-level usage on --help
2508
2901
  // instead of executing (audit used to silently run the sample audit).
2509
- // call/market/enrich/bulk-update print their own richer help.
2510
- if (!["call", "market", "enrich", "bulk-update"].includes(command) && (args.includes("--help") || args.includes("-h"))) {
2902
+ // call/market/enrich/bulk-update/schedule print their own richer help.
2903
+ if (!["call", "market", "enrich", "bulk-update", "schedule"].includes(command) && (args.includes("--help") || args.includes("-h"))) {
2511
2904
  console.log(usage());
2512
2905
  return;
2513
2906
  }
@@ -2575,6 +2968,10 @@ export async function runCli(argv) {
2575
2968
  await enrichCommand(args);
2576
2969
  return;
2577
2970
  }
2971
+ if (command === "schedule") {
2972
+ await scheduleCommand(args);
2973
+ return;
2974
+ }
2578
2975
  if (command === "profiles") {
2579
2976
  profilesCommand(args);
2580
2977
  return;
package/dist/index.d.ts CHANGED
@@ -30,5 +30,6 @@ export { buildWorksheet, classifyMarket, type ClassifyMarketOptions, type Classi
30
30
  export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, type CallDocument, type ClaimMentionStats, type DirectiveStat, type DirectiveType, type MarketDirective, type OverlayOptions, type OverlayStats, type VendorMentionStats, } from "./marketOverlay.ts";
31
31
  export { computeScaleIndex, dimensionForMetric, scaleReportToText, type ScaleDimension, type ScaleReport, type SignalEstimate, type VendorScale, } from "./marketScale.ts";
32
32
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
33
+ export { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, cronMatches, crontabSentinels, expectedFirings, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, scheduleId, scheduleRunsDir, schedulesPath, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, type CronExpression, type CrontabIo, type ScheduleEntry, type ScheduleProvider, type ScheduleRunRecord, type ScheduleRunStore, type ScheduleRunTrigger, type ScheduleStore, } from "./schedule.ts";
33
34
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
34
35
  export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
package/dist/index.js CHANGED
@@ -30,4 +30,5 @@ export { buildWorksheet, classifyMarket, } from "./marketClassify.js";
30
30
  export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, } from "./marketOverlay.js";
31
31
  export { computeScaleIndex, dimensionForMetric, scaleReportToText, } from "./marketScale.js";
32
32
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
33
+ export { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, cronMatches, crontabSentinels, expectedFirings, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, scheduleId, scheduleRunsDir, schedulesPath, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, } from "./schedule.js";
33
34
  export { suggestValues } from "./suggest.js";