fullstackgtm 0.47.0 → 0.49.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 (100) hide show
  1. package/CHANGELOG.md +76 -6
  2. package/CONTRIBUTING.md +23 -4
  3. package/DATA-FLOWS.md +7 -2
  4. package/INSTALL_FOR_AGENTS.md +15 -6
  5. package/README.md +28 -9
  6. package/SECURITY.md +27 -3
  7. package/dist/audit.js +7 -0
  8. package/dist/backfill.js +2 -16
  9. package/dist/cli/audit.js +10 -7
  10. package/dist/cli/auth.js +8 -4
  11. package/dist/cli/fix.d.ts +3 -0
  12. package/dist/cli/fix.js +90 -8
  13. package/dist/cli/help.d.ts +6 -0
  14. package/dist/cli/help.js +81 -3
  15. package/dist/cli/market.js +180 -2
  16. package/dist/cli/plans.js +56 -5
  17. package/dist/cli/suggest.d.ts +2 -1
  18. package/dist/cli/suggest.js +49 -3
  19. package/dist/cli/ui.js +2 -0
  20. package/dist/cli.js +41 -16
  21. package/dist/config.d.ts +13 -1
  22. package/dist/config.js +23 -2
  23. package/dist/connectors/hubspot.d.ts +2 -1
  24. package/dist/connectors/prospectSources.js +4 -11
  25. package/dist/connectors/salesforce.d.ts +2 -1
  26. package/dist/connectors/salesforce.js +12 -5
  27. package/dist/connectors/salesforceAuth.d.ts +9 -0
  28. package/dist/connectors/salesforceAuth.js +47 -5
  29. package/dist/connectors/signalSources.js +2 -11
  30. package/dist/connectors/theirstack.d.ts +0 -19
  31. package/dist/connectors/theirstack.js +3 -10
  32. package/dist/credentials.js +36 -26
  33. package/dist/format.js +31 -5
  34. package/dist/freeEmailDomains.d.ts +1 -0
  35. package/dist/freeEmailDomains.js +14 -0
  36. package/dist/hierarchy.d.ts +29 -0
  37. package/dist/hierarchy.js +164 -0
  38. package/dist/index.d.ts +9 -4
  39. package/dist/index.js +8 -3
  40. package/dist/market.d.ts +1 -1
  41. package/dist/market.js +7 -127
  42. package/dist/marketClassify.d.ts +10 -0
  43. package/dist/marketClassify.js +52 -1
  44. package/dist/marketSourcing.js +7 -45
  45. package/dist/mcp.js +86 -130
  46. package/dist/planStore.d.ts +28 -1
  47. package/dist/planStore.js +195 -49
  48. package/dist/providerError.d.ts +21 -0
  49. package/dist/providerError.js +30 -0
  50. package/dist/publicHttp.d.ts +28 -0
  51. package/dist/publicHttp.js +143 -0
  52. package/dist/relationships.d.ts +39 -0
  53. package/dist/relationships.js +101 -0
  54. package/dist/route.d.ts +46 -0
  55. package/dist/route.js +228 -0
  56. package/dist/rules.d.ts +1 -0
  57. package/dist/rules.js +172 -12
  58. package/dist/secureFile.d.ts +15 -0
  59. package/dist/secureFile.js +164 -0
  60. package/dist/types.d.ts +16 -1
  61. package/docs/api.md +49 -5
  62. package/docs/architecture.md +18 -4
  63. package/llms.txt +7 -0
  64. package/package.json +5 -3
  65. package/skills/fullstackgtm/SKILL.md +9 -3
  66. package/src/audit.ts +7 -0
  67. package/src/backfill.ts +2 -17
  68. package/src/cli/audit.ts +10 -7
  69. package/src/cli/auth.ts +8 -4
  70. package/src/cli/fix.ts +96 -8
  71. package/src/cli/help.ts +88 -3
  72. package/src/cli/market.ts +181 -2
  73. package/src/cli/plans.ts +66 -5
  74. package/src/cli/suggest.ts +58 -4
  75. package/src/cli/ui.ts +1 -0
  76. package/src/cli.ts +39 -14
  77. package/src/config.ts +39 -1
  78. package/src/connectors/hubspot.ts +3 -1
  79. package/src/connectors/prospectSources.ts +4 -11
  80. package/src/connectors/salesforce.ts +15 -6
  81. package/src/connectors/salesforceAuth.ts +46 -6
  82. package/src/connectors/signalSources.ts +2 -12
  83. package/src/connectors/theirstack.ts +3 -10
  84. package/src/credentials.ts +47 -28
  85. package/src/format.ts +33 -5
  86. package/src/freeEmailDomains.ts +14 -0
  87. package/src/hierarchy.ts +185 -0
  88. package/src/index.ts +29 -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 +115 -152
  93. package/src/planStore.ts +235 -57
  94. package/src/providerError.ts +37 -0
  95. package/src/publicHttp.ts +147 -0
  96. package/src/relationships.ts +115 -0
  97. package/src/route.ts +278 -0
  98. package/src/rules.ts +192 -12
  99. package/src/secureFile.ts +169 -0
  100. package/src/types.ts +18 -0
package/src/cli/fix.ts CHANGED
@@ -1,13 +1,18 @@
1
1
  // Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
2
2
 
