fullstackgtm 0.48.0 → 0.50.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 (98) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/CONTRIBUTING.md +23 -4
  3. package/DATA-FLOWS.md +8 -2
  4. package/INSTALL_FOR_AGENTS.md +13 -4
  5. package/README.md +53 -1
  6. package/SECURITY.md +27 -3
  7. package/dist/acquireCheckpoint.d.ts +33 -0
  8. package/dist/acquireCheckpoint.js +149 -0
  9. package/dist/acquireLinkedIn.d.ts +2 -0
  10. package/dist/acquireLinkedIn.js +1 -1
  11. package/dist/cli/audit.js +10 -7
  12. package/dist/cli/auth.js +8 -4
  13. package/dist/cli/enrich.js +283 -79
  14. package/dist/cli/fix.js +20 -7
  15. package/dist/cli/help.js +23 -15
  16. package/dist/cli/market.js +180 -2
  17. package/dist/cli/plans.js +154 -9
  18. package/dist/cli/shared.d.ts +3 -1
  19. package/dist/cli/shared.js +2 -2
  20. package/dist/cli/ui.d.ts +10 -5
  21. package/dist/cli/ui.js +18 -5
  22. package/dist/cli.js +1 -1
  23. package/dist/config.d.ts +13 -1
  24. package/dist/config.js +23 -2
  25. package/dist/connectors/linkedin.d.ts +2 -0
  26. package/dist/connectors/linkedin.js +5 -0
  27. package/dist/connectors/prospectSources.d.ts +23 -0
  28. package/dist/connectors/prospectSources.js +25 -14
  29. package/dist/connectors/salesforce.js +12 -5
  30. package/dist/connectors/salesforceAuth.d.ts +9 -0
  31. package/dist/connectors/salesforceAuth.js +47 -5
  32. package/dist/connectors/theirstack.d.ts +0 -19
  33. package/dist/connectors/theirstack.js +3 -10
  34. package/dist/credentials.js +36 -26
  35. package/dist/enrich.d.ts +44 -1
  36. package/dist/hostedAcquireCheckpoint.d.ts +43 -0
  37. package/dist/hostedAcquireCheckpoint.js +129 -0
  38. package/dist/hostedPatchPlan.d.ts +87 -0
  39. package/dist/hostedPatchPlan.js +270 -0
  40. package/dist/icp.js +13 -2
  41. package/dist/index.d.ts +7 -3
  42. package/dist/index.js +6 -2
  43. package/dist/market.d.ts +1 -1
  44. package/dist/market.js +7 -127
  45. package/dist/marketClassify.d.ts +10 -0
  46. package/dist/marketClassify.js +52 -1
  47. package/dist/marketSourcing.js +7 -45
  48. package/dist/mcp.js +67 -115
  49. package/dist/planStore.d.ts +42 -1
  50. package/dist/planStore.js +268 -49
  51. package/dist/progress.d.ts +2 -2
  52. package/dist/progress.js +2 -2
  53. package/dist/providerError.d.ts +21 -0
  54. package/dist/providerError.js +30 -0
  55. package/dist/publicHttp.d.ts +28 -0
  56. package/dist/publicHttp.js +143 -0
  57. package/dist/secureFile.d.ts +15 -0
  58. package/dist/secureFile.js +164 -0
  59. package/dist/types.d.ts +1 -1
  60. package/docs/api.md +53 -2
  61. package/docs/architecture.md +22 -2
  62. package/llms.txt +7 -0
  63. package/package.json +5 -3
  64. package/skills/fullstackgtm/SKILL.md +8 -2
  65. package/src/acquireCheckpoint.ts +186 -0
  66. package/src/acquireLinkedIn.ts +3 -1
  67. package/src/cli/audit.ts +10 -7
  68. package/src/cli/auth.ts +8 -4
  69. package/src/cli/enrich.ts +322 -78
  70. package/src/cli/fix.ts +28 -7
  71. package/src/cli/help.ts +24 -15
  72. package/src/cli/market.ts +181 -2
  73. package/src/cli/plans.ts +159 -9
  74. package/src/cli/shared.ts +2 -1
  75. package/src/cli/ui.ts +19 -9
  76. package/src/cli.ts +1 -1
  77. package/src/config.ts +39 -1
  78. package/src/connectors/linkedin.ts +6 -0
  79. package/src/connectors/prospectSources.ts +50 -15
  80. package/src/connectors/salesforce.ts +12 -5
  81. package/src/connectors/salesforceAuth.ts +46 -6
  82. package/src/connectors/theirstack.ts +3 -10
  83. package/src/credentials.ts +47 -28
  84. package/src/enrich.ts +47 -1
  85. package/src/hostedAcquireCheckpoint.ts +156 -0
  86. package/src/hostedPatchPlan.ts +286 -0
  87. package/src/icp.ts +14 -2
  88. package/src/index.ts +24 -0
  89. package/src/market.ts +7 -111
  90. package/src/marketClassify.ts +61 -1
  91. package/src/marketSourcing.ts +7 -43
  92. package/src/mcp.ts +93 -136
  93. package/src/planStore.ts +322 -57
  94. package/src/progress.ts +2 -2
  95. package/src/providerError.ts +37 -0
  96. package/src/publicHttp.ts +147 -0
  97. package/src/secureFile.ts +169 -0
  98. package/src/types.ts +1 -0
package/dist/cli/help.js CHANGED
@@ -176,9 +176,10 @@ Usage:
176
176
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
177
177
  derive values for requires_human_* placeholders
178
178
  from snapshot evidence, with confidence + reasons
179
- fullstackgtm plans list [--status <s>] | show <id> | reject <id>
179
+ fullstackgtm plans list [--status <s>] | show <id> | sync | reject <id>
180
180
  fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]
181
181
  fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
182
+ fullstackgtm plans recover <id> --acknowledge-uncertain-writes
182
183
  fullstackgtm apply --plan-id <id> --provider <name>
183
184
  fullstackgtm apply --plan-id <id> --channel outbox (render approved openers; transmits nothing)
184
185
  fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
@@ -199,7 +200,11 @@ Profiles (multi-organization use):
199
200
  Plan lifecycle:
