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.
Files changed (130) hide show
  1. package/CHANGELOG.md +378 -1
  2. package/README.md +37 -14
  3. package/dist/audit.d.ts +3 -1
  4. package/dist/audit.js +29 -2
  5. package/dist/cli/audit.d.ts +15 -0
  6. package/dist/cli/audit.js +392 -0
  7. package/dist/cli/auth.d.ts +80 -0
  8. package/dist/cli/auth.js +500 -0
  9. package/dist/cli/call.d.ts +1 -0
  10. package/dist/cli/call.js +373 -0
  11. package/dist/cli/capabilities.d.ts +30 -0
  12. package/dist/cli/capabilities.js +197 -0
  13. package/dist/cli/draft.d.ts +7 -0
  14. package/dist/cli/draft.js +87 -0
  15. package/dist/cli/enrich.d.ts +8 -0
  16. package/dist/cli/enrich.js +788 -0
  17. package/dist/cli/fix.d.ts +33 -0
  18. package/dist/cli/fix.js +344 -0
  19. package/dist/cli/help.d.ts +25 -0
  20. package/dist/cli/help.js +609 -0
  21. package/dist/cli/icp.d.ts +7 -0
  22. package/dist/cli/icp.js +229 -0
  23. package/dist/cli/init.d.ts +7 -0
  24. package/dist/cli/init.js +58 -0
  25. package/dist/cli/market.d.ts +1 -0
  26. package/dist/cli/market.js +391 -0
  27. package/dist/cli/plans.d.ts +5 -0
  28. package/dist/cli/plans.js +454 -0
  29. package/dist/cli/schedule.d.ts +8 -0
  30. package/dist/cli/schedule.js +479 -0
  31. package/dist/cli/shared.d.ts +70 -0
  32. package/dist/cli/shared.js +331 -0
  33. package/dist/cli/signals.d.ts +7 -0
  34. package/dist/cli/signals.js +412 -0
  35. package/dist/cli/suggest.d.ts +49 -0
  36. package/dist/cli/suggest.js +135 -0
  37. package/dist/cli/tam.d.ts +9 -0
  38. package/dist/cli/tam.js +387 -0
  39. package/dist/cli/ui.d.ts +121 -0
  40. package/dist/cli/ui.js +375 -0
  41. package/dist/cli.d.ts +1 -57
  42. package/dist/cli.js +104 -4556
  43. package/dist/connector.d.ts +15 -0
  44. package/dist/connector.js +35 -0
  45. package/dist/connectors/hubspot.d.ts +3 -1
  46. package/dist/connectors/hubspot.js +10 -3
  47. package/dist/connectors/outboxChannel.d.ts +64 -0
  48. package/dist/connectors/outboxChannel.js +170 -0
  49. package/dist/connectors/prospectSources.d.ts +22 -0
  50. package/dist/connectors/prospectSources.js +43 -0
  51. package/dist/connectors/salesforce.d.ts +3 -1
  52. package/dist/connectors/salesforce.js +12 -5
  53. package/dist/connectors/signalSources.d.ts +105 -0
  54. package/dist/connectors/signalSources.js +264 -0
  55. package/dist/connectors/theirstack.d.ts +84 -0
  56. package/dist/connectors/theirstack.js +125 -0
  57. package/dist/icp.d.ts +52 -4
  58. package/dist/icp.js +112 -35
  59. package/dist/index.d.ts +1 -0
  60. package/dist/index.js +1 -0
  61. package/dist/init.js +3 -0
  62. package/dist/judge.d.ts +2 -0
  63. package/dist/judge.js +6 -0
  64. package/dist/judgeEval.d.ts +7 -0
  65. package/dist/judgeEval.js +8 -1
  66. package/dist/marketClassify.d.ts +2 -0
  67. package/dist/marketClassify.js +7 -1
  68. package/dist/mcp-bin.js +2 -2
  69. package/dist/mcp.js +259 -166
  70. package/dist/runReport.d.ts +26 -0
  71. package/dist/runReport.js +15 -0
  72. package/dist/schedule.d.ts +80 -2
  73. package/dist/schedule.js +272 -5
  74. package/dist/signals.d.ts +54 -0
  75. package/dist/signals.js +64 -0
  76. package/dist/spoolFiles.d.ts +44 -0
  77. package/dist/spoolFiles.js +114 -0
  78. package/dist/tam.d.ts +225 -0
  79. package/dist/tam.js +470 -0
  80. package/dist/types.d.ts +11 -0
  81. package/docs/api.md +91 -11
  82. package/docs/outbox-format.md +92 -0
  83. package/docs/recipes.md +37 -0
  84. package/docs/roadmap-to-1.0.md +31 -1
  85. package/docs/signal-spool-format.md +175 -0
  86. package/docs/tam.md +195 -0
  87. package/llms.txt +83 -11
  88. package/package.json +1 -1
  89. package/skills/fullstackgtm/SKILL.md +4 -3
  90. package/src/audit.ts +27 -1
  91. package/src/cli/audit.ts +447 -0
  92. package/src/cli/auth.ts +549 -0
  93. package/src/cli/call.ts +398 -0
  94. package/src/cli/capabilities.ts +215 -0
  95. package/src/cli/draft.ts +101 -0
  96. package/src/cli/enrich.ts +885 -0
  97. package/src/cli/fix.ts +372 -0
  98. package/src/cli/help.ts +664 -0
  99. package/src/cli/icp.ts +265 -0
  100. package/src/cli/init.ts +65 -0
  101. package/src/cli/market.ts +423 -0
  102. package/src/cli/plans.ts +523 -0
  103. package/src/cli/schedule.ts +526 -0
  104. package/src/cli/shared.ts +375 -0
  105. package/src/cli/signals.ts +434 -0
  106. package/src/cli/suggest.ts +151 -0
  107. package/src/cli/tam.ts +435 -0
  108. package/src/cli/ui.ts +426 -0
  109. package/src/cli.ts +103 -5170
  110. package/src/connector.ts +46 -0
  111. package/src/connectors/hubspot.ts +14 -2
  112. package/src/connectors/outboxChannel.ts +202 -0
  113. package/src/connectors/prospectSources.ts +57 -0
  114. package/src/connectors/salesforce.ts +18 -2
  115. package/src/connectors/signalSources.ts +314 -0
  116. package/src/connectors/theirstack.ts +170 -0
  117. package/src/icp.ts +120 -34
  118. package/src/index.ts +32 -0
  119. package/src/init.ts +3 -0
  120. package/src/judge.ts +7 -0
  121. package/src/judgeEval.ts +8 -1
  122. package/src/marketClassify.ts +8 -1
  123. package/src/mcp-bin.ts +2 -2
  124. package/src/mcp.ts +130 -57
  125. package/src/runReport.ts +39 -0
  126. package/src/schedule.ts +330 -7
  127. package/src/signals.ts +90 -0
  128. package/src/spoolFiles.ts +116 -0
  129. package/src/tam.ts +654 -0
  130. package/src/types.ts +12 -0
