fullstackgtm 0.45.0 → 0.46.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 (95) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/INSTALL_FOR_AGENTS.md +13 -11
  3. package/README.md +27 -18
  4. package/dist/backfill.d.ts +86 -0
  5. package/dist/backfill.js +251 -0
  6. package/dist/bin.js +4 -1
  7. package/dist/cli/auth.d.ts +2 -0
  8. package/dist/cli/auth.js +119 -12
  9. package/dist/cli/backfill.d.ts +1 -0
  10. package/dist/cli/backfill.js +125 -0
  11. package/dist/cli/backfillRuns.d.ts +1 -0
  12. package/dist/cli/backfillRuns.js +187 -0
  13. package/dist/cli/call.js +23 -9
  14. package/dist/cli/enrich.js +28 -10
  15. package/dist/cli/fix.js +9 -7
  16. package/dist/cli/help.js +60 -23
  17. package/dist/cli/plans.js +13 -11
  18. package/dist/cli/shared.d.ts +4 -3
  19. package/dist/cli/shared.js +31 -20
  20. package/dist/cli/ui.d.ts +21 -0
  21. package/dist/cli/ui.js +53 -1
  22. package/dist/cli.js +37 -41
  23. package/dist/connector.d.ts +8 -0
  24. package/dist/connector.js +22 -10
  25. package/dist/connectors/hubspot.d.ts +7 -0
  26. package/dist/connectors/hubspot.js +236 -9
  27. package/dist/connectors/hubspotAuth.js +5 -1
  28. package/dist/connectors/linkedin.js +14 -14
  29. package/dist/connectors/salesforce.d.ts +7 -0
  30. package/dist/connectors/salesforce.js +28 -2
  31. package/dist/connectors/stripe.d.ts +37 -0
  32. package/dist/connectors/stripe.js +103 -31
  33. package/dist/enrich.d.ts +7 -0
  34. package/dist/enrich.js +7 -0
  35. package/dist/health.d.ts +11 -69
  36. package/dist/health.js +4 -134
  37. package/dist/healthScore.d.ts +71 -0
  38. package/dist/healthScore.js +143 -0
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +3 -1
  41. package/dist/llm.d.ts +29 -0
  42. package/dist/llm.js +206 -0
  43. package/dist/market.d.ts +39 -1
  44. package/dist/market.js +116 -44
  45. package/dist/marketClassify.d.ts +9 -1
  46. package/dist/marketClassify.js +10 -1
  47. package/dist/marketTaxonomy.d.ts +6 -1
  48. package/dist/marketTaxonomy.js +4 -2
  49. package/dist/mcp-bin.js +29 -0
  50. package/dist/mcp.js +117 -4
  51. package/dist/progress.d.ts +96 -0
  52. package/dist/progress.js +142 -0
  53. package/dist/runReport.d.ts +24 -0
  54. package/dist/runReport.js +139 -4
  55. package/dist/types.d.ts +18 -1
  56. package/docs/api.md +2 -1
  57. package/docs/architecture.md +2 -0
  58. package/docs/linkedin-connector-spec.md +1 -1
  59. package/llms.txt +3 -3
  60. package/package.json +10 -3
  61. package/skills/fullstackgtm/SKILL.md +1 -0
  62. package/src/backfill.ts +340 -0
  63. package/src/bin.ts +5 -1
  64. package/src/cli/auth.ts +135 -15
  65. package/src/cli/backfill.ts +156 -0
  66. package/src/cli/backfillRuns.ts +198 -0
  67. package/src/cli/call.ts +26 -10
  68. package/src/cli/enrich.ts +33 -10
  69. package/src/cli/fix.ts +11 -10
  70. package/src/cli/help.ts +61 -23
  71. package/src/cli/plans.ts +15 -14
  72. package/src/cli/shared.ts +44 -27
  73. package/src/cli/ui.ts +72 -1
  74. package/src/cli.ts +38 -41
  75. package/src/connector.ts +29 -9
  76. package/src/connectors/hubspot.ts +261 -9
  77. package/src/connectors/hubspotAuth.ts +5 -1
  78. package/src/connectors/linkedin.ts +14 -14
  79. package/src/connectors/salesforce.ts +34 -2
  80. package/src/connectors/stripe.ts +154 -34
  81. package/src/enrich.ts +13 -0
  82. package/src/health.ts +14 -213
  83. package/src/healthScore.ts +223 -0
  84. package/src/index.ts +28 -1
  85. package/src/llm.ts +239 -0
  86. package/src/market.ts +157 -44
  87. package/src/marketClassify.ts +18 -1
  88. package/src/marketTaxonomy.ts +10 -2
  89. package/src/mcp-bin.ts +32 -0
  90. package/src/mcp.ts +140 -6
  91. package/src/progress.ts +197 -0
  92. package/src/runReport.ts +159 -4
  93. package/src/types.ts +20 -2
  94. package/docs/dx-punch-list.md +0 -87
  95. package/docs/roadmap-to-1.0.md +0 -211