200
201
  audit --save persists the dry-run plan to ~/.fullstackgtm/plans. Approve
201
202
  specific operations (optionally with --value <opId>=<v> for placeholders),
202
- then apply by id — the store enforces approval and records every run.
203
+ then apply by id — the store enforces approval and records every run. When
204
+ paired, local and hosted are replicas of the same immutable plan: either
205
+ capable surface may approve or apply, and each CLI check-in imports missing
206
+ approvals and exact operation receipts. Use \`plans sync\` for an explicit
207
+ check-in; offline changes reconcile later without blocking local work.
203
208
 
204
209
  Authentication (checked in order):
205
210
  1. --token-env <name> explicit env var for this invocation (hubspot)
@@ -226,6 +231,8 @@ Audit options:
226
231
  --config <path> Config file (default: ./fullstackgtm.config.json if present)
227
232
  { "policy": {...}, "rules": {"enabled":[],"disabled":[]},
228
233
  "rulePackages": ["./team-rules.mjs"] }
234
+ --allow-plugins Execute rulePackages from an explicit --config path after review
235
+ --no-plugins Ignore rulePackages; use declarative config and built-in rules only
229
236
  --rules <ids> Comma-separated rule ids to run (default: all; see \`rules\`)
230
237
  --json Print the JSON patch plan instead of markdown
231
238
  --out <path> Also write the JSON patch plan to a file
@@ -492,14 +499,15 @@ export const HELP = {
492
499
  seeAlso: ["audit", "plans", "apply"],
493
500
  },
494
501
  plans: {
495
- summary: "plan lifecycle: list / show / approve / reject saved plans",
502
+ summary: "replicated plan lifecycle: list / show / sync / approve / reject",
496
503
  phase: "Govern",
497
504
  synopsis: [
498
- "fullstackgtm plans list [--status <s>] | show <id> | reject <id>",
505
+ "fullstackgtm plans list [--status <s>] | show <id> | sync | reject <id>",
499
506
  "fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]",
500
507
  "fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low]",
508
+ "fullstackgtm plans recover <id> --acknowledge-uncertain-writes",
501
509
  ],
502
- detail: "Approval is explicit and per-operation; placeholders need a concrete --value or a suggestion to be approvable.",
510
+ detail: "Approval is explicit and per-operation; placeholders need a concrete --value or a suggestion to be approvable. When paired, hosted and CLI are eventually consistent replicas of one immutable plan. `plans sync` explicitly exchanges approval revisions, execution state, and exact per-operation receipts; ordinary plan commands also check in best-effort. Either surface may apply when its CRM connector supports the selected operations.",
503
511
  seeAlso: ["audit", "suggest", "apply"],
504
512
  },
505
513
  apply: {
@@ -510,7 +518,7 @@ export const HELP = {
510
518
  "fullstackgtm apply --plan-id <id> --channel outbox (render approved drafted openers to the outbox; transmits nothing)",
511
519
  "fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [--value <opId>=<v>]",
512
520
  ],
513
- detail: "The only verb that mutates a CRM. Writes only operations approved via `plans approve` or `--approve`, with compare-and-set and readback. Never writes requires_human_* placeholders without a --value override.",
521
+ detail: "The CLI CRM-write verb. Writes only operations approved locally or imported from the shared hosted replica, with an online execution claim when available, stable operation ids, compare-and-set, resolve-before-create, and readback. Hosted may execute the same synchronized plan when it has a compatible CRM connection; its exact operation receipts are imported on the next CLI check-in. Never writes requires_human_* placeholders without a --value override.",
514
522
  seeAlso: ["plans", "suggest", "audit-log"],
515
523
  },
516
524
  "audit-log": {
@@ -585,11 +593,11 @@ export const HELP = {
585
593
  };
586
594
  // Verbs that print their own richer multi-subcommand help; runCli routes their
587
595
  // `--help` to themselves, so commandHelp() only renders these via `help <verb>`.
588
- export const BESPOKE_HELP = ["init", "call", "market", "tam", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
596
+ export const BESPOKE_HELP = ["init", "call", "market", "tam", "enrich", "schedule", "signals", "icp", "draft"];
589
597
  export const GLOBAL_FLAGS = ["--help", "--full"];
590
598
  export const GLOBAL_SHORT_FLAGS = ["-h"];
591
599
  export const SOURCE_FLAGS = ["--provider", "--token-env", "--input", "--demo", "--sample", "--seed", "--today"];
592
- export const AUDIT_FLAGS = ["--config", "--rules", "--stale-days", "--fail-on", "--save", "--dry-run", "--json", "--out", "--full"];
600
+ export const AUDIT_FLAGS = ["--config", "--allow-plugins", "--no-plugins", "--rules", "--stale-days", "--fail-on", "--save", "--dry-run", "--json", "--out", "--full"];
593
601
  // Complete per-command flag registry used by runCli's fail-closed flag
594
602
  // validation. Keep this next to HELP so focused help, machine capabilities,
595
603
  // and the parser safety gate have one command inventory to reconcile against.
@@ -605,21 +613,21 @@ export const COMMAND_FLAGS = {
605
613
  snapshot: [...SOURCE_FLAGS, "--since", "--out", "--archive"],
606
614
  audit: [...SOURCE_FLAGS, ...AUDIT_FLAGS],
607
615
  report: [...SOURCE_FLAGS, ...AUDIT_FLAGS, "--plan", "--client", "--title", "--prepared-by", "--format", "--max-examples"],
608
- diff: ["--before", "--after", "--config", "--stale-days", "--today", "--json", "--fail-on-new-findings"],
609
- rules: ["--config", "--json"],
616
+ diff: ["--before", "--after", "--config", "--allow-plugins", "--no-plugins", "--stale-days", "--today", "--json", "--fail-on-new-findings"],
617
+ rules: ["--config", "--allow-plugins", "--no-plugins", "--json"],
610
618
  resolve: [...SOURCE_FLAGS, "--name", "--domain", "--email", "--account-id", "--json"],
611
619
  hierarchy: [...SOURCE_FLAGS, "--json", "--out"],
612
620
  relationships: [...SOURCE_FLAGS, "--account-id", "--domain", "--json", "--out"],
613
621
  route: [...SOURCE_FLAGS, "--match", "--no-inherit-owner", "--reassign-owned", "--policy", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
614
- fix: [...SOURCE_FLAGS, "--config", "--rule", "--provider", "--min-confidence", "--include-creates", "--yes", "--confirm", "--dry-run"],
622
+ fix: [...SOURCE_FLAGS, "--config", "--allow-plugins", "--no-plugins", "--rule", "--provider", "--min-confidence", "--include-creates", "--yes", "--confirm", "--dry-run"],
615
623
  "bulk-update": [...SOURCE_FLAGS, "--where", "--set", "--guard", "--require", "--create-task", "--archive", "--force-archive-duplicates", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
616
624
  dedupe: [...SOURCE_FLAGS, "--key", "--keep", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
617
625
  reassign: [...SOURCE_FLAGS, "--from", "--assign-unowned", "--to", "--objects", "--where", "--except-deal-stage", "--include-closed-deals", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
618
626
  backfill: [...SOURCE_FLAGS, "--since", "--pipeline", "--match-property", "--skip-unmatched", "--save", "--dry-run", "--json"],
619
- enrich: [...SOURCE_FLAGS, "--config", "--source", "--icp", "--input", "--provider", "--stale-days", "--assign-owner", "--objects", "--max", "--staged-run", "--run-label", "--label", "--runs", "--save", "--dry-run", "--json", "--out"],
627
+ enrich: [...SOURCE_FLAGS, "--config", "--source", "--icp", "--input", "--provider", "--list", "--stale-days", "--assign-owner", "--objects", "--max", "--scan-limit", "--staged-run", "--run-label", "--label", "--runs", "--save", "--dry-run", "--json", "--out"],
620
628
  call: [...SOURCE_FLAGS, "--transcript", "--title", "--source", "--model", "--deterministic", "--heuristics", "--llm", "--json", "--ndjson", "--out", "--call", "--call-type", "--rubric", "--list", "--list-rubrics", "--attendees", "--domain", "--deal", "--save", "--dry-run"],
621
629
  suggest: [...SOURCE_FLAGS, "--plan-id", "--plan", "--json", "--out"],
622
- plans: ["--status", "--operations", "--value", "--values-from", "--min-confidence", "--include-creates", "--json"],
630
+ plans: ["--status", "--operations", "--value", "--values-from", "--min-confidence", "--include-creates", "--acknowledge-uncertain-writes", "--json"],
623
631
  apply: ["--plan", "--plan-id", "--provider", "--channel", "--token-env", "--approve", "--value", "--json", "--config"],
624
632
  "audit-log": ["--in", "--out", "--json"],
625
633
  merge: ["--input", "--out", "--json"],
@@ -628,10 +636,10 @@ export const COMMAND_FLAGS = {
628
636
  icp: [...SOURCE_FLAGS, "--name", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--save", "--json", "--out"],
629
637
  signals: ["--bucket", "--source", "--connector", "--connector-opt", "--watchlist", "--keywords", "--from", "--label", "--since", "--account", "--contact", "--unjudged", "--touch", "--result", "--save", "--json", "--explain"],
630
638
  draft: ["--from-judge", "--min-score", "--prompt", "--model", "--channel", "--save", "--dry-run", "--json", "--out"],
631
- schedule: ["--cron", "--label", "--provider", "--trigger", "--runs", "--json"],
639
+ schedule: ["--cron", "--label", "--provider", "--trigger", "--timer", "--runs", "--json"],
632
640
  };
633
641
  export const FLAGS_WITH_VALUES = new Set([
634
- "--account", "--account-id", "--accounts", "--acv", "--acv-basis", "--after", "--anchor", "--api-key", "--approve", "--archive", "--assign-owner", "--attendees", "--before", "--bucket", "--buyers-per-account", "--call", "--call-type", "--calls", "--capture-run", "--category", "--channel", "--client", "--client-id", "--client-secret", "--config", "--connector", "--connector-opt", "--contact", "--cron", "--cross-checks", "--deal", "--deal-period", "--diff", "--domain", "--email", "--except-deal-stage", "--format", "--from", "--from-judge", "--golden", "--guard", "--icp", "--in", "--input", "--instance-url", "--keep", "--key", "--keywords", "--label", "--login-url", "--match", "--match-property", "--max", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--min-accuracy", "--min-confidence", "--min-mentions", "--min-score", "--model", "--name", "--objects", "--operations", "--out", "--pipeline", "--plan", "--plan-id", "--policy", "--port", "--prepared-by", "--prior-run", "--profile", "--promote-lift", "--prompt", "--provider", "--reason", "--require", "--result", "--rubric", "--rule", "--rules", "--run", "--run-label", "--runs", "--scopes", "--seed", "--set", "--signals-from", "--since", "--snapshot", "--source", "--staged-run", "--stale-days", "--status", "--task-account", "--task-deal", "--title", "--to", "--today", "--token", "--token-env", "--touch", "--transcript", "--trigger", "--usd-per-credit", "--value", "--values-from", "--vendor", "--via", "--watchlist", "--where",
642
+ "--account", "--account-id", "--accounts", "--acv", "--acv-basis", "--after", "--anchor", "--api-key", "--approve", "--archive", "--assign-owner", "--attendees", "--before", "--bucket", "--buyers-per-account", "--call", "--call-type", "--calls", "--capture-run", "--category", "--channel", "--client", "--client-id", "--client-secret", "--config", "--connector", "--connector-opt", "--contact", "--cron", "--cross-checks", "--deal", "--deal-period", "--diff", "--domain", "--email", "--except-deal-stage", "--format", "--from", "--from-judge", "--golden", "--guard", "--icp", "--in", "--input", "--instance-url", "--keep", "--key", "--keywords", "--label", "--list", "--login-url", "--match", "--match-property", "--max", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--min-accuracy", "--min-confidence", "--min-mentions", "--min-score", "--model", "--name", "--objects", "--operations", "--out", "--pipeline", "--plan", "--plan-id", "--policy", "--port", "--prepared-by", "--prior-run", "--profile", "--promote-lift", "--prompt", "--provider", "--reason", "--require", "--result", "--rubric", "--rule", "--rules", "--run", "--run-label", "--runs", "--scan-limit", "--scopes", "--seed", "--set", "--signals-from", "--since", "--snapshot", "--source", "--staged-run", "--stale-days", "--status", "--task-account", "--task-deal", "--timer", "--title", "--to", "--today", "--token", "--token-env", "--touch", "--transcript", "--trigger", "--usd-per-credit", "--value", "--values-from", "--vendor", "--via", "--watchlist", "--where",
635
643
  ]);
636
644
  // Lifecycle-grouped front door. One line per verb, organized by the
637
645
  // Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
@@ -2,7 +2,7 @@
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { createFilePlanStore } from "../planStore.js";
5
- import { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, loadCaptureTexts, loadMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans } from "../market.js";
5
+ import { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, loadCaptureTexts, loadMarketConfig, observationId, starterMarketConfig, validateObservationSet, verifyEvidenceSpans } from "../market.js";
6
6
  import { assessAxes, axesReportToText } from "../marketAxes.js";
7
7
  import { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown } from "../marketOverlay.js";
8
8
  import { computeScaleIndex, scaleReportToText } from "../marketScale.js";
@@ -24,6 +24,7 @@ import { unknownSubcommandError } from "./suggest.js";
24
24
  */
25
25
  const MARKET_SUBCOMMANDS = [
26
26
  "init", "capture", "classify", "worksheet", "observe", "fronts", "axes", "overlay", "scale", "report", "refresh",
27
+ "schema", "hints", "observe-template", "review",
27
28
  ];
28
29
  export async function marketCommand(args) {
29
30
  const [subcommand, ...rest] = args;
@@ -35,9 +36,13 @@ export async function marketCommand(args) {
35
36
  market init --category <name> [--out <path>] write a starter market.config.json
36
37
  market init --category <name> --auto --vendor <url> [--vendor <url>...] [--anchor <url>] [--max-claims n]
37
38
  LLM-propose vendors + claim taxonomy from seed pages (needs an API key)
39
+ market schema print exact MarketConfig + ObservationSet shapes and a tiny example
38
40
  market capture [--config <path>] [--run <label>]
39
41
  market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model m] [--out <path>]
40
42
  market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
43
+ market hints [--vendor <id>] [--capture-run <label>] [--json]
44
+ market observe-template [--run <label>] [--out <path>]
45
+ market review --from <observations.json> [--json]
41
46
  market observe --from <observations.json|sets.jsonl|spool-dir> [--unverified]
42
47
  market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
43
48
  market axes [--config <path>] [--run <label>] [--json]
@@ -78,6 +83,63 @@ one client's category intel never bleeds into another's). Front states are
78
83
  recomputed deterministically on every invocation — never stored.`);