3
- import { readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
5
  import { auditSnapshot, defaultPolicy } from "../audit.ts";
6
- import { loadConfig, mergePolicy, resolveConfiguredRules } from "../config.ts";
6
+ import { loadConfig, mergePolicy, resolveConfiguredRules, rulePackageTrustFromCli } from "../config.ts";
7
7
  import { applyPatchPlan } from "../connector.ts";
8
8
  import { patchPlanToMarkdown } from "../format.ts";
9
9
  import { createFilePlanStore } from "../planStore.ts";
10
+ import { verifyApprovalDigests } from "../integrity.ts";
10
11
  import { resolveRecord, type ResolveCandidate } from "../resolve.ts";
12
+ import { parseAssignmentPolicy } from "../assign.ts";
13
+ import { buildLeadRoutePlan } from "../route.ts";
14
+ import { accountHierarchyToMarkdown, buildAccountHierarchy } from "../hierarchy.ts";
15
+ import { buildRelationshipMap, relationshipMapToMarkdown } from "../relationships.ts";
11
16
  import { buildBulkUpdatePlan } from "../bulkUpdate.ts";
12
17
  import { buildDedupePlan, type DedupeOptions } from "../dedupe.ts";
13
18
  import { buildReassignPlans, type ReassignObjectType } from "../reassign.ts";
@@ -209,8 +214,9 @@ export async function fixCommand(args: string[]) {
209
214
  }
210
215
  const includeCreates = args.includes("--include-creates");
211
216
 
212
- const loaded = loadConfig(option(args, "--config") ?? undefined);
213
- const configured = await resolveConfiguredRules(loaded);
217
+ const explicitConfig = option(args, "--config") ?? undefined;
218
+ const loaded = loadConfig(explicitConfig);
219
+ const configured = await resolveConfiguredRules(loaded, undefined, rulePackageTrustFromCli(args, explicitConfig));
214
220
  const rule = configured.find((candidate) => candidate.id === ruleId);
215
221
  if (!rule) {
216
222
  throw new Error(`Unknown rule: ${ruleId}. Available rules: ${configured.map((r) => r.id).join(", ")}`);
@@ -283,6 +289,22 @@ export async function fixCommand(args: string[]) {
283
289
  return;
284
290
  }
285
291
  const connector = await connectorFor(provider, args);
292
+ const claimed = await store.claimApply(plan.id, { provider, source: "fix" });
293
+ const { claimId } = claimed;
294
+ const claimedVerification = verifyApprovalDigests(
295
+ claimed.stored.plan.operations,
296
+ claimed.stored.approvedOperationIds,
297
+ claimed.stored.valueOverrides,
298
+ claimed.stored.approvalDigests,
299
+ );
300
+ if (!claimedVerification.ok) {
301
+ await store.abortApplyPreflight(
302
+ plan.id,
303
+ claimId,
304
+ "Approval integrity verification failed after the claim and before provider I/O.",
305
+ );
306
+ throw new Error(`Refusing to apply plan ${plan.id}: approval changed while acquiring the apply claim.`);
307
+ }
286
308
  // Live apply board on interactive terminals (stderr; inert otherwise); the
287
309
  // same emitter streams heartbeats to the paired hosted app on long runs.
288
310
  const renderer = createProgressRenderer(APPLY_STAGES);
@@ -291,15 +313,18 @@ export async function fixCommand(args: string[]) {
291
313
  );
292
314
  let run: PatchPlanRun;
293
315
  try {
294
- run = await applyPatchPlan(connector, plan, {
295
- approvedOperationIds: approvedIds,
296
- valueOverrides: overrides,
316
+ run = await applyPatchPlan(connector, claimed.stored.plan, {
317
+ approvedOperationIds: claimed.stored.approvedOperationIds,
318
+ valueOverrides: claimed.stored.valueOverrides,
297
319
  progress,
298
320
  });
321
+ } catch (error) {
322
+ await store.markApplyUncertain(plan.id, claimId);
323
+ throw error;
299
324
  } finally {
300
325
  renderer.done();
301
326
  }
302
- await store.recordRun(plan.id, run);
327
+ await store.recordRun(plan.id, run, claimId);
303
328
  const counts: Record<string, number> = { applied: 0, conflict: 0, skipped: 0, failed: 0 };
304
329
  for (const result of run.results) counts[result.status] = (counts[result.status] ?? 0) + 1;
305
330
  lines.push(
@@ -371,3 +396,66 @@ function snapshotSourceHint(args: string[]) {
371
396
  if (input) return `--input ${input} `;
372
397
  return "";
373
398
  }
399
+
400
+ function parseJsonOrFile(value: string): unknown {
401
+ const candidate = resolve(process.cwd(), value);
402
+ const text = existsSync(candidate) ? readFileSync(candidate, "utf8") : value;
403
+ return JSON.parse(text);
404
+ }
405
+
406
+ export async function routeCommand(args: string[]) {
407
+ const [subcommand, ...rest] = args;
408
+ if (subcommand !== "leads") {
409
+ throw new Error("Usage: fullstackgtm route leads [source options] [--match domain|company|both] [--no-inherit-owner] [--reassign-owned] [--policy <json|path>] [--save] [--json] [--out <path>]");
410
+ }
411
+ const match = option(rest, "--match") ?? "domain";
412
+ if (!["domain", "company", "both"].includes(match)) throw new Error("--match must be domain, company, or both");
413
+ const policyRaw = option(rest, "--policy");
414
+ const snapshot = await readSnapshot(rest);
415
+ const result = buildLeadRoutePlan(snapshot, {
416
+ matchByDomain: match === "domain" || match === "both",
417
+ matchByCompanyName: match === "company" || match === "both",
418
+ inheritAccountOwner: !rest.includes("--no-inherit-owner"),
419
+ reassignExistingOwner: rest.includes("--reassign-owned"),
420
+ assignmentPolicy: policyRaw ? parseAssignmentPolicy(parseJsonOrFile(policyRaw)) : undefined,
421
+ maxOperations: numericOption(rest, "--max-operations"),
422
+ reason: option(rest, "--reason") ?? undefined,
423
+ });
424
+ if (rest.includes("--json")) {
425
+ const out = option(rest, "--out");
426
+ if (out) writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(result.plan, null, 2)}\n`);
427
+ if (saveRequested(rest)) await createFilePlanStore().save(result.plan);
428
+ console.error(`Route dry-run: ${JSON.stringify(result.counts)}`);
429
+ console.log(JSON.stringify({ counts: result.counts, plan: result.plan }, null, 2));
430
+ return;
431
+ }
432
+ console.error(
433
+ `Route dry-run: ${result.counts.linkOperations} link(s), ${result.counts.ownerOperations} owner assignment(s), ${result.counts.ambiguousAccountMatches} ambiguous match(es).`,
434
+ );
435
+ await emitPlan(result.plan, rest);
436
+ }
437
+
438
+ export async function hierarchyCommand(args: string[]) {
439
+ const [subcommand, ...rest] = args;
440
+ if (subcommand !== "report") throw new Error("Usage: fullstackgtm hierarchy report [source options] [--json|--out <path>]");
441
+ const snapshot = await readSnapshot(rest);
442
+ const report = buildAccountHierarchy(snapshot);
443
+ const out = option(rest, "--out");
444
+ const rendered = rest.includes("--json") ? `${JSON.stringify(report, null, 2)}\n` : `${accountHierarchyToMarkdown(report)}\n`;
445
+ if (out) writeFileSync(resolve(process.cwd(), out), rendered);
446
+ console.log(rendered.trimEnd());
447
+ }
448
+
449
+ export async function relationshipsCommand(args: string[]) {
450
+ const [subcommand, ...rest] = args;
451
+ if (subcommand !== "account") throw new Error("Usage: fullstackgtm relationships account (--account-id <id>|--domain <d>) [source options] [--json|--out <path>]");
452
+ const accountId = option(rest, "--account-id") ?? undefined;
453
+ const domain = option(rest, "--domain") ?? undefined;
454
+ if (!accountId && !domain) throw new Error("relationships account needs --account-id <id> or --domain <domain>");
455
+ const snapshot = await readSnapshot(rest);
456
+ const map = buildRelationshipMap(snapshot, { accountId, domain });
457
+ const out = option(rest, "--out");
458
+ const rendered = rest.includes("--json") ? `${JSON.stringify(map, null, 2)}\n` : `${relationshipMapToMarkdown(map)}\n`;
459
+ if (out) writeFileSync(resolve(process.cwd(), out), rendered);
460
+ console.log(rendered.trimEnd());
461
+ }
package/src/cli/help.ts CHANGED
@@ -50,6 +50,12 @@ Usage:
50
50
  fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]
51
51
  the create gate: exit 0 = safe to create, exit 2 = match
52
52
  found (exists/ambiguous) — call before ANY record creation
53
+ fullstackgtm route leads [source options] [--match domain|company|both] [--no-inherit-owner] [--reassign-owned] [--policy <json|path>] [--save|--json|--out <path>]
54
+ lead-to-account matching + owner routing as a governed plan
55
+ fullstackgtm hierarchy report [source options] [--json|--out <path>]
56
+ account hierarchy view from native parents + subdomains
57
+ fullstackgtm relationships account (--account-id <id>|--domain <d>) [source options] [--json|--out <path>]
58
+ relationship map from contacts, deals, and activity evidence
53
59
  fullstackgtm market init --category <name> start a market map: vendors + claim taxonomy as reviewable config
54
60
  fullstackgtm market capture [--config <path>] [--run <label>]
55
61
  fullstackgtm market classify [--run <label>] [--vendor <id>] [--model m] [--out <path>]
@@ -177,6 +183,7 @@ Usage:
177
183
  fullstackgtm plans list [--status <s>] | show <id> | reject <id>
178
184
  fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]
179
185
  fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
186
+ fullstackgtm plans recover <id> --acknowledge-uncertain-writes
180
187
  fullstackgtm apply --plan-id <id> --provider <name>
181
188
  fullstackgtm apply --plan-id <id> --channel outbox (render approved openers; transmits nothing)
182
189
  fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
@@ -224,6 +231,8 @@ Audit options:
224
231
  --config <path> Config file (default: ./fullstackgtm.config.json if present)
225
232
  { "policy": {...}, "rules": {"enabled":[],"disabled":[]},
226
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
227
236
  --rules <ids> Comma-separated rule ids to run (default: all; see \`rules\`)
228
237
  --json Print the JSON patch plan instead of markdown
229
238
  --out <path> Also write the JSON patch plan to a file
@@ -420,7 +429,32 @@ export const HELP: Record<string, HelpEntry> = {
420
429
  seeAlso: ["dedupe", "audit"],
421
430
  },
422
431
 
432
+ hierarchy: {
433
+ summary: "account hierarchy report (native parents + subdomain inference)",
434
+ phase: "Prevent",
435
+ synopsis: ["fullstackgtm hierarchy report [source options] [--json|--out <path>]"],
436
+ detail:
437
+ "Builds a report-only account tree from provider-native parent ids (when present in raw payloads) and deterministic subdomain inference. It surfaces duplicate-domain and ambiguous-parent conflicts instead of guessing writes.",
438
+ seeAlso: ["route", "relationships"],
439
+ },
440
+ relationships: {
441
+ summary: "relationship map for an account from contacts/deals/activity evidence",
442
+ phase: "Prevent",
443
+ synopsis: ["fullstackgtm relationships account (--account-id <id>|--domain <d>) [source options] [--json|--out <path>]"],
444
+ detail:
445
+ "Builds a stakeholder map with inferred buyer roles, sentiment from activity subjects, open deals, and missing-role gaps. Read-only evidence surface; no CRM writes.",
446
+ seeAlso: ["call", "route"],
447
+ },
448
+
423
449
  // Remediate — governed writes (all produce plans; nothing writes outside approve → apply)
450
+ route: {
451
+ summary: "lead-to-account matching and owner routing plan",
452
+ phase: "Remediate",
453
+ synopsis: ["fullstackgtm route leads [source options] [--match domain|company|both] [--no-inherit-owner] [--reassign-owned] [--policy <json|path>] [--save|--json|--out <path>]"],
454
+ detail:
455
+ "Matches contact-shaped leads to accounts by domain and/or company name, inherits account owners (or applies an assignment policy), and emits every change as a dry-run patch plan for approve → apply. Ambiguous matches are surfaced, never guessed.",
456
+ seeAlso: ["resolve", "reassign"],
457
+ },
424
458
  fix: {
425
459
  summary: "one-shot composite: audit one rule → suggest → approve → apply",
426
460
  phase: "Remediate",
@@ -515,6 +549,7 @@ export const HELP: Record<string, HelpEntry> = {
515
549
  "fullstackgtm plans list [--status <s>] | show <id> | reject <id>",
516
550
  "fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]",
517
551
  "fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low]",
552
+ "fullstackgtm plans recover <id> --acknowledge-uncertain-writes",
518
553
  ],
519
554
  detail: "Approval is explicit and per-operation; placeholders need a concrete --value or a suggestion to be approvable.",
520
555
  seeAlso: ["audit", "suggest", "apply"],
@@ -613,7 +648,57 @@ export const HELP: Record<string, HelpEntry> = {
613
648
 
614
649
  // Verbs that print their own richer multi-subcommand help; runCli routes their
615
650
  // `--help` to themselves, so commandHelp() only renders these via `help <verb>`.
616
- export const BESPOKE_HELP = ["init", "call", "market", "tam", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
651
+ export const BESPOKE_HELP = ["init", "call", "market", "tam", "enrich", "schedule", "signals", "icp", "draft"];
652
+
653
+ export const GLOBAL_FLAGS = ["--help", "--full"];
654
+ export const GLOBAL_SHORT_FLAGS = ["-h"];
655
+ export const SOURCE_FLAGS = ["--provider", "--token-env", "--input", "--demo", "--sample", "--seed", "--today"];
656
+ export const AUDIT_FLAGS = ["--config", "--allow-plugins", "--no-plugins", "--rules", "--stale-days", "--fail-on", "--save", "--dry-run", "--json", "--out", "--full"];
657
+
658
+ // Complete per-command flag registry used by runCli's fail-closed flag
659
+ // validation. Keep this next to HELP so focused help, machine capabilities,
660
+ // and the parser safety gate have one command inventory to reconcile against.
661
+ export const COMMAND_FLAGS: Record<string, string[]> = {
662
+ init: ["--source", "--provider", "--out", "--force"],
663
+ login: ["--via", "--hosted", "--private-token", "--token", "--key", "--api-key", "--client-id", "--client-secret", "--scopes", "--port", "--oauth", "--device", "--instance-url", "--login-url", "--no-validate"],
664
+ logout: [],
665
+ doctor: ["--json"],
666
+ capabilities: ["--json"],
667
+ "robot-docs": [],
668
+ profiles: ["--json"],
669
+ health: ["--json"],
670
+ snapshot: [...SOURCE_FLAGS, "--since", "--out", "--archive"],
671
+ audit: [...SOURCE_FLAGS, ...AUDIT_FLAGS],
672
+ report: [...SOURCE_FLAGS, ...AUDIT_FLAGS, "--plan", "--client", "--title", "--prepared-by", "--format", "--max-examples"],
673
+ diff: ["--before", "--after", "--config", "--allow-plugins", "--no-plugins", "--stale-days", "--today", "--json", "--fail-on-new-findings"],
674
+ rules: ["--config", "--allow-plugins", "--no-plugins", "--json"],
675
+ resolve: [...SOURCE_FLAGS, "--name", "--domain", "--email", "--account-id", "--json"],
676
+ hierarchy: [...SOURCE_FLAGS, "--json", "--out"],
677
+ relationships: [...SOURCE_FLAGS, "--account-id", "--domain", "--json", "--out"],
678
+ route: [...SOURCE_FLAGS, "--match", "--no-inherit-owner", "--reassign-owned", "--policy", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
679
+ fix: [...SOURCE_FLAGS, "--config", "--allow-plugins", "--no-plugins", "--rule", "--provider", "--min-confidence", "--include-creates", "--yes", "--confirm", "--dry-run"],
680
+ "bulk-update": [...SOURCE_FLAGS, "--where", "--set", "--guard", "--require", "--create-task", "--archive", "--force-archive-duplicates", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
681
+ dedupe: [...SOURCE_FLAGS, "--key", "--keep", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
682
+ reassign: [...SOURCE_FLAGS, "--from", "--assign-unowned", "--to", "--objects", "--where", "--except-deal-stage", "--include-closed-deals", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
683
+ backfill: [...SOURCE_FLAGS, "--since", "--pipeline", "--match-property", "--skip-unmatched", "--save", "--dry-run", "--json"],
684
+ 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"],
685
+ 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"],
686
+ suggest: [...SOURCE_FLAGS, "--plan-id", "--plan", "--json", "--out"],
687
+ plans: ["--status", "--operations", "--value", "--values-from", "--min-confidence", "--include-creates", "--acknowledge-uncertain-writes", "--json"],
688
+ apply: ["--plan", "--plan-id", "--provider", "--channel", "--token-env", "--approve", "--value", "--json", "--config"],
689
+ "audit-log": ["--in", "--out", "--json"],
690
+ merge: ["--input", "--out", "--json"],
691
+ market: ["--category", "--config", "--vendor", "--snapshot", "--task-account", "--task-deal", "--capture-run", "--run", "--prior-run", "--diff", "--from", "--calls", "--anchor", "--min-mentions", "--max-claims", "--promote-lift", "--model", "--format", "--auto", "--unverified", "--save", "--dry-run", "--json", "--out"],
692
+ tam: [...SOURCE_FLAGS, "--source", "--name", "--icp", "--accounts", "--acv", "--acv-basis", "--acv-from-crm", "--deal-period", "--buyers-per-account", "--cross-checks", "--max", "--max-credits", "--usd-per-credit", "--dry-run", "--confirm", "--cron", "--label", "--save", "--json", "--out"],
693
+ icp: [...SOURCE_FLAGS, "--name", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--save", "--json", "--out"],
694
+ signals: ["--bucket", "--source", "--connector", "--connector-opt", "--watchlist", "--keywords", "--from", "--label", "--since", "--account", "--contact", "--unjudged", "--touch", "--result", "--save", "--json", "--explain"],
695
+ draft: ["--from-judge", "--min-score", "--prompt", "--model", "--channel", "--save", "--dry-run", "--json", "--out"],
696
+ schedule: ["--cron", "--label", "--provider", "--trigger", "--runs", "--json"],
697
+ };
698
+
699
+ export const FLAGS_WITH_VALUES = new Set([
700
+ "--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",
701
+ ]);
617
702
 
618
703
  // Lifecycle-grouped front door. One line per verb, organized by the
619
704
  // Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
@@ -621,9 +706,9 @@ export function shortUsage() {
621
706
  const groups: Array<[string, string[]]> = [
622
707
  ["Get started", ["init"]],
623
708
  ["Setup & health", ["login", "logout", "doctor", "capabilities", "robot-docs", "profiles", "health"]],
624
- ["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules"]],
709
+ ["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules", "hierarchy", "relationships"]],
625
710
  ["Prevent — gate writes", ["resolve"]],
626
- ["Remediate — governed writes", ["fix", "bulk-update", "dedupe", "reassign", "enrich", "backfill"]],
711
+ ["Remediate — governed writes", ["fix", "bulk-update", "dedupe", "reassign", "route", "enrich", "backfill"]],
627
712
  ["Calls → evidence", ["call"]],
628
713
  ["Govern — the plan/apply spine", ["suggest", "plans", "apply", "audit-log", "merge"]],
629
714
  ["Market intelligence", ["market", "tam"]],
package/src/cli/market.ts CHANGED
@@ -4,7 +4,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
5
  import { createFilePlanStore } from "../planStore.ts";
6
6
  import type { ParsedCall } from "../calls.ts";
7
- import { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, loadCaptureTexts, loadMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, type ObservationSet } from "../market.ts";
7
+ import { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, loadCaptureTexts, loadMarketConfig, observationId, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, type ObservationSet } from "../market.ts";
8
8
  import { assessAxes, axesReportToText } from "../marketAxes.ts";
9
9
  import { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, type CallDocument } from "../marketOverlay.ts";
10
10
  import { computeScaleIndex, scaleReportToText } from "../marketScale.ts";
@@ -29,6 +29,7 @@ import { unknownSubcommandError } from "./suggest.ts";
29
29
  */
30
30
  const MARKET_SUBCOMMANDS = [
31
31
  "init", "capture", "classify", "worksheet", "observe", "fronts", "axes", "overlay", "scale", "report", "refresh",
32
+ "schema", "hints", "observe-template", "review",
32
33
  ];
33
34
 
34
35
  export async function marketCommand(args: string[]) {
@@ -42,9 +43,13 @@ export async function marketCommand(args: string[]) {
42
43
  market init --category <name> [--out <path>] write a starter market.config.json
43
44
  market init --category <name> --auto --vendor <url> [--vendor <url>...] [--anchor <url>] [--max-claims n]
44
45
  LLM-propose vendors + claim taxonomy from seed pages (needs an API key)
46
+ market schema print exact MarketConfig + ObservationSet shapes and a tiny example
45
47
  market capture [--config <path>] [--run <label>]
46
48
  market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model m] [--out <path>]
47
49
  market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
50
+ market hints [--vendor <id>] [--capture-run <label>] [--json]
51
+ market observe-template [--run <label>] [--out <path>]
52
+ market review --from <observations.json> [--json]
48
53
  market observe --from <observations.json|sets.jsonl|spool-dir> [--unverified]
49
54
  market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
50
55
  market axes [--config <path>] [--run <label>] [--json]
@@ -86,6 +91,64 @@ recomputed deterministically on every invocation — never stored.`);
86
91
  return;
87
92
  }
88
93
 
94
+ if (subcommand === "schema") {
95
+ console.log(`MarketConfig shape:
96
+ {
97
+ "category": "string",
98
+ "anchorVendor": "optional vendor id",
99
+ "vendors": [{
100
+ "id": "stable-vendor-id",
101
+ "name": "Vendor Name",
102
+ "urls": { "home": "https://...", "pricing": null, "product": ["https://..."] },
103
+ "aliases": ["optional alternate names"]
104
+ }],
105
+ "claims": [{
106
+ "id": "stable-claim-id",
107
+ "capability": "Specific differentiating capability",
108
+ "icp": "buyer/segment this claim matters to",
109
+ "pricingStructure": "pricing motion implied by the claim, or unknown",
110
+ "definition": "How to judge LOUD vs QUIET vs ABSENT from page text",
111
+ "terms": ["buyer/search terms for deterministic mention matching"]
112
+ }],
113
+ "surfaceRule": "LOUD = hero/primary positioning; QUIET = supported but secondary; ABSENT = no support; UNOBSERVABLE = no usable capture."
114
+ }
115
+
116
+ ObservationSet shape:
117
+ {
118
+ "id": "set_run1",
119
+ "category": "same category as config",
120
+ "runLabel": "run-1",
121
+ "runAt": "2026-06-11T00:00:00.000Z",
122
+ "extractor": "manual|agent:<model>|llm:<provider>:<model>",
123
+ "observations": [{
124
+ "id": "stable observation id (any unique string is accepted)",
125
+ "vendorId": "vendor id from config",
126
+ "claimId": "claim id from config",
127
+ "observedAt": "2026-06-11",
128
+ "intensity": "loud|quiet|absent|unobservable",
129
+ "confidence": "high|medium|low",
130
+ "reason": "one reviewer-facing sentence",
131
+ "evidence": [{
132
+ "id": "ev1",
133
+ "sourceSystem": "web",
134
+ "sourceObjectType": "page",
135
+ "sourceObjectId": "source URL",
136
+ "text": "VERBATIM quote from captured page text",
137
+ "metadata": { "url": "source URL", "captureHash": "hash from worksheet" }
138
+ }]
139
+ }]
140
+ }
141
+
142
+ Rules:
143
+ - exactly one observation per vendor × claim cell
144
+ - loud/quiet require ≥1 verbatim evidence quote
145
+ - absent/unobservable use [] evidence
146
+ - unobservable means no usable captured page exists for the vendor; if pages were captured but the claim is unsupported, use absent
147
+ - submit with: fullstackgtm market observe --from observations.json
148
+ - derive outputs with: fullstackgtm market fronts --json; fullstackgtm market report --format md --out market-report.md`);
149
+ return;
150
+ }
151
+
89
152
  if (subcommand === "init") {
90
153
  const category = option(rest, "--category");
91
154
  if (!category) throw new Error("market init requires --category <name>");
@@ -162,7 +225,22 @@ recomputed deterministically on every invocation — never stored.`);
162
225
  return;
163
226
  }
164
227
  if (!rest.includes("--unverified")) {
165
- const { textByHash } = loadCaptureTexts(config.category);
228
+ const { entries, textByHash } = loadCaptureTexts(config.category);
229
+ const usableVendorIds = new Set(
230
+ entries
231
+ .filter((entry) => entry.runLabel === set.runLabel && entry.captureHash && textByHash.has(entry.captureHash))
232
+ .map((entry) => entry.vendorId),
233
+ );
234
+ const unsupportedUnobservable = set.observations.filter((obs) => obs.intensity === "unobservable" && usableVendorIds.has(obs.vendorId));
235
+ if (unsupportedUnobservable.length > 0) {
236
+ console.error(`Rejected: ${unsupportedUnobservable.length} unobservable reading(s) for vendors with usable captures`);
237
+ for (const obs of unsupportedUnobservable.slice(0, 20)) {
238
+ console.error(` - ${obs.vendorId} × ${obs.claimId}: vendor has captured page text; unsupported claims should be absent, not unobservable`);
239
+ }
240
+ console.error("Use unobservable only when no usable captured page exists for that vendor/run. (--unverified skips this gate when captures genuinely live elsewhere.)");
241
+ process.exitCode = 1;
242
+ return;
243
+ }
166
244
  const failures = verifyEvidenceSpans(set.observations, textByHash);
167
245
  if (failures.length > 0) {
168
246
  console.error(`Rejected: ${failures.length} evidence span(s) failed verification against the stored captures`);
@@ -195,6 +273,107 @@ recomputed deterministically on every invocation — never stored.`);
195
273
  return;
196
274
  }
197
275
 
276
+ if (subcommand === "hints") {
277
+ const vendorFilter = option(rest, "--vendor");
278
+ const vendorIds = vendorFilter ? [vendorFilter] : config.vendors.map((vendor) => vendor.id);
279
+ const worksheets = vendorIds.map((vendorId) => buildWorksheet(config, vendorId, { captureRun: option(rest, "--capture-run") ?? undefined }));
280
+ if (rest.includes("--json")) {
281
+ console.log(JSON.stringify({
282
+ category: config.category,
283
+ captureRun: worksheets[0]?.captureRun ?? null,
284
+ vendors: worksheets.map((worksheet) => ({ vendor: worksheet.vendor, claimHints: worksheet.claimHints })),
285
+ }, null, 2));
286
+ return;
287
+ }
288
+ for (const worksheet of worksheets) {
289
+ console.log(`\n## ${worksheet.vendor.id} — ${worksheet.vendor.name}`);
290
+ if (worksheet.pages.length === 0) {
291
+ console.log("No usable captures: classify every claim as unobservable.");
292
+ continue;
293
+ }
294
+ for (const hint of worksheet.claimHints) {
295
+ console.log(`\n${hint.claimId}`);
296
+ if (hint.matches.length === 0) {
297
+ console.log(" no lexical matches in captured text (usually absent; still inspect pages for synonyms)");
298
+ continue;
299
+ }
300
+ for (const match of hint.matches) console.log(` - ${match.term} @ ${match.url}: ${match.quote}`);
301
+ }
302
+ }
303
+ return;
304
+ }
305
+
306
+ if (subcommand === "observe-template") {
307
+ const runLabel = option(rest, "--run") ?? "run-1";
308
+ const now = new Date().toISOString();
309
+ const observations = config.vendors.flatMap((vendor) => {
310
+ const worksheet = buildWorksheet(config, vendor.id, { captureRun: runLabel });
311
+ const noPages = worksheet.pages.length === 0;
312
+ return config.claims.map((claim) => ({
313
+ id: observationId(config.category, runLabel, vendor.id, claim.id),
314
+ vendorId: vendor.id,
315
+ claimId: claim.id,
316
+ observedAt: now.slice(0, 10),
317
+ intensity: noPages ? "unobservable" : "absent",
318
+ confidence: "low",
319
+ reason: noPages ? `No usable captures for ${vendor.name}; cannot judge.` : "TODO: inspect worksheet/hints and classify this cell.",
320
+ evidence: [],
321
+ _claimHints: worksheet.claimHints.find((hint) => hint.claimId === claim.id)?.matches ?? [],
322
+ }));
323
+ });
324
+ const set = { id: `set_${runLabel}`, category: config.category, runLabel, runAt: now, extractor: "manual-template", observations };
325
+ const payload = `${JSON.stringify(set, null, 2)}\n`;
326
+ const outPath = option(rest, "--out");
327
+ if (outPath) {
328
+ writeFileSync(resolve(process.cwd(), outPath), payload);
329
+ console.log(`Wrote ${outPath}: ${observations.length} template observations. Fill TODO cells, then run market review --from ${outPath}`);
330
+ } else {
331
+ console.log(payload);
332
+ }
333
+ return;
334
+ }
335
+
336
+ if (subcommand === "review") {
337
+ const fromPath = option(rest, "--from");
338
+ if (!fromPath) throw new Error("market review requires --from <observations.json>");
339
+ const set = JSON.parse(readFileSync(resolve(process.cwd(), fromPath), "utf8")) as ObservationSet;
340
+ const errors = validateObservationSet(config, set).map((detail) => ({ code: "invalid_observation_set", detail }));
341
+ const warnings: Array<{ code: string; detail: string }> = [];
342
+ const { entries, textByHash } = loadCaptureTexts(config.category);
343
+ const usableVendorIds = new Set(
344
+ entries
345
+ .filter((entry) => entry.runLabel === set.runLabel && entry.captureHash && textByHash.has(entry.captureHash))
346
+ .map((entry) => entry.vendorId),
347
+ );
348
+ for (const obs of set.observations) {
349
+ if (obs.intensity === "unobservable" && usableVendorIds.has(obs.vendorId)) {
350
+ errors.push({ code: "unobservable_with_captures", detail: `${obs.vendorId} × ${obs.claimId}: vendor has captured page text; use absent when unsupported.` });
351
+ }
352
+ if ((obs.intensity === "absent" || obs.intensity === "unobservable") && usableVendorIds.has(obs.vendorId)) {
353
+ const hints = buildWorksheet(config, obs.vendorId, { captureRun: set.runLabel }).claimHints.find((hint) => hint.claimId === obs.claimId)?.matches ?? [];
354
+ if (hints.length > 0) {
355
+ warnings.push({
356
+ code: "support_candidate_marked_absent",
357
+ 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}`,
358
+ });
359
+ }
360
+ }
361
+ }
362
+ for (const failure of verifyEvidenceSpans(set.observations, textByHash)) {
363
+ errors.push({ code: "bad_evidence", detail: `${failure.vendorId} × ${failure.claimId}: ${failure.problem}` });
364
+ }
365
+ const summary = { ok: errors.length === 0, errors, warnings };
366
+ if (rest.includes("--json")) {
367
+ console.log(JSON.stringify(summary, null, 2));
368
+ } else {
369
+ console.log(`${summary.ok ? "OK" : "FAILED"}: ${errors.length} error(s), ${warnings.length} warning(s)`);
370
+ for (const err of errors.slice(0, 20)) console.log(`ERROR ${err.code}: ${err.detail}`);
371
+ for (const warn of warnings.slice(0, 20)) console.log(`WARN ${warn.code}: ${warn.detail}`);
372
+ }
373
+ if (errors.length > 0) process.exitCode = 1;
374
+ return;
375
+ }
376
+
198
377
  if (subcommand === "classify") {
199
378
  const credential = await requireLlmCredential("market classify");
200
379
  const vendorFilter = option(rest, "--vendor");
package/src/cli/plans.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
5
  import { auditSnapshot, defaultPolicy } from "../audit.ts";
6
- import { loadConfig, mergePolicy, resolveConfiguredRules } from "../config.ts";
6
+ import { loadConfig, mergePolicy, resolveConfiguredRules, rulePackageTrustFromCli } from "../config.ts";
7
7
  import { applyPatchPlan } from "../connector.ts";
8
8
  import { diffFindings, diffSnapshots, diffToMarkdown } from "../diff.ts";
9
9
  import { createChannelConnector } from "../connectors/outboxChannel.ts";
@@ -234,6 +234,28 @@ export async function apply(args: string[]) {
234
234
  // A channel (e.g. outbox) renders approved ops to a local artifact and
235
235
  // transmits nothing; a CRM provider writes records. Same governed apply path.
236
236
  const connector = channel ? createChannelConnector(channel) : await connectorFor(provider!, args);
237
+ let applyClaimId: string | undefined;
238
+ if (planId && store) {
239
+ const claimed = await store.claimApply(planId, { provider: provider ?? `channel:${channel}`, source: "cli" });
240
+ applyClaimId = claimed.claimId;
241
+ const claimedVerification = verifyApprovalDigests(
242
+ claimed.stored.plan.operations,
243
+ claimed.stored.approvedOperationIds,
244
+ claimed.stored.valueOverrides,
245
+ claimed.stored.approvalDigests,
246
+ );
247
+ if (!claimedVerification.ok) {
248
+ await store.abortApplyPreflight(
249
+ planId,
250
+ claimed.claimId,
251
+ "Approval integrity verification failed after the claim and before provider I/O.",
252
+ );
253
+ throw new Error(`Refusing to apply plan ${planId}: approval changed while acquiring the apply claim.`);
254
+ }
255
+ plan = claimed.stored.plan;
256
+ approvedOperationIds = claimed.stored.approvedOperationIds;
257
+ valueOverrides = claimed.stored.valueOverrides;
258
+ }
237
259
  // Interactive terminals get a live apply board on stderr while the run
238
260
  // executes (preflight → operations → results, with a per-op safety ticker);
239
261
  // piped runs render nothing. Either way the emitter streams heartbeats to
@@ -249,11 +271,16 @@ export async function apply(args: string[]) {
249
271
  valueOverrides,
250
272
  progress,
251
273
  });
274
+ } catch (error) {
275
+ // Once applyPatchPlan starts, an exception cannot prove that the provider
276
+ // performed no write. Keep the claim fail-closed for operator reconciliation.
277
+ if (planId && store && applyClaimId) await store.markApplyUncertain(planId, applyClaimId);
278
+ throw error;
252
279
  } finally {
253
280
  renderer.done();
254
281
  }
255
282
  if (planId && store) {
256
- await store.recordRun(planId, run);
283
+ await store.recordRun(planId, run, applyClaimId!);
257
284
  }
258
285
 
259
286
  // Charge the acquire meter for the creates that actually landed.
@@ -307,8 +334,9 @@ export async function diffCommand(args: string[]) {
307
334
  readFileSync(resolve(process.cwd(), afterPath), "utf8"),
308
335
  ) as CanonicalGtmSnapshot;
309
336
 
310
- const loaded = loadConfig(option(args, "--config") ?? undefined);
311
- const rules = selectedRules(args, await resolveConfiguredRules(loaded));
337
+ const explicitConfig = option(args, "--config") ?? undefined;
338
+ const loaded = loadConfig(explicitConfig);
339
+ const rules = selectedRules(args, await resolveConfiguredRules(loaded, undefined, rulePackageTrustFromCli(args, explicitConfig)));
312
340
  const policy = mergePolicy(defaultPolicy(), loaded?.config);
313
341
  const today = option(args, "--today");
314
342
  if (today) policy.today = today;
@@ -455,6 +483,13 @@ export async function plansCommand(args: string[]) {
455
483
  console.log(`Status: ${planStatusWord(stored.status, showPaint)}`);
456
484
  console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
457
485
  console.log(`Runs: ${stored.runs.length}`);
486
+ if (stored.applyAttempts?.length) {
487
+ console.log("Apply attempts:");
488
+ for (const attempt of stored.applyAttempts) {
489
+ console.log(` ${attempt.id} ${attempt.status} ${attempt.provider} via ${attempt.source} ${attempt.claimedAt}`);
490
+ if (attempt.note) console.log(` ${attempt.note}`);
491
+ }
492
+ }
458
493
  console.log("");
459
494
  console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
460
495
  return;
@@ -520,5 +555,31 @@ export async function plansCommand(args: string[]) {
520
555
  return;
521
556
  }
522
557
 
523
- throw unknownSubcommandError("plans", subcommand, ["list", "show", "approve", "reject"]);
558
+ if (subcommand === "recover") {
559
+ const planId = rest.find((arg) => !arg.startsWith("--"));
560
+ if (!planId) {
561
+ throw new Error("Usage: fullstackgtm plans recover <planId> --acknowledge-uncertain-writes");
562
+ }
563
+ const stored = await store.get(planId);
564
+ if (!stored) throw new Error(`No stored plan with id ${planId}.`);
565
+ if (stored.status !== "applying" || !stored.applyClaim) {
566
+ throw new Error(`Plan ${planId} has no unresolved apply attempt.`);
567
+ }
568
+ const attempt = stored.applyAttempts?.find((entry) => entry.id === stored.applyClaim?.id);
569
+ if (!rest.includes("--acknowledge-uncertain-writes")) {
570
+ throw new Error(
571
+ `Plan ${planId} has an unresolved ${attempt?.provider ?? "provider"} apply attempt (${stored.applyClaim.id}). ` +
572
+ "Inspect the affected records in the provider; do not replay automatically. If you accept that some writes may already have landed, " +
573
+ `run \`fullstackgtm plans recover ${planId} --acknowledge-uncertain-writes\`. This clears every approval and requires a fresh review and approval before retrying.`,
574
+ );
575
+ }
576
+ await store.recoverApply(planId);
577
+ console.log(
578
+ `Released ${planId} to needs_approval after acknowledging uncertain provider state. ` +
579
+ `No writes were replayed. Re-audit provider state, review the plan, then approve operations again before any apply.`,
580
+ );
581
+ return;
582
+ }
583
+
584
+ throw unknownSubcommandError("plans", subcommand, ["list", "show", "approve", "reject", "recover"]);
524
585
  }