package/src/market.ts CHANGED
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
4
4
  import { isIP } from "node:net";
5
5
  import { join } from "node:path";
6
6
  import { credentialsDir } from "./credentials.ts";
7
+ import { MARKET_CAPTURE_STAGES, nullProgressEmitter, type ProgressEmitter } from "./progress.ts";
7
8
  import type { GtmEvidence } from "./types.ts";
8
9
 
9
10
  /**
@@ -445,12 +446,24 @@ const defaultFetchPage: FetchPage = async (url) => {
445
446
  };
446
447
 
447
448
  export type CaptureOptions = {
448
- /** Directory for captures; defaults to <marketHome>/captures. */
449
+ /** Directory for captures; defaults to <marketHome>/captures. Ignored when `store` is given. */
449
450
  dir?: string;
450
451
  runLabel?: string;
451
452
  /** Injectable for tests; defaults to global fetch. */
452
453
  fetchPage?: FetchPage;
453
454
  now?: () => Date;
455
+ /**
456
+ * Storage seam (see MarketStore below). Defaults to the profile-home file
457
+ * store; the hosted app passes a Convex-backed store. Same engine, same
458
+ * evidence chain, different persistence.
459
+ */
460
+ store?: MarketStore;
461
+ /**
462
+ * Progress emission over MARKET_CAPTURE_STAGES ("sources" → "capture" →
463
+ * "persist" here; "classify" belongs to classifyMarket). Presentation-only:
464
+ * a throwing listener never fails the capture.
465
+ */
466
+ progress?: ProgressEmitter;
454
467
  };
455
468
 