79
84
  return;
80
85
  }
86
+ if (subcommand === "schema") {
87
+ console.log(`MarketConfig shape:
88
+ {
89
+ "category": "string",
90
+ "anchorVendor": "optional vendor id",
91
+ "vendors": [{
92
+ "id": "stable-vendor-id",
93
+ "name": "Vendor Name",
94
+ "urls": { "home": "https://...", "pricing": null, "product": ["https://..."] },
95
+ "aliases": ["optional alternate names"]
96
+ }],
97
+ "claims": [{
98
+ "id": "stable-claim-id",
99
+ "capability": "Specific differentiating capability",
100
+ "icp": "buyer/segment this claim matters to",
101
+ "pricingStructure": "pricing motion implied by the claim, or unknown",
102
+ "definition": "How to judge LOUD vs QUIET vs ABSENT from page text",
103
+ "terms": ["buyer/search terms for deterministic mention matching"]
104
+ }],
105
+ "surfaceRule": "LOUD = hero/primary positioning; QUIET = supported but secondary; ABSENT = no support; UNOBSERVABLE = no usable capture."
106
+ }
107
+
108
+ ObservationSet shape:
109
+ {
110
+ "id": "set_run1",
111
+ "category": "same category as config",
112
+ "runLabel": "run-1",
113
+ "runAt": "2026-06-11T00:00:00.000Z",
114
+ "extractor": "manual|agent:<model>|llm:<provider>:<model>",
115
+ "observations": [{
116
+ "id": "stable observation id (any unique string is accepted)",
117
+ "vendorId": "vendor id from config",
118
+ "claimId": "claim id from config",
119
+ "observedAt": "2026-06-11",
120
+ "intensity": "loud|quiet|absent|unobservable",
121
+ "confidence": "high|medium|low",
122
+ "reason": "one reviewer-facing sentence",
123
+ "evidence": [{
124
+ "id": "ev1",
125
+ "sourceSystem": "web",
126
+ "sourceObjectType": "page",
127
+ "sourceObjectId": "source URL",
128
+ "text": "VERBATIM quote from captured page text",
129
+ "metadata": { "url": "source URL", "captureHash": "hash from worksheet" }
130
+ }]
131
+ }]
132
+ }
133
+
134
+ Rules:
135
+ - exactly one observation per vendor × claim cell
136
+ - loud/quiet require ≥1 verbatim evidence quote
137
+ - absent/unobservable use [] evidence
138
+ - unobservable means no usable captured page exists for the vendor; if pages were captured but the claim is unsupported, use absent
139
+ - submit with: fullstackgtm market observe --from observations.json
140
+ - derive outputs with: fullstackgtm market fronts --json; fullstackgtm market report --format md --out market-report.md`);
141
+ return;
142
+ }
81
143
  if (subcommand === "init") {
82
144
  const category = option(rest, "--category");
83
145
  if (!category)
@@ -148,7 +210,20 @@ recomputed deterministically on every invocation — never stored.`);
148
210
  return;
149
211
  }
150
212
  if (!rest.includes("--unverified")) {
151
- const { textByHash } = loadCaptureTexts(config.category);
213
+ const { entries, textByHash } = loadCaptureTexts(config.category);
214
+ const usableVendorIds = new Set(entries
215
+ .filter((entry) => entry.runLabel === set.runLabel && entry.captureHash && textByHash.has(entry.captureHash))
216
+ .map((entry) => entry.vendorId));
217
+ const unsupportedUnobservable = set.observations.filter((obs) => obs.intensity === "unobservable" && usableVendorIds.has(obs.vendorId));
218
+ if (unsupportedUnobservable.length > 0) {
219
+ console.error(`Rejected: ${unsupportedUnobservable.length} unobservable reading(s) for vendors with usable captures`);
220
+ for (const obs of unsupportedUnobservable.slice(0, 20)) {
221
+ console.error(` - ${obs.vendorId} × ${obs.claimId}: vendor has captured page text; unsupported claims should be absent, not unobservable`);
222
+ }
223
+ console.error("Use unobservable only when no usable captured page exists for that vendor/run. (--unverified skips this gate when captures genuinely live elsewhere.)");
224
+ process.exitCode = 1;
225
+ return;
226
+ }
152
227
  const failures = verifyEvidenceSpans(set.observations, textByHash);
153
228
  if (failures.length > 0) {
154
229
  console.error(`Rejected: ${failures.length} evidence span(s) failed verification against the stored captures`);
@@ -181,6 +256,109 @@ recomputed deterministically on every invocation — never stored.`);
181
256
  }