package/llms.txt CHANGED
@@ -20,6 +20,7 @@ at/above `--fail-on`.
20
20
  - [Contributing](https://github.com/fullstackgtm/core/blob/main/CONTRIBUTING.md): dev setup, the open-core mirror model, the release ritual
21
21
  - [API reference](https://github.com/fullstackgtm/core/blob/main/docs/api.md): semver-covered surfaces — canonical model, rule interface, plan/apply contract, connector contract, config, CLI, MCP tools
22
22
  - [CRM-health lifecycle](https://github.com/fullstackgtm/core/blob/main/docs/crm-health-lifecycle.md): the Prevent → Detect → Remediate → Verify/Attribute model; no-new-dupes design
23
+ - [TAM mapping](https://github.com/fullstackgtm/core/blob/main/docs/tam.md): size the reachable market from the ICP, then populate it with scheduled governed acquire and track coverage to an ETA
23
24
  - [CHANGELOG](https://github.com/fullstackgtm/core/blob/main/CHANGELOG.md): release history
24
25
 
25
26
  `fullstackgtm resolve <type>` is the create gate: exit 0 = safe to create,
@@ -58,6 +59,60 @@ uncertainty; report bubbles ∝ estimated share only when signals exist.
58
59
  Storage is profile-scoped under `<home>/market/<category>`. MCP:
59
60
  `fullstackgtm_market_worksheet`, `fullstackgtm_market_observe`.
60
61
 
62
+ ## Key invariants (TAM mapping)
63
+
64
+ `fullstackgtm tam` sizes the reachable market FROM the ICP and then iteratively
65
+ fills it — not a one-shot headline. `tam estimate` computes a transparent model:
66
+ account universe × ACV = TAM (account/per-logo basis by default; `--acv-basis
67
+ buyer` for per-seat = contacts × ACV), where contacts = accounts ×
68
+ buyers/account is the population target. The account count is `--accounts <n>`
69
+ (assumption), OR `--source theirstack` — the TECHNOGRAPHIC universe: counts
70
+ companies that USE a CRM/MAP (`firmographics.technologies` → TheirStack
71
+ `company_technology_slug_or`) + geo + employee bounds; the real RevOps buying signal
72
+ AND the only source that returns a LIST (`tam accounts --source theirstack` pulls
73
+ real companies name/domain, `--out csv`/`--json`, `--max` caps credit spend; a
74
+ count is cheap but NOT free — limit:0 is rejected so a count returns 1 row ≈ 3
75
+ credits, a list pull ≈ 3/company; `login theirstack`). OR `--source explorium` — a firmographic
76
+ count via `/v1/businesses` (`total_results`), a count ONLY (Explorium 403s on
77
+ list-pull, can't target by CRM-usage; must OMIT `size` or it caps the total; capped
78
+ at 60,000 `EXPLORIUM_BUSINESS_COUNT_CAP` → `provider:explorium (≥60k cap — floor)` +
79
+ ⚠). Verified live: people endpoints can't size a market (Explorium `/v1/prospects`
80
+ total == page size; pipe0/Crustdata returns only a cursor — `--source pipe0` for
81
+ estimate is REJECTED, it's a population source). The model always LABELS
82
+ `accountsSource` (provider:theirstack/explorium vs assumption). ACV must be a
83
+ real ANNUAL figure the operator CONFIRMS — `--acv <annual-usd>`, OR `--acv-from-crm
84
+ --deal-period monthly|quarterly|annual` which takes the median closed-won amount and
85
+ ANNUALIZES it (the period is required — a deal can be MRR/quarterly/annual and
86
+ guessing is a 4–12× error; $15k/mo → $180k). NO band defaults, and a bare
87
+ `--provider` does NOT auto-set ACV (HubSpot is the COVERAGE source, not the ACV) —
88
+ `estimate` REFUSES without a confirmed ACV. Buyers/account is `--buyers-per-account`
89
+ OR the CRM's avg-contacts-per-account OR a labeled `assumption:1-buyer`. Both
90
+ `acv.source` and `universe.buyersSource` are labeled (incl. deal count + the
91
+ period×factor, so a thin signal like "1 deal" is visible). Optional
92
+ citable cross-checks (`{claim,valueUsd?,sourceUrl,quote}`) sit beside the
93
+ bottom-up number as a top-down sanity check. `estimate`
94
+ records a coverage BASELINE (CRM counts now) so `status` attributes what the
95
+ campaign added. `tam populate --cron` schedules `enrich acquire --source <s>
96
+ --save` — the ONE create-side verb the schedule allowlist permits, and only in
97
+ its plan-producing `--save` form: each firing queues a needs_approval lead plan,
98
+ the acquire meter is charged only at apply, and apply stays `apply --plan-id`
99
+ (re-checked approved). `tam status` measures coverage on the INTERSECTION of CRM ×
100
+ TAM-ICP, not a raw count: `tam estimate` stores the ICP filter (`model.targeting`),
101
+ and status CLASSIFIES each domain-bearing account into in-TAM / out-of-TAM / unknown
102
+ (checked on size+industry — geo + "uses-CRM" aren't on a CanonicalAccount, so a
103
+ `--reverify` re-enrich is future; the classifier labels what it checked). ONLY
104
+ in-TAM counts toward coverage, so off-ICP accounts loaded from elsewhere can't
105
+ inflate it. Bottom-up vs top-down reconciliation: the in-TAM count is a FLOOR on the
106
+ real universe (those are verified members), so when in-TAM ≥ the estimate, status
107
+ stops reporting a fake 100% and flips to "estimate was a floor — real market is at
108
+ least N (`reconciledUniverse = max(estimate, inTam)`); re-estimate for the headroom".
109
+ `--save` stamps an append-only timeline. `tam report` (markdown) and `status` project
110
+ an ETA on the IN-TAM accounts/day burn across the timeline → days-remaining → fill
111
+ date — and REFUSE to project (honest "not enough history") below two readings, a flat
112
+ rate, or zero elapsed time.
113
+ Coverage + ETA are deterministic (no LLM, byte-stable). Storage profile-scoped
114
+ under `<home>/tam/<name>/` (`model.json` + `coverage.jsonl`, 0600).
115
+
61
116
  ## Key invariants (governed write verbs)
62
117
 
63
118
  `bulk-update <object> --where … (--set|--archive|--create-task)` filters the
@@ -123,8 +178,12 @@ and no webhook ingestion (Phase 2). Never auto-writes.
123
178
  The timing/outbound layer turns "who changed" into one grounded, governed
124
179
  opener — and **never sends**. `signals fetch` captures fresh buying triggers
125
180
  into a profile-scoped ledger (free Greenhouse/Lever/Ashby ATS scrapers in the
126
- box; funding/social via `ingest`/`--from`), keyed by **account domain**, ranked
127
- by learned `weights`; it writes NOTHING to the CRM. `icp interview|set|show`
181
+ box; funding/social via `--from`; connected platforms via `--connector`
182
+ file/serpapi-news/hubspot-forms with secrets through the credential ladder; and
183
+ push platforms via the webhook spool — `--connector file` reads the conventional
184
+ landing zone `<home>/signals/spool/*.jsonl`, see docs/signal-spool-format.md),
185
+ keyed by **account domain**, ranked by learned `weights`; it writes NOTHING to
186
+ the CRM. `icp interview|set|show`
128
187
  builds `icp.json`; `icp judge` ranks fresh signals into send/nurture/skip — pass
129
188
  a snapshot (`--provider`/`--input`/`--demo`) and each decision resolves its CRM
130
189
  target: `accountId`, the best `contact {id,email,title}`, and `contacts[]` (all
@@ -135,8 +194,11 @@ bar). `draft` emits ONE trigger-grounded opener per hot account as a governed
135
194
  `create_task` targeting the resolved `contact.id` (or `accountId`) — a
136
195
  domain-only decision (account not yet in the CRM) is REJECTED with "acquire it
137
196
  first" (run `enrich acquire`, re-judge with the snapshot). `--channel
138
- email|linkedin|task` shapes the opener; it is a task the operator sends from
139
- their own tool, never an automated send. `signals outcome --account <domain>
197
+ email|linkedin|task` shapes the opener. After `plans approve`, applying the draft
198
+ plan logs the touch as a CRM task (`apply --provider <crm>`) OR renders it to the
199
+ governed outbox (`apply --channel outbox` → `<home>/signals/outbox/<channel>.jsonl`
200
+ for a downstream sender to drain). Either way the CLI **transmits nothing** — the
201
+ outbox is the send-side mirror of the spool; the always-on sender is hosted/opt-in. `signals outcome --account <domain>
140
202
  --contact <id> --result replied|...` credits the contact reached so weights
141
203
  re-learn. The contact↔account join (`contact.accountId` + `account.domain`) is
142
204
  surfaced at every hop, so the loop is contact-coherent end to end — see the
@@ -147,18 +209,28 @@ surfaced at every hop, so the loop is contact-coherent end to end — see the
147
209
  `fullstackgtm schedule` is the horizontal scheduler — no feature namespace
148
210
  owns cron logic. `add "<command>" --cron "<expr>"` validates the command
149
211
  against the read/plan-side allowlist (audit, snapshot, enrich append|refresh,
150
- market capture|refresh, suggest, report, doctor) and the 5-field cron
151
- expression, but touches no crontab; `install` materializes enabled entries
152
- into a sentinel-delimited managed block (`# >>> fullstackgtm <profile> >>>`)
153
- replaced wholesale on re-install, lines outside it untouched; `uninstall`
154
- removes only the block. Scheduling never auto-approves: `apply` is
212
+ enrich acquire --save, market capture|refresh, signals fetch, icp judge|eval,
213
+ draft, suggest, report, doctor) and the 5-field cron
214
+ expression, but touches no system timer; `install` materializes enabled
215
+ entries into the platform timer (`--timer` defaults launchd on macOS,
216
+ crontab elsewhere). crontab: a sentinel-delimited managed block
217
+ (`# >>> fullstackgtm <profile> >>>`) replaced wholesale on re-install, lines
218
+ outside it untouched. launchd: one LaunchAgent plist per entry
219
+ (`com.fullstackgtm.<profile>.<id>` in `~/Library/LaunchAgents`), the fleet
220
+ replaced wholesale, foreign plists untouched — chosen because macOS gates
221
+ `crontab` writes behind Full Disk Access (unpromptable TCC), while
222
+ LaunchAgents need no permission and coalesce firings missed during sleep
223
+ into one run on wake. `uninstall` removes only what install created.
224
+ Scheduling never auto-approves: `apply` is
155
225
  schedulable ONLY as `apply --plan-id <id>` and every firing re-checks the
156
226
  plan is approved — unapproved plans record a `plan_not_approved` no-op run,
157
227
  no flag relaxes this. `schedule run <id>` is the single provider entry point
158
- (in-process dispatch, never shell); run records (exit code, output tail,
228
+ (in-process dispatch, never shell; a second cron trigger in the same minute
229
+ records a `duplicate_firing` no-op — launchd double-triggers the Vixie
230
+ dom+dow OR corner); run records (exit code, output tail,
159
231
  artifacts: plan ids / enrich run labels, trigger cron|manual) land under
160
232
  `<home>/schedule/runs/<id>/`. `status` shows next firing and surfaces missed
161
- firings (visibility only — local cron has no catch-up). Providers beyond
233
+ firings (visibility only — cron has no catch-up). Providers beyond
162
234
  `local` are not yet implemented (future: codegen scaffolds, same contract).
163
235
 
164
236
  ## Key invariants (engagement workspace / health)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.43.0",
3
+ "version": "0.45.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
@@ -63,10 +63,11 @@ credentials AND stored plans per client org.
63
63
  | `call parse\|score\|link\|plan` | Transcripts → evidence-quoted insights, rubric scorecards, deal linking, governed next-step writes |
64
64
  | `enrich append\|refresh\|ingest\|status` | Governed enrichment (Apollo pull / Clay ingest), fill-blanks-only plans |
65
65
  | `enrich acquire [--source explorium\|pipe0\|linkedin]` | Net-new ICP-targeted lead gen: resolve-first deduped `create_record` plans, capped by a per-profile windowed meter (records + spend); owner-stamped via `acquire.assign`/`--assign-owner` (never born ownerless); links each lead to a domain-stamped, signal-watchable account; `acquire.create.company` acquires accounts (ABM); `--source linkedin --list <id>` reads a HeyReach lead list (Phase 1 discovery, read-only — never sends); never auto-writes |
66
- | `signals fetch\|list\|outcome\|weights` | Detect-side timing layer: capture fresh buying triggers into a profile-scoped ledger (free Greenhouse/Lever/Ashby ATS in the box; funding/social via ingest), ranked by learned weights. Writes NOTHING to the CRM; `outcome --account <domain> --contact <id> --result …` re-weights which triggers earn a touch |
66
+ | `signals fetch\|list\|outcome\|weights` | Detect-side timing layer: capture fresh buying triggers into a profile-scoped ledger (free Greenhouse/Lever/Ashby ATS in the box; `--from` staged files; `--connector file\|serpapi-news\|hubspot-forms` to pull connected platforms / read the webhook spool `<home>/signals/spool`, secrets via the credential ladder), ranked by learned weights. Writes NOTHING to the CRM; `outcome --account <domain> --contact <id> --result …` re-weights which triggers earn a touch |
67
67
  | `icp interview\|set\|show\|judge\|eval` | Build the ICP, then `judge` ranks fresh signals into send/nurture/skip — pass a snapshot (`--provider`/`--input`) and each decision resolves its CRM target (`accountId` + the `contact`/`contacts` to reach). `eval` is the calibration gate (exit 2 below the bar) |
68
- | `draft [--from-judge latest] [--channel email\|linkedin\|task]` | One trigger-grounded opener per hot account → a governed `create_task` targeting the resolved `contact.id` (or `accountId`); rejects a domain-only decision ("acquire it first"). **Never sends** — the opener is a task the operator sends from their own tool |
68
+ | `draft [--from-judge latest] [--channel email\|linkedin\|task]` | One trigger-grounded opener per hot account → a governed `create_task` targeting the resolved `contact.id` (or `accountId`); rejects a domain-only decision ("acquire it first"). **Never sends** — after `plans approve`, `apply --provider <crm>` logs it as a CRM task, or `apply --channel outbox` renders it to `<home>/signals/outbox/<channel>.jsonl` for a downstream sender to drain; the CLI transmits nothing |
69
69
  | `market init\|capture\|classify\|worksheet\|observe\|fronts\|axes\|overlay\|scale\|report\|refresh` | Competitive category map; evidence quotes verified verbatim against stored captures |
70
+ | `tam estimate\|accounts\|status\|report\|populate` | Size the reachable market FROM the ICP (real account count × real ACV; buyers/account → contact target), then iteratively fill it: `populate` schedules plan-only `enrich acquire --save` (apply stays gated), `status` classifies CRM accounts vs the TAM ICP into **in-TAM / out-of-TAM / unknown** (only in-TAM counts — junk loaded from elsewhere doesn't inflate coverage; checked on size+industry, geo/tech need re-enrich) and flips to "estimate was a FLOOR, revise up" when bottom-up in-CRM exceeds the top-down estimate; `--save` stamps a timeline, `report` projects burn-up + ETA on the in-TAM rate. **`estimate --source theirstack`** counts companies that USE a CRM/MAP (`firmographics.technologies`) — the real RevOps universe — and **`tam accounts --source theirstack`** pulls the actual company LIST (names+domains, `--out csv`) at ~3 credits/company — `--dry-run [--usd-per-credit <r>]` prices the pull + full TAM for 0 credits, `--confirm` gates pulls above `--max-credits` (default 150). `--source explorium` = firmographic count only (no list, 60k cap = floor); else `--accounts`. **ACV must be a real ANNUAL figure you confirm** — `--acv <annual-usd>`, or `--acv-from-crm --deal-period monthly\|quarterly\|annual` (median closed-won, annualized); NO band defaults, a bare `--provider` does NOT set ACV (HubSpot=coverage), refuses without one. Buyers/account = CRM avg-contacts or explicit. Every number labeled with its source |
70
71
  | `schedule add\|list\|remove\|enable\|disable\|run\|install\|uninstall\|status` | Horizontal cron; read/plan-side allowlist only — scheduling NEVER auto-approves |
71
72
  | `report` | Client-ready audit deliverable (markdown or self-contained HTML) |
72
73
  | `health [--json]` | Per-profile CRM health timeline: deterministic 0–100 score, trend, per-rule deltas — accrues from `audit --save`, read-only |
@@ -78,7 +79,7 @@ Add `--json` for machine-readable output on any command.
78
79
  ## MCP server (alternative surface, same gates)
79
80
 
80
81
  ```bash
81
- npx -y -p fullstackgtm -p @modelcontextprotocol/sdk -p zod fullstackgtm-mcp
82
+ npx -y fullstackgtm-mcp
82
83
  ```
83
84
 
84
85
  Tools over stdio: `fullstackgtm_audit` (read-only), `fullstackgtm_rules`,
package/src/audit.ts CHANGED
@@ -18,6 +18,19 @@ const DEFAULT_POLICY: Omit<GtmPolicy, "today"> = {
18
18
  requireAccountForDeal: true,
19
19
  };
20
20
 
21
+ /**
22
+ * Wall clock for the plan's stdout-bound `createdAt`, honoring the
23
+ * SOURCE_DATE_EPOCH reproducible-build convention (seconds since the Unix
24
+ * epoch). When set, `audit --demo --json` is byte-deterministic across
25
+ * re-runs — golden tests and CI diffs can pin the payload. Unset (normal
26
+ * use) it is the real clock, exactly as before.
27
+ */
28
+ function nowIso(): string {
29
+ const epoch = process.env.SOURCE_DATE_EPOCH;
30
+ if (epoch && /^\d+$/.test(epoch)) return new Date(Number(epoch) * 1000).toISOString();
31
+ return new Date().toISOString();
32
+ }
33
+
21
34
  export function defaultPolicy(today = new Date().toISOString().slice(0, 10)): GtmPolicy {
22
35
  return {
23
36
  ...DEFAULT_POLICY,
@@ -28,20 +41,33 @@ export function defaultPolicy(today = new Date().toISOString().slice(0, 10)): Gt
28
41
  /**
29
42
  * Run every rule over the snapshot and collect the results into a single
30
43
  * dry-run patch plan. Pass custom rules to extend or replace the built-ins.
44
+ * `onRule` reports per-rule progress (presentation only — a throwing callback
45
+ * never fails the audit).
31
46
  */
32
47
  export function auditSnapshot(
33
48
  snapshot: CanonicalGtmSnapshot,
34
49
  policy: GtmPolicy = defaultPolicy(),
35
50
  rules: GtmAuditRule[] = builtinAuditRules,
51
+ onRule?: (ruleId: string, phase: "start" | "done", findings?: number) => void,
36
52
  ): PatchPlan {
37
53
  const context = { snapshot, policy, index: buildSnapshotIndex(snapshot) };
38
54
  const findings: AuditFinding[] = [];
39
55
  const operations: PatchOperation[] = [];
40
56
 
41
57
  for (const rule of rules) {
58
+ try {
59
+ onRule?.(rule.id, "start");
60
+ } catch {
61
+ // progress is presentation-only
62
+ }
42
63
  const result = rule.evaluate(context);
43
64
  findings.push(...result.findings);
44
65
  operations.push(...result.operations);
66
+ try {
67
+ onRule?.(rule.id, "done", result.findings.length);
68
+ } catch {
69
+ // progress is presentation-only
70
+ }
45
71
  }
46
72
 
47
73
 
@@ -52,7 +78,7 @@ export function auditSnapshot(
52
78
  return {
53
79
  id: `patch_plan_${stableHash(`${snapshot.provider}:${snapshot.generatedAt}:${findings.length}:${operations.length}`)}`,
54
80
  title: "GTM hygiene audit patch plan",
55
- createdAt: new Date().toISOString(),
81
+ createdAt: nowIso(),
56
82
  status: operations.length > 0 ? "needs_approval" : "draft",
57
83
  dryRun: true,
58
84
  summary: `${findings.length} findings and ${operations.length} proposed dry-run operations.`,