456
469
  export type CaptureResult = {
@@ -459,57 +472,55 @@ export type CaptureResult = {
459
472
  };
460
473
 
461
474
  export async function captureMarket(config: MarketConfig, options: CaptureOptions = {}): Promise<CaptureResult> {
462
- const dir = options.dir ?? join(marketHome(config.category), "captures");
475
+ const store = options.store ?? createFileMarketStore(config.category, { capturesDir: options.dir });
476
+ const progress = options.progress ?? nullProgressEmitter();
463
477
  const runLabel = options.runLabel ?? "run-1";
464
478
  const fetchPage = options.fetchPage ?? defaultFetchPage;
465
479
  const fetchedAt = (options.now ?? (() => new Date()))().toISOString();
466
- mkdirSync(dir, { recursive: true });
467
480
 
468
- const manifestPath = join(dir, "manifest.json");
469
- const manifest: CaptureEntry[] = existsSync(manifestPath)
470
- ? (JSON.parse(readFileSync(manifestPath, "utf8")) as CaptureEntry[])
471
- : [];
481
+ progress.stage(MARKET_CAPTURE_STAGES[0], 0, MARKET_CAPTURE_STAGES.length);
482
+ const targets: Array<{ vendorId: string; kind: CaptureEntry["kind"]; url: string }> = [];
483
+ for (const vendor of config.vendors) {
484
+ targets.push({ vendorId: vendor.id, kind: "home", url: vendor.urls.home });
485
+ if (vendor.urls.pricing) targets.push({ vendorId: vendor.id, kind: "pricing", url: vendor.urls.pricing });
486
+ for (const url of vendor.urls.product) targets.push({ vendorId: vendor.id, kind: "product", url });
487
+ }
488
+ progress.note(`${config.vendors.length} vendor(s), ${targets.length} page(s)`);
472
489
 
490
+ progress.stage(MARKET_CAPTURE_STAGES[1], 1, MARKET_CAPTURE_STAGES.length);
473
491
  const entries: CaptureEntry[] = [];
474
- for (const vendor of config.vendors) {
475
- const targets: Array<{ kind: CaptureEntry["kind"]; url: string }> = [
476
- { kind: "home", url: vendor.urls.home },
477
- ];
478
- if (vendor.urls.pricing) targets.push({ kind: "pricing", url: vendor.urls.pricing });
479
- for (const url of vendor.urls.product) targets.push({ kind: "product", url });
480
-
481
- for (const target of targets) {
482
- let status: number | null = null;
483
- let text = "";
484
- try {
485
- const page = await fetchPage(target.url);
486
- status = page.status;
487
- if (page.status === 200) text = extractReadableText(page.body);
488
- } catch {
489
- status = null;
490
- }
491
- let captureHash: string | null = null;
492
- if (text) {
493
- captureHash = createHash("sha256").update(text).digest("hex");
494
- // Content-addressed: an unchanged page dedupes to the same file.
495
- writeFileSync(join(dir, `${captureHash}.txt`), text);
496
- }
497
- const entry: CaptureEntry = {
498
- runLabel,
499
- vendorId: vendor.id,
500
- kind: target.kind,
501
- url: target.url,
502
- fetchedAt,
503
- httpStatus: status,
504
- captureHash,
505
- textChars: text.length,
506
- };
507
- manifest.push(entry);
508
- entries.push(entry);
492
+ for (const target of targets) {
493
+ let status: number | null = null;
494
+ let text = "";
495
+ try {
496
+ const page = await fetchPage(target.url);
497
+ status = page.status;
498
+ if (page.status === 200) text = extractReadableText(page.body);
499
+ } catch {
500
+ status = null;
509
501
  }
502
+ let captureHash: string | null = null;
503
+ if (text) {
504
+ captureHash = createHash("sha256").update(text).digest("hex");
505
+ // Content-addressed: an unchanged page dedupes to the same key.
506
+ await store.saveCaptureText(captureHash, text);
507
+ }
508
+ entries.push({
509
+ runLabel,
510
+ vendorId: target.vendorId,
511
+ kind: target.kind,
512
+ url: target.url,
513
+ fetchedAt,
514
+ httpStatus: status,
515
+ captureHash,
516
+ textChars: text.length,
517
+ });
518
+ progress.items(entries.length, targets.length);
510
519
  }
511
- writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
512
- return { entries, manifestPath };
520
+ progress.stage(MARKET_CAPTURE_STAGES[3], 3, MARKET_CAPTURE_STAGES.length);
521
+ await store.appendCaptureEntries(entries);
522
+ progress.flush();
523
+ return { entries, manifestPath: store.captureLocation() };
513
524
  }
514
525
 
515
526
  // ---------------------------------------------------------------------------
@@ -651,6 +662,108 @@ export function loadCaptureTexts(
651
662
  return { entries, textByHash };
652
663
  }
653
664
 
665
+ // ---------------------------------------------------------------------------
666
+ // MarketStore — the storage seam for the whole market workspace (captures +
667
+ // observation runs), mirroring the PlanStore precedent: the profile-home file
668
+ // layout and the hosted app's Convex tables are two implementations of the
669
+ // same contract. Engines never touch storage directly — captureMarket and
670
+ // classifyMarket take a store — and the default file store preserves the
671
+ // on-disk layout exactly, so the CLI's behavior is unchanged.
672
+ //
673
+ // loadConfig is deliberately NOT part of the store: the config is a reviewed
674
+ // input (a versioned file, or a hosted map document) that callers load and
675
+ // hand to the engines. The store owns only what the engines write.
676
+
677
+ export interface MarketStore {
678
+ /** Operator-facing location of the capture manifest (file path or logical label). */
679
+ captureLocation(): string;
680
+ /** Persist one page's extracted text under its content hash (idempotent). */
681
+ saveCaptureText(captureHash: string, text: string): Promise<void>;
682
+ /** Append one capture pass's manifest entries (append-only; never rewrites history). */
683
+ appendCaptureEntries(entries: CaptureEntry[]): Promise<void>;
684
+ /** Full manifest + resolvable texts — the evidence-verification input. */
685
+ loadCaptureTexts(): Promise<{ entries: CaptureEntry[]; textByHash: Map<string, string> }>;
686
+ /** Append-only observation runs (the same ObservationStore contract as before). */
687
+ observations: ObservationStore;
688
+ }
689
+
690
+ /** The CLI's store: captures + observations under the profile market home. */
691
+ export function createFileMarketStore(
692
+ category: string,
693
+ options: { capturesDir?: string; observationsDir?: string } = {},
694
+ ): MarketStore {
695
+ const dir = options.capturesDir ?? join(marketHome(category), "captures");
696
+ const manifestPath = join(dir, "manifest.json");
697
+ return {
698
+ captureLocation: () => manifestPath,
699
+ async saveCaptureText(captureHash, text) {
700
+ mkdirSync(dir, { recursive: true });
701
+ writeFileSync(join(dir, `${captureHash}.txt`), text);
702
+ },
703
+ async appendCaptureEntries(entries) {
704
+ mkdirSync(dir, { recursive: true });
705
+ const manifest: CaptureEntry[] = existsSync(manifestPath)
706
+ ? (JSON.parse(readFileSync(manifestPath, "utf8")) as CaptureEntry[])
707
+ : [];
708
+ manifest.push(...entries);
709
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
710
+ },
711
+ async loadCaptureTexts() {
712
+ return loadCaptureTexts(category, dir);
713
+ },
714
+ observations: createFileObservationStore(category, options.observationsDir),
715
+ };
716
+ }
717
+
718
+ /**
719
+ * In-memory store: seam tests, and throwaway grounding captures (e.g. the
720
+ * taxonomy proposer's bootstrap pass) that should never persist anywhere.
721
+ */
722
+ export function createMemoryMarketStore(category: string): MarketStore {
723
+ const entries: CaptureEntry[] = [];
724
+ const textByHash = new Map<string, string>();
725
+ const sets = new Map<string, ObservationSet>();
726
+ const sorted = () => [...sets.values()].sort((a, b) => a.runAt.localeCompare(b.runAt));
727
+ return {
728
+ captureLocation: () => `memory://${category}/captures`,
729
+ async saveCaptureText(captureHash, text) {
730
+ textByHash.set(captureHash, text);
731
+ },
732
+ async appendCaptureEntries(newEntries) {
733
+ entries.push(...newEntries);
734
+ },
735
+ async loadCaptureTexts() {
736
+ return { entries: [...entries], textByHash: new Map(textByHash) };
737
+ },
738
+ observations: {
739
+ async append(set) {
740
+ if (set.category !== category) {
741
+ throw new Error(`Observation set category "${set.category}" does not match store "${category}"`);
742
+ }
743
+ if (sets.has(set.runLabel)) {
744
+ throw new Error(`Run "${set.runLabel}" already exists — observations are append-only; use a new run label`);
745
+ }
746
+ sets.set(set.runLabel, set);
747
+ return set;
748
+ },
749
+ async get(runLabel) {
750
+ return sets.get(runLabel) ?? null;
751
+ },
752
+ async list() {
753
+ return sorted().map((set) => ({
754
+ runLabel: set.runLabel,
755
+ runAt: set.runAt,
756
+ observations: set.observations.length,
757
+ }));
758
+ },
759
+ async latest() {
760
+ const all = sorted();
761
+ return all.length ? all[all.length - 1] : null;
762
+ },
763
+ },
764
+ };
765
+ }
766
+
654
767
  /**
655
768
  * Whitespace-only normalization for span matching, plus one extraction
656
769
  * artifact: the HTML-to-text step can emit a line break before punctuation
@@ -7,9 +7,11 @@ import {
7
7
  type MarketClaim,
8
8
  type MarketConfig,
9
9
  type MarketObservation,
10
+ type MarketStore,
10
11
  type ObservationSet,
11
12
  type SpanVerificationFailure,
12
13
  } from "./market.ts";
14
+ import { MARKET_CAPTURE_STAGES, nullProgressEmitter, type ProgressEmitter } from "./progress.ts";
13
15
 
14
16
  /**
15
17
  * LLM intensity classification for the market map — the same
@@ -112,9 +114,16 @@ export type ClassifyMarketOptions = {
112
114
  vendors?: string[];
113
115
  /** Captures directory override (tests); defaults to the profile market home. */
114
116
  capturesDir?: string;
117
+ /** Storage seam: when given, captures are read through it instead of the file layout. */
118
+ store?: MarketStore;
115
119
  now?: () => Date;
116
120
  /** Per-vendor progress (presentation only — a throwing callback never fails the run). */
117
121
  onVendor?: (done: number, total: number, vendorId: string) => void;
122
+ /**
123
+ * Progress emission over MARKET_CAPTURE_STAGES (the "classify" stage, items
124
+ * per vendor). Presentation-only: a throwing listener never fails the run.
125
+ */
126
+ progress?: ProgressEmitter;
118
127
  };
119
128
 
120
129
  export type ClassifyMarketResult = {
@@ -129,7 +138,10 @@ export async function classifyMarket(
129
138
  options: ClassifyMarketOptions,
130
139
  ): Promise<ClassifyMarketResult> {
131
140
  const model = options.llm.model ?? DEFAULT_MODELS[options.llm.provider];
132
- const { entries, textByHash } = loadCaptureTexts(config.category, options.capturesDir);
141
+ const progress = options.progress ?? nullProgressEmitter();
142
+ const { entries, textByHash } = options.store
143
+ ? await options.store.loadCaptureTexts()
144
+ : loadCaptureTexts(config.category, options.capturesDir);
133
145
  if (entries.length === 0) {
134
146
  throw new Error(`No captures for ${config.category} — run \`market capture\` first`);
135
147
  }
@@ -145,12 +157,14 @@ export async function classifyMarket(
145
157
  const observations: MarketObservation[] = [];
146
158
  const retriedVendorIds: string[] = [];
147
159
 
160
+ progress.stage(MARKET_CAPTURE_STAGES[2], 2, MARKET_CAPTURE_STAGES.length);
148
161
  for (const [vendorIndex, vendorId] of vendorIds.entries()) {
149
162
  try {
150
163
  options.onVendor?.(vendorIndex, vendorIds.length, vendorId);
151
164
  } catch {
152
165
  // progress is presentation-only
153
166
  }
167
+ progress.note(vendorId);
154
168
  const vendor = config.vendors.find((candidate) => candidate.id === vendorId);
155
169
  if (!vendor) throw new Error(`Unknown vendor "${vendorId}"`);
156
170
  const vendorEntries = runEntries.filter((entry) => entry.vendorId === vendorId);
@@ -174,6 +188,7 @@ export async function classifyMarket(
174
188
  evidence: [],
175
189
  });
176
190
  }
191
+ progress.items(vendorIndex + 1, vendorIds.length);
177
192
  continue;
178
193
  }
179
194
 
@@ -229,7 +244,9 @@ export async function classifyMarket(
229
244
  );
230
245
  }
231
246
  for (const reading of outcome.readings) observations.push(toObservation(reading, vendorId));
247
+ progress.items(vendorIndex + 1, vendorIds.length);
232
248
  }
249
+ progress.flush();
233
250
 
234
251
  return {
235
252
  set: {
@@ -9,6 +9,7 @@ import {
9
9
  loadCaptureTexts,
10
10
  type MarketClaim,
11
11
  type MarketConfig,
12
+ type MarketStore,
12
13
  type MarketVendor,
13
14
  } from "./market.ts";
14
15
 
@@ -44,6 +45,11 @@ export type SuggestTaxonomyOptions = {
44
45
  /** Test injectables. */
45
46
  fetchPage?: FetchPage;
46
47
  capturesDir?: string;
48
+ /**
49
+ * Storage seam for the bootstrap captures; defaults to the file layout.
50
+ * Pass createMemoryMarketStore for throwaway grounding (hosted flow).
51
+ */
52
+ store?: MarketStore;
47
53
  now?: () => Date;
48
54
  };
49
55
 
@@ -227,9 +233,11 @@ export async function suggestMarketConfig(options: SuggestTaxonomyOptions): Prom
227
233
  // fetched (the SSRF guard in captureMarket applies to these user-supplied URLs).
228
234
  await captureMarket(
229
235
  { category, vendors, claims: [] },
230
- { dir: options.capturesDir, runLabel: "bootstrap", fetchPage: options.fetchPage, now: options.now },
236
+ { dir: options.capturesDir, store: options.store, runLabel: "bootstrap", fetchPage: options.fetchPage, now: options.now },
231
237
  );
232
- const capture = loadCaptureTexts(category, options.capturesDir);
238
+ const capture = options.store
239
+ ? await options.store.loadCaptureTexts()
240
+ : loadCaptureTexts(category, options.capturesDir);
233
241
  const { dossier, unreadable } = buildDossier(vendors, capture, perVendorChars);
234
242
  if (!dossier.trim()) {
235
243
  throw new Error(
package/src/mcp-bin.ts CHANGED
@@ -1,6 +1,38 @@
1
1
  #!/usr/bin/env node
2
+ const args = process.argv.slice(2);
3
+
4
+ if (args.includes("--help") || args.includes("-h")) {
5
+ console.log(`Usage: fullstackgtm-mcp [--help] [--version]
6
+
7
+ Starts the fullstackgtm MCP stdio server.
8
+
9
+ The MCP server requires optional peer dependencies:
10
+ @modelcontextprotocol/sdk
11
+ zod
12
+
13
+ Zero-install with peers:
14
+ npx -y -p fullstackgtm -p @modelcontextprotocol/sdk -p zod fullstackgtm-mcp`);
15
+ process.exit(0);
16
+ }
17
+
18
+ if (args.includes("--version") || args.includes("-v")) {
19
+ console.log(await readPackageVersion());
20
+ process.exit(0);
21
+ }
22
+
2
23
  export {};
3
24
 
25
+ async function readPackageVersion(): Promise<string> {
26
+ try {
27
+ const { readFileSync } = await import("node:fs");
28
+ const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
29
+ const parsed = JSON.parse(raw) as { version?: string };
30
+ return parsed.version ?? "unknown";
31
+ } catch {
32
+ return "unknown";
33
+ }
34
+ }
35
+
4
36
  // The MCP server needs the optional peer dependencies. Import dynamically so
5
37
  // a missing peer produces install guidance instead of a module-load stack
6
38
  // trace — `npx fullstackgtm-mcp` alone never installs optional peers.
package/src/mcp.ts CHANGED
@@ -53,6 +53,8 @@ import {
53
53
  import { generateDemoSnapshot } from "./demo.ts";
54
54
  import type { FieldMappings } from "./mappings.ts";
55
55
  import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
56
+ import { verifyApprovalDigests } from "./integrity.ts";
57
+ import { createFilePlanStore, type StoredPlan } from "./planStore.ts";
56
58
  import { builtinAuditRules } from "./rules.ts";
57
59
  import { sampleSnapshot } from "./sampleData.ts";
58
60
  import { normalizeTranscript, parseCall } from "./calls.ts";
@@ -322,7 +324,11 @@ const toolDefinitions: ToolDefinition[] = [
322
324
  description:
323
325
  "Apply explicitly approved operations from a patch plan through a provider " +
324
326
  "connector. Operations not listed in approvedOperationIds are never written, " +
325
- "and requires_human_* placeholders need a value override.",
327
+ "and requires_human_* placeholders need a value override. When the plan is in " +
328
+ "the local plan store (saved via `audit --save`), approvals are verified against " +
329
+ "the store's HMAC approval digests — ids not approved with `plans approve` are " +
330
+ "refused — and the run is recorded onto the stored plan so `plans show` and " +
331
+ "`audit-log export` include it.",
326
332
  inputSchema: {
327
333
  provider: z.enum(["hubspot", "salesforce"]),
328
334
  planPath: z.string(),
@@ -332,14 +338,128 @@ const toolDefinitions: ToolDefinition[] = [
332
338
  },
333
339
  },
334
340
  handler: async ({ provider, planPath, approvedOperationIds, valueOverrides, output }) => {
335
- const plan = JSON.parse(
336
- readFileSync(resolve(process.cwd(), planPath), "utf8"),
337
- ) as PatchPlan;
341
+ // The file may be a raw PatchPlan (audit --out) or a StoredPlan envelope
342
+ // (a file straight out of the plan-store directory).
343
+ const parsed = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8")) as unknown;
344
+ const filePlan = isStoredPlanFile(parsed) ? parsed.plan : (parsed as PatchPlan);
345
+ if (!filePlan || !Array.isArray(filePlan.operations)) {
346
+ throw new Error(`${planPath} is not a patch plan (expected { id, operations: [...] }).`);
347
+ }
348
+
349
+ // Governance parity with CLI apply: when this plan id exists in the local
350
+ // plan store, the STORE is the source of truth — the approved-id set must
351
+ // come from `plans approve` (which HMAC-signed each approved op), the
352
+ // signatures are re-verified here, and the run is recorded back onto the
353
+ // plan so audit-log export sees MCP applies exactly like CLI applies.
354
+ const store = createFilePlanStore();
355
+ const stored =
356
+ typeof filePlan.id === "string" && /^[\w.-]+$/.test(filePlan.id)
357
+ ? await store.get(filePlan.id)
358
+ : null;
359
+
360
+ let plan: PatchPlan;
361
+ let effectiveOverrides: Record<string, unknown>;
362
+ if (stored) {
363
+ if (stored.status !== "approved") {
364
+ throw new Error(
365
+ `Plan ${filePlan.id} is ${stored.status}; approve operations first with ` +
366
+ `\`fullstackgtm plans approve ${filePlan.id} --operations <ids|all>\`.`,
367
+ );
368
+ }
369
+ // Downgrade guard (same as CLI apply): an approved plan with no
370
+ // signatures either predates 0.26 or had approvalDigests stripped to
371
+ // skip the integrity check. Refuse rather than trust the file.
372
+ if (stored.approvedOperationIds.length > 0 && !stored.approvalDigests) {
373
+ throw new Error(
374
+ `Refusing to apply plan ${filePlan.id}: it was approved without integrity signatures ` +
375
+ "(approved before 0.26.0, or its signatures were removed). Re-approve it with " +
376
+ `\`fullstackgtm plans approve ${filePlan.id} --operations <ids|all>\`.`,
377
+ );
378
+ }
379
+ // Never widen: every id the tool wants applied must already be
380
+ // store-approved. (A subset is fine — narrowing never writes more.)
381
+ const storeApproved = new Set(stored.approvedOperationIds);
382
+ const unapproved = approvedOperationIds.filter((id: string) => !storeApproved.has(id));
383
+ if (unapproved.length > 0) {
384
+ throw new Error(
385
+ `Refusing to apply plan ${filePlan.id}: operation(s) ${unapproved.join(", ")} were never ` +
386
+ "approved in the plan store. Approve them first with " +
387
+ `\`fullstackgtm plans approve ${filePlan.id} --operations <ids>\`.`,
388
+ );
389
+ }
390
+ // Integrity gate: verify against the EFFECTIVE overrides (stored ∪ tool
391
+ // args), so a tool-supplied value that changes what the human approved
392
+ // is treated as tamper, not a live override — what gets written must
393
+ // equal what was signed.
394
+ effectiveOverrides = { ...stored.valueOverrides, ...(valueOverrides ?? {}) };
395
+ const verification = verifyApprovalDigests(
396
+ stored.plan.operations,
397
+ stored.approvedOperationIds,
398
+ effectiveOverrides,
399
+ stored.approvalDigests,
400
+ );
401
+ if (!verification.ok) {
402
+ const detail =
403
+ verification.reason === "no_key"
404
+ ? "the plan-signing key is missing (was this plan approved on another machine?). Re-approve it here with `fullstackgtm plans approve`."
405
+ : `these operations differ from what was approved: ${verification.tampered.join(", ")}. ` +
406
+ "If you want a different value, set it at approval (`plans approve --value <op>=<v>`) and re-approve; " +
407
+ "otherwise the plan was edited after approval — review and re-approve.";
408
+ throw new Error(`Refusing to apply plan ${filePlan.id}: ${detail}`);
409
+ }
410
+ plan = stored.plan; // apply what was signed, not what the file says now
411
+ } else {
412
+ // External plan file, not in the store: no digests exist to verify, but
413
+ // approvals must still reference real operations in the plan content.
414
+ plan = filePlan;
415
+ const known = new Set(plan.operations.map((operation) => operation.id));
416
+ const unknown = approvedOperationIds.filter((id: string) => !known.has(id));
417
+ if (unknown.length > 0) {
418
+ throw new Error(
419
+ `Plan ${planPath} has no operation(s) ${unknown.join(", ")} — approvedOperationIds ` +
420
+ "must reference operations in the plan.",
421
+ );
422
+ }
423
+ effectiveOverrides = valueOverrides ?? {};
424
+ }
425
+
338
426
  const run = await applyPatchPlan(await connectorFor(provider), plan, {
339
427
  approvedOperationIds,
340
- valueOverrides,
428
+ valueOverrides: effectiveOverrides,
341
429
  });
342
- return content(output === "markdown" ? formatPatchPlanRun(run) : run);
430
+
431
+ // Persist the run (same data the CLI records) so runs[] and plan status
432
+ // update and audit-log export includes MCP applies. External plan files
433
+ // have no store entry to update — say so instead of silently dropping it.
434
+ let runRecorded = false;
435
+ if (stored) {
436
+ await store.recordRun(plan.id, run);
437
+ runRecorded = true;
438
+ }
439
+ const governance = {
440
+ planSource: stored ? ("store" as const) : ("external" as const),
441
+ approvalDigestsVerified: stored !== null,
442
+ runRecorded,
443
+ ...(stored
444
+ ? {}
445
+ : {
446
+ note:
447
+ "Plan file is not in the local plan store: approvals came from tool arguments " +
448
+ "(verified against the plan content only — no approval-digest check) and the run " +
449
+ "was not recorded. Use `fullstackgtm audit --save` + `plans approve` for " +
450
+ "store-backed governance.",
451
+ }),
452
+ };
453
+ if (output === "markdown") {
454
+ return content(
455
+ `${formatPatchPlanRun(run)}\n\nGovernance: plan source ${governance.planSource}; ` +
456
+ (runRecorded
457
+ ? "approval digests verified; run recorded on the stored plan (audit-log export includes it)."
458
+ : governance.note!),
459
+ );
460
+ }
461
+ // Backward compatible: the run's own fields stay top-level; governance is additive.
462
+ return content({ ...run, governance });
343
463
  },
344
464
  },
345
465
  {
@@ -456,6 +576,20 @@ export async function startMcpServer() {
456
576
  await server.connect(transport);
457
577
  }
458
578
 
579
+ /**
580
+ * A plan-store file is a StoredPlan envelope ({ plan, status, runs, ... });
581
+ * `audit --out` writes a bare PatchPlan. fullstackgtm_apply accepts either.
582
+ */
583
+ function isStoredPlanFile(value: unknown): value is StoredPlan {
584
+ if (typeof value !== "object" || value === null || !("plan" in value)) return false;
585
+ const plan = (value as { plan?: unknown }).plan;
586
+ return (
587
+ typeof plan === "object" &&
588
+ plan !== null &&
589
+ Array.isArray((plan as { operations?: unknown }).operations)
590
+ );
591
+ }
592
+
459
593
  function loadMarketConfigOrHint(path: string): ReturnType<typeof loadMarketConfig> {
460
594
  try {
461
595
  return loadMarketConfig(path);