182
257
  return;
183
258
  }
259
+ if (subcommand === "hints") {
260
+ const vendorFilter = option(rest, "--vendor");
261
+ const vendorIds = vendorFilter ? [vendorFilter] : config.vendors.map((vendor) => vendor.id);
262
+ const worksheets = vendorIds.map((vendorId) => buildWorksheet(config, vendorId, { captureRun: option(rest, "--capture-run") ?? undefined }));
263
+ if (rest.includes("--json")) {
264
+ console.log(JSON.stringify({
265
+ category: config.category,
266
+ captureRun: worksheets[0]?.captureRun ?? null,
267
+ vendors: worksheets.map((worksheet) => ({ vendor: worksheet.vendor, claimHints: worksheet.claimHints })),
268
+ }, null, 2));
269
+ return;
270
+ }
271
+ for (const worksheet of worksheets) {
272
+ console.log(`\n## ${worksheet.vendor.id} — ${worksheet.vendor.name}`);
273
+ if (worksheet.pages.length === 0) {
274
+ console.log("No usable captures: classify every claim as unobservable.");
275
+ continue;
276
+ }
277
+ for (const hint of worksheet.claimHints) {
278
+ console.log(`\n${hint.claimId}`);
279
+ if (hint.matches.length === 0) {
280
+ console.log(" no lexical matches in captured text (usually absent; still inspect pages for synonyms)");
281
+ continue;
282
+ }
283
+ for (const match of hint.matches)
284
+ console.log(` - ${match.term} @ ${match.url}: ${match.quote}`);
285
+ }
286
+ }
287
+ return;
288
+ }
289
+ if (subcommand === "observe-template") {
290
+ const runLabel = option(rest, "--run") ?? "run-1";
291
+ const now = new Date().toISOString();
292
+ const observations = config.vendors.flatMap((vendor) => {
293
+ const worksheet = buildWorksheet(config, vendor.id, { captureRun: runLabel });
294
+ const noPages = worksheet.pages.length === 0;
295
+ return config.claims.map((claim) => ({
296
+ id: observationId(config.category, runLabel, vendor.id, claim.id),
297
+ vendorId: vendor.id,
298
+ claimId: claim.id,
299
+ observedAt: now.slice(0, 10),
300
+ intensity: noPages ? "unobservable" : "absent",
301
+ confidence: "low",
302
+ reason: noPages ? `No usable captures for ${vendor.name}; cannot judge.` : "TODO: inspect worksheet/hints and classify this cell.",
303
+ evidence: [],
304
+ _claimHints: worksheet.claimHints.find((hint) => hint.claimId === claim.id)?.matches ?? [],
305
+ }));
306
+ });
307
+ const set = { id: `set_${runLabel}`, category: config.category, runLabel, runAt: now, extractor: "manual-template", observations };
308
+ const payload = `${JSON.stringify(set, null, 2)}\n`;
309
+ const outPath = option(rest, "--out");
310
+ if (outPath) {
311
+ writeFileSync(resolve(process.cwd(), outPath), payload);
312
+ console.log(`Wrote ${outPath}: ${observations.length} template observations. Fill TODO cells, then run market review --from ${outPath}`);
313
+ }
314
+ else {
315
+ console.log(payload);
316
+ }
317
+ return;
318
+ }
319
+ if (subcommand === "review") {
320
+ const fromPath = option(rest, "--from");
321
+ if (!fromPath)
322
+ throw new Error("market review requires --from <observations.json>");
323
+ const set = JSON.parse(readFileSync(resolve(process.cwd(), fromPath), "utf8"));
324
+ const errors = validateObservationSet(config, set).map((detail) => ({ code: "invalid_observation_set", detail }));
325
+ const warnings = [];
326
+ const { entries, textByHash } = loadCaptureTexts(config.category);
327
+ const usableVendorIds = new Set(entries
328
+ .filter((entry) => entry.runLabel === set.runLabel && entry.captureHash && textByHash.has(entry.captureHash))
329
+ .map((entry) => entry.vendorId));
330
+ for (const obs of set.observations) {
331
+ if (obs.intensity === "unobservable" && usableVendorIds.has(obs.vendorId)) {
332
+ errors.push({ code: "unobservable_with_captures", detail: `${obs.vendorId} × ${obs.claimId}: vendor has captured page text; use absent when unsupported.` });
333
+ }
334
+ if ((obs.intensity === "absent" || obs.intensity === "unobservable") && usableVendorIds.has(obs.vendorId)) {
335
+ const hints = buildWorksheet(config, obs.vendorId, { captureRun: set.runLabel }).claimHints.find((hint) => hint.claimId === obs.claimId)?.matches ?? [];
336
+ if (hints.length > 0) {
337
+ warnings.push({
338
+ code: "support_candidate_marked_absent",
339
+ detail: `${obs.vendorId} × ${obs.claimId}: ${hints.length} candidate snippet(s) matched terms but cell is ${obs.intensity}; verify absent vs quiet. Example: ${hints[0].quote}`,
340
+ });
341
+ }
342
+ }
343
+ }
344
+ for (const failure of verifyEvidenceSpans(set.observations, textByHash)) {
345
+ errors.push({ code: "bad_evidence", detail: `${failure.vendorId} × ${failure.claimId}: ${failure.problem}` });
346
+ }
347
+ const summary = { ok: errors.length === 0, errors, warnings };
348
+ if (rest.includes("--json")) {
349
+ console.log(JSON.stringify(summary, null, 2));
350
+ }
351
+ else {
352
+ console.log(`${summary.ok ? "OK" : "FAILED"}: ${errors.length} error(s), ${warnings.length} warning(s)`);
353
+ for (const err of errors.slice(0, 20))
354
+ console.log(`ERROR ${err.code}: ${err.detail}`);
355
+ for (const warn of warnings.slice(0, 20))
356
+ console.log(`WARN ${warn.code}: ${warn.detail}`);
357
+ }
358
+ if (errors.length > 0)
359
+ process.exitCode = 1;
360
+ return;
361
+ }
184
362
  if (subcommand === "classify") {
185
363
  const credential = await requireLlmCredential("market classify");
186
364
  const vendorFilter = option(rest, "--vendor");
package/dist/cli/plans.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { auditSnapshot, defaultPolicy } from "../audit.js";
5
- import { loadConfig, mergePolicy, resolveConfiguredRules } from "../config.js";
5
+ import { loadConfig, mergePolicy, resolveConfiguredRules, rulePackageTrustFromCli } from "../config.js";
6
6
  import { applyPatchPlan } from "../connector.js";
7
7
  import { diffFindings, diffSnapshots, diffToMarkdown } from "../diff.js";
8
8
  import { createChannelConnector } from "../connectors/outboxChannel.js";
@@ -14,6 +14,7 @@ import { createFilePlanStore } from "../planStore.js";
14
14
  import { ENRICH_CONFIG_FILE_NAME, loadEnrichConfig } from "../enrich.js";
15
15
  import { loadMeter, recordConsumption, remaining } from "../acquireMeter.js";
16
16
  import { progressReporter, reportCounts } from "../runReport.js";
17
+ import { claimHostedPlanApply, reconcileHostedPatchPlan, releaseHostedPlanApply, reportHostedPlanLifecycle, uploadHostedPatchPlan } from "../hostedPatchPlan.js";
17
18
  import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progress.js";
18
19
  import { connectorFor, isOptionValue, numericOption, option, repeatedOption, selectedRules } from "./shared.js";
19
20
  import { colorEnabled, createProgressRenderer, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.js";
@@ -132,9 +133,20 @@ export async function apply(args) {
132
133
  let valueOverrides;
133
134
  const store = planId ? createFilePlanStore() : null;
134
135
  if (planId && store) {
135
- const stored = await store.get(planId);
136
+ let stored = await store.get(planId);
136
137
  if (!stored)
137
138
  throw new Error(`No stored plan with id ${planId}.`);
139
+ const reconciliation = await reconcileHostedPatchPlan(store, stored);
140
+ if (reconciliation.status === "conflict")
141
+ throw new Error(`Refusing to apply plan ${planId}: ${reconciliation.reason}.`);
142
+ if (reconciliation.status === "updated") {
143
+ stored = reconciliation.stored;
144
+ console.error(`Synced hosted ${reconciliation.remoteStatus} state for ${planId}.`);
145
+ }
146
+ if (stored.status === "applied") {
147
+ console.log(`Plan ${planId} was already applied by another replica; imported its execution receipt. No provider writes were attempted.`);
148
+ return;
149
+ }
138
150
  if (stored.status !== "approved") {
139
151
  throw new Error(`Plan ${planId} is ${stored.status}; approve operations first with \`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`);
140
152
  }
@@ -210,6 +222,46 @@ export async function apply(args) {
210
222
  // A channel (e.g. outbox) renders approved ops to a local artifact and
211
223
  // transmits nothing; a CRM provider writes records. Same governed apply path.
212
224
  const connector = channel ? createChannelConnector(channel) : await connectorFor(provider, args);
225
+ let applyClaimId;
226
+ let hostedApplyClaimId;
227
+ if (planId && store) {
228
+ const claimed = await store.claimApply(planId, { provider: provider ?? `channel:${channel}`, source: "cli" });
229
+ applyClaimId = claimed.claimId;
230
+ const hostedClaim = await claimHostedPlanApply(claimed.stored);
231
+ if (hostedClaim.status === "applied") {
232
+ await store.abortApplyPreflight(planId, claimed.claimId, "Another replica completed apply before provider I/O.");
233
+ const refreshed = await store.get(planId);
234
+ if (refreshed)
235
+ await reconcileHostedPatchPlan(store, refreshed);
236
+ console.log(`Plan ${planId} was already applied by another replica. No provider writes were attempted.`);
237
+ return;
238
+ }
239
+ if (hostedClaim.status === "conflict") {
240
+ await store.abortApplyPreflight(planId, claimed.claimId, `Hosted execution claim failed: ${hostedClaim.reason}`);
241
+ throw new Error(`Refusing to apply plan ${planId}: could not coordinate with its hosted replica (${hostedClaim.reason}).`);
242
+ }
243
+ if (hostedClaim.status === "unavailable") {
244
+ console.error(`Hosted replica is unavailable (${hostedClaim.reason}); continuing local-first. Resolve-before-create and provider conflict guards remain active, and the execution receipt will sync on a later check-in.`);
245
+ }
246
+ if (hostedClaim.status === "claimed") {
247
+ hostedApplyClaimId = hostedClaim.claimId;
248
+ await store.recordHostedClaim(planId, claimed.claimId, hostedClaim.claimId);
249
+ }
250
+ const claimedVerification = verifyApprovalDigests(claimed.stored.plan.operations, claimed.stored.approvedOperationIds, claimed.stored.valueOverrides, claimed.stored.approvalDigests);
251
+ if (!claimedVerification.ok) {
252
+ if (hostedApplyClaimId) {
253
+ const released = await releaseHostedPlanApply(claimed.stored, hostedApplyClaimId, "Approval integrity verification failed after claim and before provider I/O.");
254
+ if (released.status === "conflict" || released.status === "unavailable") {
255
+ console.error(`Hosted apply claim may require recovery: ${released.reason}.`);
256
+ }
257
+ }
258
+ await store.abortApplyPreflight(planId, claimed.claimId, "Approval integrity verification failed after the claim and before provider I/O.");
259
+ throw new Error(`Refusing to apply plan ${planId}: approval changed while acquiring the apply claim.`);
260
+ }
261
+ plan = claimed.stored.plan;
262
+ approvedOperationIds = claimed.stored.approvedOperationIds;
263
+ valueOverrides = claimed.stored.valueOverrides;
264
+ }
213
265
  // Interactive terminals get a live apply board on stderr while the run
214
266
  // executes (preflight → operations → results, with a per-op safety ticker);
215
267
  // piped runs render nothing. Either way the emitter streams heartbeats to
@@ -224,11 +276,23 @@ export async function apply(args) {
224
276
  progress,
225
277
  });
226
278
  }
279
+ catch (error) {
280
+ // Once applyPatchPlan starts, an exception cannot prove that the provider
281
+ // performed no write. Keep the claim fail-closed for operator reconciliation.
282
+ if (planId && store && applyClaimId)
283
+ await store.markApplyUncertain(planId, applyClaimId);
284
+ throw error;
285
+ }
227
286
  finally {
228
287
  renderer.done();
229
288
  }
230
289
  if (planId && store) {
231
- await store.recordRun(planId, run);
290
+ const recorded = await store.recordRun(planId, run, applyClaimId);
291
+ const durableHostedClaimId = recorded.applyAttempts?.find((attempt) => attempt.id === applyClaimId)?.hostedClaimId;
292
+ const mirrored = await reportHostedPlanLifecycle(recorded, { claimId: durableHostedClaimId ?? hostedApplyClaimId });
293
+ if (mirrored.status === "unavailable" || mirrored.status === "conflict") {
294
+ console.error(`Hosted plan status is stale: ${mirrored.reason}. Local apply history remains authoritative.`);
295
+ }
232
296
  }
233
297
  // Charge the acquire meter for the creates that actually landed.
234
298
  if (createOps.length > 0) {
@@ -272,8 +336,9 @@ export async function diffCommand(args) {
272
336
  }
273
337
  const before = JSON.parse(readFileSync(resolve(process.cwd(), beforePath), "utf8"));
274
338
  const after = JSON.parse(readFileSync(resolve(process.cwd(), afterPath), "utf8"));
275
- const loaded = loadConfig(option(args, "--config") ?? undefined);
276
- const rules = selectedRules(args, await resolveConfiguredRules(loaded));
339
+ const explicitConfig = option(args, "--config") ?? undefined;
340
+ const loaded = loadConfig(explicitConfig);
341
+ const rules = selectedRules(args, await resolveConfiguredRules(loaded, undefined, rulePackageTrustFromCli(args, explicitConfig)));
277
342
  const policy = mergePolicy(defaultPolicy(), loaded?.config);
278
343
  const today = option(args, "--today");
279
344
  if (today)
@@ -332,7 +397,14 @@ export async function plansCommand(args) {
332
397
  const [subcommand, ...rest] = args;
333
398
  if (subcommand === "list" || subcommand === undefined) {
334
399
  const status = option(rest, "--status");
335
- const plans = await store.list(status ?? undefined);
400
+ let plans = await store.list(status ?? undefined);
401
+ const synced = await Promise.all(plans.map(async (stored) => {
402
+ const result = await reconcileHostedPatchPlan(store, stored);
403
+ if (result.status === "updated")
404
+ return result.stored;
405
+ return stored;
406
+ }));
407
+ plans = status ? synced.filter((stored) => stored.status === status) : synced;
336
408
  if (rest.includes("--json") || args.includes("--json")) {
337
409
  console.log(JSON.stringify(plans.map((stored) => ({
338
410
  id: stored.plan.id,
@@ -386,9 +458,17 @@ export async function plansCommand(args) {
386
458
  const planId = rest.find((arg) => !arg.startsWith("--"));
387
459
  if (!planId)
388
460
  throw new Error("Usage: fullstackgtm plans show <planId>");
389
- const stored = await store.get(planId);
461
+ let stored = await store.get(planId);
390
462
  if (!stored)
391
463
  throw new Error(`No stored plan with id ${planId}.`);
464
+ const reconciliation = await reconcileHostedPatchPlan(store, stored);
465
+ if (reconciliation.status === "updated") {
466
+ stored = reconciliation.stored;
467
+ console.error(`Synced hosted ${reconciliation.remoteStatus} state for ${planId}.`);
468
+ }
469
+ else if (reconciliation.status === "conflict") {
470
+ console.error(`Hosted sync conflict: ${reconciliation.reason}.`);
471
+ }
392
472
  if (rest.includes("--json")) {
393
473
  console.log(JSON.stringify(stored, null, 2));
394
474
  return;
@@ -397,6 +477,19 @@ export async function plansCommand(args) {
397
477
  console.log(`Status: ${planStatusWord(stored.status, showPaint)}`);
398
478
  console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
399
479
  console.log(`Runs: ${stored.runs.length}`);
480
+ const mirror = await uploadHostedPatchPlan(stored);
481
+ if (mirror.status === "saved")
482
+ console.log(`Hosted review: ${mirror.state.url}`);
483
+ else if (mirror.status === "unavailable" || mirror.status === "conflict")
484
+ console.log(`Hosted review: sync pending (${mirror.reason})`);
485
+ if (stored.applyAttempts?.length) {
486
+ console.log("Apply attempts:");
487
+ for (const attempt of stored.applyAttempts) {
488
+ console.log(` ${attempt.id} ${attempt.status} ${attempt.provider} via ${attempt.source} ${attempt.claimedAt}`);
489
+ if (attempt.note)
490
+ console.log(` ${attempt.note}`);
491
+ }
492
+ }
400
493
  console.log("");
401
494
  console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
402
495
  return;
@@ -441,6 +534,10 @@ export async function plansCommand(args) {
441
534
  ...fileOverrides,
442
535
  ...explicitOverrides,
443
536
  });
537
+ const mirror = await reportHostedPlanLifecycle(updated);
538
+ if (mirror.status === "unavailable" || mirror.status === "conflict") {
539
+ console.error(`Hosted plan status is stale: ${mirror.reason}. Local approval remains authoritative.`);
540
+ }
444
541
  console.log(`Approved ${updated.approvedOperationIds.length} operation(s) on ${planId}. Apply with \`fullstackgtm apply --plan-id ${planId} --provider <name>\`.`);
445
542
  return;
446
543
  }
@@ -448,9 +545,57 @@ export async function plansCommand(args) {
448
545
  const planId = rest.find((arg) => !arg.startsWith("--"));
449
546
  if (!planId)
450
547
  throw new Error("Usage: fullstackgtm plans reject <planId>");
451
- await store.reject(planId);
548
+ const rejected = await store.reject(planId);
549
+ const mirror = await reportHostedPlanLifecycle(rejected);
550
+ if (mirror.status === "unavailable" || mirror.status === "conflict") {
551
+ console.error(`Hosted plan status is stale: ${mirror.reason}. Local rejection remains authoritative.`);
552
+ }
452
553
  console.log(`Rejected ${planId}.`);
453
554
  return;
454
555
  }
455
- throw unknownSubcommandError("plans", subcommand, ["list", "show", "approve", "reject"]);
556
+ if (subcommand === "recover") {
557
+ const planId = rest.find((arg) => !arg.startsWith("--"));
558
+ if (!planId) {
559
+ throw new Error("Usage: fullstackgtm plans recover <planId> --acknowledge-uncertain-writes");
560
+ }
561
+ const stored = await store.get(planId);
562
+ if (!stored)
563
+ throw new Error(`No stored plan with id ${planId}.`);
564
+ if (stored.status !== "applying" || !stored.applyClaim) {
565
+ throw new Error(`Plan ${planId} has no unresolved apply attempt.`);
566
+ }
567
+ const attempt = stored.applyAttempts?.find((entry) => entry.id === stored.applyClaim?.id);
568
+ if (!rest.includes("--acknowledge-uncertain-writes")) {
569
+ throw new Error(`Plan ${planId} has an unresolved ${attempt?.provider ?? "provider"} apply attempt (${stored.applyClaim.id}). ` +
570
+ "Inspect the affected records in the provider; do not replay automatically. If you accept that some writes may already have landed, " +
571
+ `run \`fullstackgtm plans recover ${planId} --acknowledge-uncertain-writes\`. This clears every approval and requires a fresh review and approval before retrying.`);
572
+ }
573
+ await store.recoverApply(planId);
574
+ console.log(`Released ${planId} to needs_approval after acknowledging uncertain provider state. ` +
575
+ `No writes were replayed. Re-audit provider state, review the plan, then approve operations again before any apply.`);
576
+ return;
577
+ }
578
+ if (subcommand === "sync") {
579
+ const plans = await store.list();
580
+ let updated = 0;
581
+ const warnings = [];
582
+ for (const stored of plans) {
583
+ const result = await reconcileHostedPatchPlan(store, stored);
584
+ if (result.status === "updated")
585
+ updated += 1;
586
+ else if (result.status === "conflict" || result.status === "unavailable")
587
+ warnings.push(`${stored.plan.id}: ${result.reason}`);
588
+ if (stored.status === "applied") {
589
+ const hostedClaimId = [...(stored.applyAttempts ?? [])].reverse().find((attempt) => attempt.hostedClaimId)?.hostedClaimId;
590
+ const pushed = await reportHostedPlanLifecycle(stored, { claimId: hostedClaimId });
591
+ if (pushed.status === "conflict" || pushed.status === "unavailable")
592
+ warnings.push(`${stored.plan.id} receipt: ${pushed.reason}`);
593
+ }
594
+ }
595
+ console.log(`Plan replicas synchronized: ${updated} updated, ${plans.length - updated} unchanged.`);
596
+ for (const warning of warnings)
597
+ console.error(` ${warning}`);
598
+ return;
599
+ }
600
+ throw unknownSubcommandError("plans", subcommand, ["list", "show", "sync", "approve", "reject", "recover"]);
456
601
  }
@@ -23,7 +23,9 @@ export declare function saveRequested(args: string[]): boolean;
23
23
  export declare function confirmRequested(args: string[], ...legacyAliases: string[]): boolean;
24
24
  export declare function numericOption(args: string[], name: string): number | undefined;
25
25
  export declare function connectorFor(provider: string, args: string[], progress?: ProgressEmitter): Promise<GtmConnector>;
26
- export declare function readSnapshot(args: string[], progress?: ProgressEmitter): Promise<CanonicalGtmSnapshot>;
26
+ export declare function readSnapshot(args: string[], progress?: ProgressEmitter, options?: {
27
+ persistProgress?: boolean;
28
+ }): Promise<CanonicalGtmSnapshot>;
27
29
  /**
28
30
  * Validate that an --input file actually has the canonical snapshot shape
29
31
  * (the JSON `snapshot --out` writes) instead of blindly casting — a plan or
@@ -131,7 +131,7 @@ export async function connectorFor(provider, args, progress) {
131
131
  throw new Error(`Unknown provider: ${provider}.${providerSuggestion ? ` Did you mean ${providerSuggestion}?` : ""} ` +
132
132
  "Supported providers: hubspot, salesforce, stripe");
133
133
  }
134
- export async function readSnapshot(args, progress) {
134
+ export async function readSnapshot(args, progress, options = {}) {
135
135
  const provider = option(args, "--provider");
136
136
  if (provider) {
137
137
  // A verb driving its own progress board (e.g. `backfill stripe`) passes
@@ -153,7 +153,7 @@ export async function readSnapshot(args, progress) {
153
153
  return await connector.fetchSnapshot();
154
154
  }
155
155
  finally {
156
- renderer.done();
156
+ renderer.done({ persist: options.persistProgress });
157
157
  }
158
158
  }
159
159
  if (args.includes("--demo")) {