fullstackgtm 0.50.0 → 0.50.1

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.
@@ -15,6 +15,7 @@ import { progressReporter } from "../runReport.ts";
15
15
  import { unknownSubcommandError } from "./suggest.ts";
16
16
  import { option, readSnapshot, saveRequested } from "./shared.ts";
17
17
  import { colorEnabled, createProgressRenderer, paint, stylizePlanMarkdown, table } from "./ui.ts";
18
+ import { compactPlan, verbosePlanRequested } from "./planOutput.ts";
18
19
 
19
20
  /** STRIPE_SECRET_KEY env ∨ stored `login stripe` credential (same ladder as `--provider stripe`). */
20
21
  function resolveStripeKey(): string {
@@ -93,6 +94,8 @@ export async function backfillCommand(args: string[]) {
93
94
  2,
94
95
  ),
95
96
  );
97
+ } else if (!verbosePlanRequested(rest)) {
98
+ console.log(compactPlan(result.plan, { saved: save && result.plan.operations.length > 0 }));
96
99
  } else {
97
100
  // TTY-only styling; piped output stays byte-identical plain text.
98
101
  console.log(
package/src/cli/call.ts CHANGED
@@ -177,11 +177,11 @@ score always needs a key (scoring is LLM work).`);
177
177
  if (!outPath) console.log(JSON.stringify(parsed, null, 2));
178
178
  return;
179
179
  }
180
- console.log(`Call ${parsed.id}${parsed.title ? ` — ${parsed.title}` : ""} (${parsed.sourceSystem})`);
181
- console.log(`${parsed.segments.length} segments · ${parsed.insights.length} insights (${parsed.summary.highImportance} high-importance)\n`);
182
- for (const insight of parsed.insights) {
183
- console.log(`[${insight.type}] (importance ${insight.importance}) ${insight.text}`);
184
- }
180
+ console.log(`Call ${parsed.id}${parsed.title ? ` — ${parsed.title}` : ""}`);
181
+ console.log(`${parsed.segments.length} segments · ${parsed.insights.length} insights · ${parsed.summary.highImportance} high priority`);
182
+ const visible = rest.includes("--verbose") ? parsed.insights : parsed.insights.filter((insight) => insight.importance >= 3);
183
+ for (const insight of visible) console.log(`\n${insight.type.replaceAll("_", " ")} · priority ${insight.importance}\n ${insight.text.replace(/\s+/g, " ").trim()}`);
184
+ if (!rest.includes("--verbose") && visible.length < parsed.insights.length) console.log(`\n${parsed.insights.length - visible.length} lower-priority insight(s) hidden; use --verbose to show all.`);
185
185
  return;
186
186
  }
187
187
 
@@ -301,13 +301,32 @@ score always needs a key (scoring is LLM work).`);
301
301
  console.log(JSON.stringify(scorecard, null, 2));
302
302
  return;
303
303
  }
304
- console.log(renderScorecard(scorecard, title));
304
+ console.log(rest.includes("--verbose") ? renderScorecard(scorecard, title) : renderCompactScorecard(scorecard, title));
305
305
  return;
306
306
  }
307
307
 
308
308
  throw new Error(`call supports: parse, classify, link, plan, score (got ${subcommand ?? "nothing"})`);
309
309
  }
310
310
 
311
+ function renderCompactScorecard(scorecard: CallScorecard, title?: string): string {
312
+ const lines = [
313
+ `${title ?? "Call"} · ${scorecard.overallScore}/${scorecard.scale}${scorecard.band ? ` · ${scorecard.band.label}` : ""}`,
314
+ scorecard.rubricName ? `${scorecard.rubricName}${scorecard.callType ? ` (${scorecard.callType})` : ""}` : "",
315
+ "",
316
+ ].filter((line, index) => line || index === 2);
317
+ for (const dimension of scorecard.dimensions) {
318
+ lines.push(`${dimension.score}/${dimension.maxScore} ${dimension.name}`);
319
+ lines.push(` ${dimension.coachingNote.replace(/\s+/g, " ").trim()}`);
320
+ }
321
+ if (scorecard.missedItems.length) {
322
+ lines.push("", "Focus next");
323
+ for (const item of scorecard.missedItems) lines.push(` ${item}`);
324
+ }
325
+ if (scorecard.highlights.length) lines.push("", `Strengths: ${scorecard.highlights.join(" · ")}`);
326
+ lines.push("", "Use --verbose for the full coaching scorecard.");
327
+ return lines.join("\n");
328
+ }
329
+
311
330
  function renderScorecard(scorecard: CallScorecard, title?: string): string {
312
331
  const bandText = scorecard.band ? ` — ${scorecard.band.label}` : "";
313
332
  const rubricLine = scorecard.rubricName ? `Rubric: ${scorecard.rubricName}${scorecard.callType ? ` (${scorecard.callType})` : ""}` : "";
package/src/cli/draft.ts CHANGED
@@ -9,6 +9,7 @@ import { createFileJudgeStore } from "../judge.ts";
9
9
  import { authorOpeners, DEFAULT_DRAFT_PROMPT, DRAFT_CHANNELS, draft, type DraftChannel } from "../draft.ts";
10
10
  import type { LlmCallOptions } from "../llm.ts";
11
11
  import { numericOption, option, resolveLlmBaseUrls, saveRequested } from "./shared.ts";
12
+ import { compactPlan, verbosePlanRequested } from "./planOutput.ts";
12
13
 
13
14
 
14
15
  /**
@@ -78,7 +79,14 @@ LLM key it emits a clearly-labeled stub rather than fake authored copy.`);
78
79
  openers,
79
80
  });
80
81
 
81
- console.log(JSON.stringify({ drafts, rejected }, null, 2));
82
+ if (args.includes("--json")) {
83
+ console.log(JSON.stringify({ drafts, rejected }, null, 2));
84
+ } else if (verbosePlanRequested(args)) {
85
+ console.log(JSON.stringify({ drafts, rejected }, null, 2));
86
+ console.log(compactPlan(plan, { saved: save }));
87
+ } else {
88
+ console.log(compactPlan(plan, { saved: save }));
89
+ }
82
90
  const stale = drafts.filter((d) => d.staleTrigger);
83
91
  console.error(
84
92
  `${drafts.length} opener(s) staged as create_task proposals (channel ${channel}, min score ${minScore})` +
package/src/cli/enrich.ts CHANGED
@@ -24,6 +24,7 @@ import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveReques
24
24
  import { providerKey } from "./tam.ts";
25
25
  import { unknownSubcommandError } from "./suggest.ts";
26
26
  import { colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, type Paint } from "./ui.ts";
27
+ import { compactPlan, verbosePlanRequested } from "./planOutput.ts";
27
28
  import type { AcquireBudget } from "../acquireMeter.ts";
28
29
 
29
30
 
@@ -309,7 +310,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
309
310
  ),
310
311
  );
311
312
  } else {
312
- console.log(patchPlanToMarkdown(result.plan));
313
+ console.log(verbosePlanRequested(rest) ? patchPlanToMarkdown(result.plan) : compactPlan(result.plan));
313
314
  console.log(meterLine);
314
315
  if (gaugeLine) console.log(gaugeLine);
315
316
  console.log("\nDry run — nothing written. Re-run with --save to persist the plan, then approve + apply.");
@@ -514,7 +515,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
514
515
  if (rest.includes("--json")) {
515
516
  console.log(JSON.stringify(result.plan, null, 2));
516
517
  } else {
517
- console.log(patchPlanToMarkdown(result.plan));
518
+ console.log(verbosePlanRequested(rest) ? patchPlanToMarkdown(result.plan) : compactPlan(result.plan));
518
519
  console.log(formatEnrichCounts(result.counts, result.ambiguities.length));
519
520
  console.log("\nDry run — nothing written. Re-run with --save to persist the plan and the run record.");
520
521
  }
package/src/cli/fix.ts CHANGED
@@ -22,6 +22,7 @@ import { progressReporter } from "../runReport.ts";
22
22
  import type { PatchPlan, PatchPlanRun } from "../types.ts";
23
23
  import { confirmRequested, connectorFor, isOptionValue, numericOption, option, readSnapshot, repeatedOption, saveRequested } from "./shared.ts";
24
24
  import { box, colorEnabled, createProgressRenderer, paint } from "./ui.ts";
25
+ import { compactPlan, verbosePlanRequested } from "./planOutput.ts";
25
26
 
26
27
 
27
28
  /**
@@ -107,8 +108,10 @@ async function emitPlan(plan: PatchPlan, args: string[]) {
107
108
  }
108
109
  if (args.includes("--json")) {
109
110
  console.log(JSON.stringify(plan, null, 2));
110
- } else {
111
+ } else if (verbosePlanRequested(args)) {
111
112
  console.log(patchPlanToMarkdown(plan));
113
+ } else {
114
+ console.log(compactPlan(plan, { saved: saveRequested(args) }));
112
115
  }
113
116
  }
114
117
 
@@ -181,8 +184,7 @@ export async function reassignCommand(args: string[]) {
181
184
  const store = saveRequested(args) ? createFilePlanStore() : null;
182
185
  for (const plan of plans) {
183
186
  if (store) await store.save(plan);
184
- console.log(`${plan.id} ${String(plan.operations.length).padStart(3)} operation(s) ${plan.title}`);
185
- console.log(` ${plan.summary}`);
187
+ console.log(verbosePlanRequested(args) ? patchPlanToMarkdown(plan) : compactPlan(plan, { saved: Boolean(store) }));
186
188
  }
187
189
  if (store) {
188
190
  console.log(
package/src/cli/help.ts CHANGED
@@ -180,11 +180,11 @@ Usage:
180
180
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
181
181
  derive values for requires_human_* placeholders
182
182
  from snapshot evidence, with confidence + reasons
183
- fullstackgtm plans list [--status <s>] | show <id> | sync | reject <id>
183
+ fullstackgtm plans list [--status <s>] | show <id> [--verbose] | sync | reject <id>
184
184
  fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]
185
185
  fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
186
186
  fullstackgtm plans recover <id> --acknowledge-uncertain-writes
187
- fullstackgtm apply --plan-id <id> --provider <name>
187
+ fullstackgtm apply --plan-id <id> --provider <name> [--verbose|--json]
188
188
  fullstackgtm apply --plan-id <id> --channel outbox (render approved openers; transmits nothing)
189
189
  fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
190
190
  fullstackgtm audit-log export [--out <path>] | verify --in <path> tamper-evident apply-run record
@@ -550,7 +550,7 @@ export const HELP: Record<string, HelpEntry> = {
550
550
  summary: "replicated plan lifecycle: list / show / sync / approve / reject",
551
551
  phase: "Govern",
552
552
  synopsis: [
553
- "fullstackgtm plans list [--status <s>] | show <id> | sync | reject <id>",
553
+ "fullstackgtm plans list [--status <s>] | show <id> [--verbose] | sync | reject <id>",
554
554
  "fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]",
555
555
  "fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low]",
556
556
  "fullstackgtm plans recover <id> --acknowledge-uncertain-writes",
@@ -563,12 +563,12 @@ export const HELP: Record<string, HelpEntry> = {
563
563
  summary: "write ONLY explicitly approved operations to a provider",
564
564
  phase: "Govern / Verify",
565
565
  synopsis: [
566
- "fullstackgtm apply --plan-id <id> --provider <name>",
566
+ "fullstackgtm apply --plan-id <id> --provider <name> [--verbose|--json]",
567
567
  "fullstackgtm apply --plan-id <id> --channel outbox (render approved drafted openers to the outbox; transmits nothing)",
568
568
  "fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [--value <opId>=<v>]",
569
569
  ],
570
570
  detail:
571
- "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.",
571
+ "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. Default output is a compact outcome card; --verbose prints the full run document and --json preserves structured output. 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.",
572
572
  seeAlso: ["plans", "suggest", "audit-log"],
573
573
  },
574
574
  "audit-log": {
@@ -658,7 +658,7 @@ export const BESPOKE_HELP = ["init", "call", "market", "tam", "enrich", "schedul
658
658
  export const GLOBAL_FLAGS = ["--help", "--full"];
659
659
  export const GLOBAL_SHORT_FLAGS = ["-h"];
660
660
  export const SOURCE_FLAGS = ["--provider", "--token-env", "--input", "--demo", "--sample", "--seed", "--today"];
661
- export const AUDIT_FLAGS = ["--config", "--allow-plugins", "--no-plugins", "--rules", "--stale-days", "--fail-on", "--save", "--dry-run", "--json", "--out", "--full"];
661
+ export const AUDIT_FLAGS = ["--config", "--allow-plugins", "--no-plugins", "--rules", "--stale-days", "--fail-on", "--save", "--dry-run", "--json", "--out", "--full", "--verbose"];
662
662
 
663
663
  // Complete per-command flag registry used by runCli's fail-closed flag
664
664
  // validation. Keep this next to HELP so focused help, machine capabilities,
@@ -667,7 +667,7 @@ export const COMMAND_FLAGS: Record<string, string[]> = {
667
667
  init: ["--source", "--provider", "--out", "--force"],
668
668
  login: ["--via", "--hosted", "--private-token", "--token", "--key", "--api-key", "--client-id", "--client-secret", "--scopes", "--port", "--oauth", "--device", "--instance-url", "--login-url", "--no-validate"],
669
669
  logout: [],
670
- doctor: ["--json"],
670
+ doctor: ["--verbose", "--json"],
671
671
  capabilities: ["--json"],
672
672
  "robot-docs": [],
673
673
  profiles: ["--json"],
@@ -681,24 +681,24 @@ export const COMMAND_FLAGS: Record<string, string[]> = {
681
681
  hierarchy: [...SOURCE_FLAGS, "--json", "--out"],
682
682
  relationships: [...SOURCE_FLAGS, "--account-id", "--domain", "--json", "--out"],
683
683
  route: [...SOURCE_FLAGS, "--match", "--no-inherit-owner", "--reassign-owned", "--policy", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
684
- fix: [...SOURCE_FLAGS, "--config", "--allow-plugins", "--no-plugins", "--rule", "--provider", "--min-confidence", "--include-creates", "--yes", "--confirm", "--dry-run"],
685
- "bulk-update": [...SOURCE_FLAGS, "--where", "--set", "--guard", "--require", "--create-task", "--archive", "--force-archive-duplicates", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
686
- dedupe: [...SOURCE_FLAGS, "--key", "--keep", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
687
- reassign: [...SOURCE_FLAGS, "--from", "--assign-unowned", "--to", "--objects", "--where", "--except-deal-stage", "--include-closed-deals", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
688
- backfill: [...SOURCE_FLAGS, "--since", "--pipeline", "--match-property", "--skip-unmatched", "--save", "--dry-run", "--json"],
689
- 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"],
690
- 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"],
684
+ fix: [...SOURCE_FLAGS, "--config", "--allow-plugins", "--no-plugins", "--rule", "--provider", "--min-confidence", "--include-creates", "--yes", "--confirm", "--dry-run", "--verbose"],
685
+ "bulk-update": [...SOURCE_FLAGS, "--where", "--set", "--guard", "--require", "--create-task", "--archive", "--force-archive-duplicates", "--reason", "--max-operations", "--save", "--dry-run", "--verbose", "--json", "--out"],
686
+ dedupe: [...SOURCE_FLAGS, "--key", "--keep", "--reason", "--max-operations", "--save", "--dry-run", "--verbose", "--json", "--out"],
687
+ reassign: [...SOURCE_FLAGS, "--from", "--assign-unowned", "--to", "--objects", "--where", "--except-deal-stage", "--include-closed-deals", "--reason", "--max-operations", "--save", "--dry-run", "--verbose", "--json", "--out"],
688
+ backfill: [...SOURCE_FLAGS, "--since", "--pipeline", "--match-property", "--skip-unmatched", "--save", "--dry-run", "--verbose", "--json"],
689
+ 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", "--verbose", "--json", "--out"],
690
+ call: [...SOURCE_FLAGS, "--transcript", "--title", "--source", "--model", "--deterministic", "--heuristics", "--llm", "--verbose", "--json", "--ndjson", "--out", "--call", "--call-type", "--rubric", "--list", "--list-rubrics", "--attendees", "--domain", "--deal", "--save", "--dry-run"],
691
691
  suggest: [...SOURCE_FLAGS, "--plan-id", "--plan", "--json", "--out"],
692
- plans: ["--status", "--operations", "--value", "--values-from", "--min-confidence", "--include-creates", "--acknowledge-uncertain-writes", "--json"],
693
- apply: ["--plan", "--plan-id", "--provider", "--channel", "--token-env", "--approve", "--value", "--json", "--config"],
692
+ plans: ["--status", "--operations", "--value", "--values-from", "--min-confidence", "--include-creates", "--acknowledge-uncertain-writes", "--verbose", "--json"],
693
+ apply: ["--plan", "--plan-id", "--provider", "--channel", "--token-env", "--approve", "--value", "--verbose", "--json", "--config"],
694
694
  "audit-log": ["--in", "--out", "--json"],
695
695
  merge: ["--input", "--out", "--json"],
696
- 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"],
697
- 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"],
698
- icp: [...SOURCE_FLAGS, "--name", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--save", "--json", "--out"],
699
- signals: ["--bucket", "--source", "--connector", "--connector-opt", "--watchlist", "--keywords", "--from", "--label", "--since", "--account", "--contact", "--unjudged", "--touch", "--result", "--save", "--json", "--explain"],
700
- draft: ["--from-judge", "--min-score", "--prompt", "--model", "--channel", "--save", "--dry-run", "--json", "--out"],
701
- schedule: ["--cron", "--label", "--provider", "--trigger", "--timer", "--runs", "--json"],
696
+ 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", "--verbose", "--json", "--out"],
697
+ 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", "--verbose", "--json", "--out"],
698
+ icp: [...SOURCE_FLAGS, "--name", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--save", "--verbose", "--json", "--out"],
699
+ signals: ["--bucket", "--source", "--connector", "--connector-opt", "--watchlist", "--keywords", "--from", "--label", "--since", "--account", "--contact", "--unjudged", "--touch", "--result", "--save", "--verbose", "--json", "--explain"],
700
+ draft: ["--from-judge", "--min-score", "--prompt", "--model", "--channel", "--save", "--dry-run", "--verbose", "--json", "--out"],
701
+ schedule: ["--cron", "--label", "--provider", "--trigger", "--timer", "--runs", "--verbose", "--json"],
702
702
  };
703
703
 
704
704
  export const FLAGS_WITH_VALUES = new Set([
package/src/cli/icp.ts CHANGED
@@ -12,6 +12,18 @@ import { loadIcp, numericOption, option, readSnapshot, resolveLlmBaseUrls, saveR
12
12
  import { createStatusLine } from "./ui.ts";
13
13
  import { unknownSubcommandError } from "./suggest.ts";
14
14
 
15
+ function renderJudgeDecisions(decisions: Awaited<ReturnType<typeof judgeSignals>>): string {
16
+ if (decisions.length === 0) return "No accounts cleared the score threshold.";
17
+ const lines = [`ICP decisions (${decisions.length})`, ""];
18
+ for (const decision of decisions) {
19
+ const target = decision.contact?.email || decision.contact?.title;
20
+ lines.push(`${decision.decision.toUpperCase().padEnd(7)} ${String(decision.score).padStart(3)} ${decision.accountDomain}${target ? ` · ${target}` : ""}`);
21
+ if (decision.whyNow) lines.push(` ${decision.whyNow.replace(/\s+/g, " ").trim()}`);
22
+ if (decision.play) lines.push(` Next: ${decision.play.replace(/\s+/g, " ").trim()}`);
23
+ }
24
+ return lines.join("\n");
25
+ }
26
+
15
27
 
16
28
  /**
17
29
  * `icp` — develop and inspect the Ideal Customer Profile that targets acquire.
@@ -158,7 +170,7 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
158
170
  decisions = decisions.filter((d) => d.score >= minScore);
159
171
 
160
172
  // 5) Ranked decisions to stdout (JSON), guidance to stderr.
161
- console.log(JSON.stringify(decisions, null, 2));
173
+ console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(decisions, null, 2) : renderJudgeDecisions(decisions));
162
174
  console.error(
163
175
  `Judged ${unjudged.length} signal(s) across ${new Set(decisions.map((d) => d.accountDomain)).size} account(s): ` +
164
176
  `${decisions.filter((d) => d.decision === "send").length} send, ` +
package/src/cli/market.ts CHANGED
@@ -499,8 +499,19 @@ Rules:
499
499
  if (outPath) {
500
500
  writeFileSync(resolve(process.cwd(), outPath), output);
501
501
  console.log(`Wrote ${outPath}`);
502
- } else {
502
+ } else if (rest.includes("--verbose") || option(rest, "--format")) {
503
503
  console.log(output);
504
+ } else {
505
+ const fronts = computeFrontStates(config, set);
506
+ const counts = new Map<string, number>();
507
+ for (const front of fronts) counts.set(front.state, (counts.get(front.state) ?? 0) + 1);
508
+ console.log(`${config.category} market · ${set.runLabel}`);
509
+ console.log(`${fronts.length} claim fronts · ${["open", "contested", "owned", "saturated", "vacant"].filter((state) => counts.has(state)).map((state) => `${counts.get(state)} ${state}`).join(" · ")}`);
510
+ for (const front of fronts) {
511
+ const owner = front.loudVendorIds.length ? ` · ${front.loudVendorIds.join(", ")}` : "";
512
+ console.log(`\n${front.state.toUpperCase()} ${front.claimId}${owner}`);
513
+ }
514
+ console.log("\nUse --verbose for the full evidence report, or --out <path> to write it.");
504
515
  }
505
516
  return;
506
517
  }
@@ -0,0 +1,30 @@
1
+ import type { PatchPlan } from "../types.ts";
2
+ import { box, colorEnabled, paint, truncateToWidth } from "./ui.ts";
3
+
4
+ export function compactPlan(plan: PatchPlan, options: { saved?: boolean } = {}): string {
5
+ const counts = new Map<string, number>();
6
+ for (const operation of plan.operations) {
7
+ const action = operation.operation || operation.field || "change";
8
+ counts.set(action, (counts.get(action) ?? 0) + 1);
9
+ }
10
+ const mix = [...counts]
11
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
12
+ .slice(0, 4)
13
+ .map(([action, count]) => `${count} ${action}`)
14
+ .join(" · ");
15
+ const lines = [
16
+ truncateToWidth(plan.title, 100),
17
+ `Status ${options.saved ? "SAVED · NEEDS APPROVAL" : "DRY RUN · NOTHING WRITTEN"}`,
18
+ truncateToWidth(`Scope ${plan.operations.length} operation${plan.operations.length === 1 ? "" : "s"}${mix ? ` · ${mix}` : ""}`, 100),
19
+ truncateToWidth(`Effect ${plan.summary}`, 100),
20
+ options.saved ? `Next fullstackgtm plans show ${plan.id}` : "Next re-run with --save to stage this plan for approval",
21
+ ];
22
+ return [
23
+ ...box(lines, paint(colorEnabled(process.stdout)), "Plan preview"),
24
+ `Details: re-run with --verbose${options.saved ? `; JSON: fullstackgtm plans show ${plan.id} --json` : "; machine output: --json"}`,
25
+ ].join("\n");
26
+ }
27
+
28
+ export function verbosePlanRequested(args: readonly string[]) {
29
+ return args.includes("--verbose") || args.includes("--full");
30
+ }
package/src/cli/plans.ts CHANGED
@@ -11,7 +11,7 @@ import { formatPatchPlanRun, patchPlanToMarkdown } from "../format.ts";
11
11
  import { mergeSnapshots } from "../merge.ts";
12
12
  import { verifyApprovalDigests } from "../integrity.ts";
13
13
  import { buildAuditLog, verifyAuditLog } from "../auditLog.ts";
14
- import { createFilePlanStore } from "../planStore.ts";
14
+ import { createFilePlanStore, type StoredPlan } from "../planStore.ts";
15
15
  import { ENRICH_CONFIG_FILE_NAME, loadEnrichConfig, type EnrichConfig } from "../enrich.ts";
16
16
  import { loadMeter, recordConsumption, remaining } from "../acquireMeter.ts";
17
17
  import { progressReporter, reportCounts } from "../runReport.ts";
@@ -20,7 +20,7 @@ import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progre
20
20
  import type { ValueSuggestion } from "../suggest.ts";
21
21
  import type { CanonicalGtmSnapshot, CreateRecordPayload, PatchOperation, PatchPlan, PatchPlanRun } from "../types.ts";
22
22
  import { connectorFor, isOptionValue, numericOption, option, repeatedOption, selectedRules } from "./shared.ts";
23
- import { colorEnabled, createProgressRenderer, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.ts";
23
+ import { box, colorEnabled, createProgressRenderer, createStatusLine, formatDuration, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.ts";
24
24
  import { unknownSubcommandError } from "./suggest.ts";
25
25
 
26
26
 
@@ -34,6 +34,115 @@ function parseValueOverrides(args: string[]) {
34
34
  return valueOverrides;
35
35
  }
36
36
 
37
+ function recordHeadline(operation: PatchOperation): { title: string; subtitle?: string; company?: string } {
38
+ const after = operation.afterValue && typeof operation.afterValue === "object" && !Array.isArray(operation.afterValue)
39
+ ? operation.afterValue as Record<string, unknown> : {};
40
+ const properties = after.properties && typeof after.properties === "object" && !Array.isArray(after.properties)
41
+ ? after.properties as Record<string, unknown> : {};
42
+ const first = typeof properties.firstname === "string" ? properties.firstname : "";
43
+ const last = typeof properties.lastname === "string" ? properties.lastname : "";
44
+ const company = typeof properties.company === "string" ? properties.company
45
+ : typeof after.associateCompanyName === "string" ? after.associateCompanyName : undefined;
46
+ const title = [first, last].filter(Boolean).join(" ") || company || operation.objectId || operation.id;
47
+ const role = typeof properties.jobtitle === "string" ? properties.jobtitle : undefined;
48
+ return { title, ...(role ? { subtitle: role } : {}), ...(company ? { company } : {}) };
49
+ }
50
+
51
+ function planEffect(stored: StoredPlan): string {
52
+ const total = stored.plan.operations.length;
53
+ const approved = stored.approvedOperationIds.length;
54
+ if (stored.status === "applied") {
55
+ const results = stored.runs.at(-1)?.results ?? [];
56
+ const applied = results.filter((result) => result.status === "applied").length;
57
+ const skipped = results.filter((result) => result.status === "skipped").length;
58
+ return `Completed ${applied} operation${applied === 1 ? "" : "s"}${skipped ? `; ${skipped} skipped` : ""}. No further writes will run.`;
59
+ }
60
+ if (stored.status === "approved") return `Apply will execute ${approved} selected operation${approved === 1 ? "" : "s"}; ${total - approved} will not run.`;
61
+ if (stored.status === "rejected") return "Rejected. No provider writes can run.";
62
+ return `No provider writes can run until operations are approved (0 of ${total} selected).`;
63
+ }
64
+
65
+ function printPlanCards(stored: StoredPlan, hostedUrl?: string) {
66
+ const p = paint(colorEnabled(process.stdout));
67
+ const total = stored.plan.operations.length;
68
+ const approved = new Set(stored.approvedOperationIds);
69
+ const width = Math.max(56, Math.min(100, (process.stdout.columns ?? 100) - 4));
70
+ const fit = (value: string) => truncateToWidth(value, width);
71
+ const summary = [
72
+ fit(stored.plan.title),
73
+ `Status ${stored.status.toUpperCase().replaceAll("_", " ")}`,
74
+ `Selection ${approved.size} of ${total} operation${total === 1 ? "" : "s"} approved`,
75
+ `Runs ${stored.runs.length}`,
76
+ fit(`Effect ${planEffect(stored)}`),
77
+ ];
78
+ console.log(box(summary, p, "Plan").join("\n"));
79
+ if (hostedUrl) console.log(`Hosted: ${hostedUrl}`);
80
+ console.log("\nOperations");
81
+ let operationIndex = 0;
82
+ const latestResults = new Map((stored.runs.at(-1)?.results ?? []).map((result) => [result.operationId, result.status]));
83
+ for (const operation of stored.plan.operations) {
84
+ const selected = approved.has(operation.id);
85
+ const resultStatus = latestResults.get(operation.id);
86
+ const headline = recordHeadline(operation);
87
+ const action = operation.operation === "create_record"
88
+ ? `Create ${operation.objectType}${headline.company ? ` and resolve/link ${headline.company}` : ""}`
89
+ : `${operation.operation.replaceAll("_", " ")} ${operation.objectType}`;
90
+ const lines = [
91
+ fit(headline.title),
92
+ ...(headline.subtitle || headline.company
93
+ ? [fit([headline.subtitle, headline.company].filter(Boolean).join(" · "))]
94
+ : []),
95
+ fit(`Action ${action}`),
96
+ `Operation ${operation.id}`,
97
+ ];
98
+ if (operationIndex++ > 0) console.log("");
99
+ const state = resultStatus === "applied" ? "✓ APPLIED"
100
+ : resultStatus && selected ? `! ${resultStatus.toUpperCase()}`
101
+ : selected ? "✓ APPROVED" : stored.status === "applied" ? "○ EXCLUDED" : "○ NOT APPROVED";
102
+ console.log(box(lines, p, state).join("\n"));
103
+ }
104
+ if (stored.status === "approved") {
105
+ console.log(`\nNext: fullstackgtm apply --plan-id ${stored.plan.id} --provider <hubspot|salesforce>`);
106
+ }
107
+ console.log(`Details: fullstackgtm plans show ${stored.plan.id} --verbose`);
108
+ }
109
+
110
+ function printApplyCards(run: PatchPlanRun, plan: PatchPlan, approvedOperationIds: string[], planIdStored: boolean) {
111
+ const p = paint(colorEnabled(process.stdout));
112
+ const approved = new Set(approvedOperationIds);
113
+ const operations = new Map(plan.operations.map((operation) => [operation.id, operation]));
114
+ const counts = { applied: 0, skipped: 0, failed: 0, conflict: 0 };
115
+ for (const result of run.results) counts[result.status] += 1;
116
+ const elapsed = Math.max(0, Date.parse(run.finishedAt) - Date.parse(run.startedAt));
117
+ const summary = [
118
+ `Status ${run.status.toUpperCase()}`,
119
+ `Provider ${run.provider}`,
120
+ `Outcome ${counts.applied} applied · ${run.results.length - approved.size} excluded · ${counts.failed + counts.conflict} failed/conflicted`,
121
+ `Duration ${formatDuration(elapsed)}`,
122
+ ...(planIdStored ? ["Receipt recorded locally; hosted reconciliation requested"] : []),
123
+ ];
124
+ console.log(box(summary, p, "Apply result").join("\n"));
125
+ console.log("\nOperations");
126
+ let index = 0;
127
+ for (const result of run.results) {
128
+ const operation = operations.get(result.operationId);
129
+ if (!operation) continue;
130
+ const selected = approved.has(result.operationId);
131
+ const headline = recordHeadline(operation);
132
+ const state = !selected ? "○ EXCLUDED" : result.status === "applied" ? "✓ APPLIED" : `! ${result.status.toUpperCase()}`;
133
+ const lines = [
134
+ headline.title,
135
+ ...(headline.subtitle || headline.company ? [[headline.subtitle, headline.company].filter(Boolean).join(" · ")] : []),
136
+ `Operation ${result.operationId}`,
137
+ ...(result.detail ? [truncateToWidth(`Result ${result.detail}`, 100)] : []),
138
+ ];
139
+ if (index++ > 0) console.log("");
140
+ console.log(box(lines, p, state).join("\n"));
141
+ }
142
+ if (planIdStored) console.log(`\nInspect: fullstackgtm plans show ${run.planId}`);
143
+ console.log("Details: rerun with --verbose; machine output: --json");
144
+ }
145
+
37
146
  function tryLoadAcquireConfig(args: string[]): EnrichConfig | undefined {
38
147
  try {
39
148
  const path = resolve(process.cwd(), option(args, "--config") ?? ENRICH_CONFIG_FILE_NAME);
@@ -361,8 +470,10 @@ export async function apply(args: string[]) {
361
470
 
362
471
  if (args.includes("--json")) {
363
472
  console.log(JSON.stringify(run, null, 2));
364
- } else {
473
+ } else if (args.includes("--verbose")) {
365
474
  console.log(formatPatchPlanRun(run));
475
+ } else {
476
+ printApplyCards(run, plan, approvedOperationIds, Boolean(planId && store));
366
477
  }
367
478
  if (run.status === "failed") process.exitCode = 1;
368
479
  }
@@ -484,14 +595,23 @@ export async function plansCommand(args: string[]) {
484
595
  }
485
596
  const p = paint(colorEnabled(process.stdout));
486
597
  if (p.enabled) {
487
- // Interactive terminals get an aligned table with status color-banding;
488
- // the plain per-line format below is unchanged for pipes.
598
+ const statusCounts = new Map<string, number>();
599
+ for (const stored of plans) statusCounts.set(stored.status, (statusCounts.get(stored.status) ?? 0) + 1);
600
+ const awaiting = plans.filter((stored) => stored.status === "needs_approval").length;
601
+ const approved = plans.filter((stored) => stored.status === "approved").length;
602
+ console.log(box([
603
+ `${plans.length} plan${plans.length === 1 ? "" : "s"} · ${awaiting} awaiting approval · ${approved} ready to apply`,
604
+ [...statusCounts.entries()].map(([name, count]) => `${name.replaceAll("_", " ")}: ${count}`).join(" · "),
605
+ ], p, "Change queue").join("\n"));
606
+ console.log("");
607
+ // Interactive terminals get an aligned decision table; the plain
608
+ // per-line format below remains stable for pipes and scripts.
489
609
  const rows = plans.map((stored) => [
490
- stored.plan.id,
491
610
  stored.status,
492
- `${stored.approvedOperationIds.length} approved`,
611
+ stored.plan.title,
612
+ `${stored.approvedOperationIds.length}/${stored.plan.operations.length} selected`,
493
613
  `${stored.runs.length} run${stored.runs.length === 1 ? "" : "s"}`,
494
- stored.plan.summary,
614
+ stored.plan.id,
495
615
  ]);
496
616
  // Long summaries would wrap and break the table's alignment. The summary
497
617
  // is the LAST column: cap it to what's left of the terminal width after
@@ -499,19 +619,26 @@ export async function plansCommand(args: string[]) {
499
619
  // terminals still show something useful).
500
620
  const columns = process.stdout.columns ?? 80;
501
621
  const fixedWidth =
502
- [0, 1, 2, 3].reduce(
622
+ [0, 2, 3, 4].reduce(
503
623
  (sum, index) => sum + Math.max(...rows.map((row) => row[index].length)),
504
624
  0,
505
625
  ) + 2 * 4;
506
- const summaryWidth = Math.max(24, columns - fixedWidth);
507
- for (const row of rows) row[4] = truncateToWidth(row[4], summaryWidth);
626
+ const titleWidth = Math.max(24, columns - fixedWidth);
627
+ for (const row of rows) row[1] = truncateToWidth(row[1], titleWidth);
508
628
  const statusPainter = (cell: string) => {
509
629
  const painted = planStatusWord(cell.trimEnd(), p);
510
630
  return painted + cell.slice(cell.trimEnd().length);
511
631
  };
512
- for (const line of table(rows, [null, statusPainter, p.dim, p.dim, null])) {
632
+ for (const line of table(rows, [statusPainter, null, p.dim, p.dim, p.dim])) {
513
633
  console.log(line);
514
634
  }
635
+ const next = plans.find((stored) => stored.status === "approved") ?? plans.find((stored) => stored.status === "needs_approval");
636
+ if (next) {
637
+ const command = next.status === "approved"
638
+ ? `fullstackgtm apply --plan-id ${next.plan.id} --provider <name>`
639
+ : `fullstackgtm plans show ${next.plan.id}`;
640
+ console.log(`\nNext: ${command}`);
641
+ }
515
642
  return;
516
643
  }
517
644
  for (const stored of plans) {
@@ -538,22 +665,21 @@ export async function plansCommand(args: string[]) {
538
665
  console.log(JSON.stringify(stored, null, 2));
539
666
  return;
540
667
  }
541
- const showPaint = paint(colorEnabled(process.stdout));
542
- console.log(`Status: ${planStatusWord(stored.status, showPaint)}`);
543
- console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
544
- console.log(`Runs: ${stored.runs.length}`);
545
668
  const mirror = await uploadHostedPatchPlan(stored);
546
- if (mirror.status === "saved") console.log(`Hosted review: ${mirror.state.url}`);
547
- else if (mirror.status === "unavailable" || mirror.status === "conflict") console.log(`Hosted review: sync pending (${mirror.reason})`);
548
- if (stored.applyAttempts?.length) {
669
+ printPlanCards(stored, mirror.status === "saved" ? mirror.state.url : undefined);
670
+ if (mirror.status === "unavailable" || mirror.status === "conflict") console.log(`Hosted sync pending: ${mirror.reason}`);
671
+ if (rest.includes("--verbose") && stored.applyAttempts?.length) {
549
672
  console.log("Apply attempts:");
550
673
  for (const attempt of stored.applyAttempts) {
551
674
  console.log(` ${attempt.id} ${attempt.status} ${attempt.provider} via ${attempt.source} ${attempt.claimedAt}`);
552
675
  if (attempt.note) console.log(` ${attempt.note}`);
553
676
  }
554
677
  }
555
- console.log("");
556
- console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
678
+ if (rest.includes("--verbose")) {
679
+ const showPaint = paint(colorEnabled(process.stdout));
680
+ console.log("\nFull plan document\n");
681
+ console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
682
+ }
557
683
  return;
558
684
  }
559
685
 
@@ -654,16 +780,35 @@ export async function plansCommand(args: string[]) {
654
780
  if (subcommand === "sync") {
655
781
  const plans = await store.list();
656
782
  let updated = 0;
783
+ let completed = 0;
657
784
  const warnings: string[] = [];
658
- for (const stored of plans) {
659
- const result = await reconcileHostedPatchPlan(store, stored);
660
- if (result.status === "updated") updated += 1;
661
- else if (result.status === "conflict" || result.status === "unavailable") warnings.push(`${stored.plan.id}: ${result.reason}`);
662
- if (stored.status === "applied") {
663
- const hostedClaimId = [...(stored.applyAttempts ?? [])].reverse().find((attempt) => attempt.hostedClaimId)?.hostedClaimId;
664
- const pushed = await reportHostedPlanLifecycle(stored, { claimId: hostedClaimId });
665
- if (pushed.status === "conflict" || pushed.status === "unavailable") warnings.push(`${stored.plan.id} receipt: ${pushed.reason}`);
785
+ const status = createStatusLine();
786
+ status.set(`Synchronizing plan replicas 0/${plans.length}`);
787
+ // A workspace can accumulate hundreds of local plans. Reconcile with a
788
+ // small worker pool so one network round-trip per plan does not turn into
789
+ // a minutes-long silent serial wait, without stampeding the hosted API.
790
+ let next = 0;
791
+ const worker = async () => {
792
+ while (next < plans.length) {
793
+ const stored = plans[next++];
794
+ const result = await reconcileHostedPatchPlan(store, stored);
795
+ if (result.status === "updated") updated += 1;
796
+ else if (result.status === "conflict" || result.status === "unavailable") warnings.push(`${stored.plan.id}: ${result.reason}`);
797
+ // Historical local-only plans have no remote row to receive a receipt.
798
+ // `backfill runs` is the explicit import path for that old history.
799
+ if (stored.status === "applied" && result.status !== "missing" && (stored.applyAttempts?.length ?? 0) > 0) {
800
+ const hostedClaimId = [...(stored.applyAttempts ?? [])].reverse().find((attempt) => attempt.hostedClaimId)?.hostedClaimId;
801
+ const pushed = await reportHostedPlanLifecycle(stored, { claimId: hostedClaimId });
802
+ if (pushed.status === "conflict" || pushed.status === "unavailable") warnings.push(`${stored.plan.id} receipt: ${pushed.reason}`);
803
+ }
804
+ completed += 1;
805
+ status.set(`Synchronizing plan replicas ${completed}/${plans.length}`);
666
806
  }
807
+ };
808
+ try {
809
+ await Promise.all(Array.from({ length: Math.min(6, plans.length) }, () => worker()));
810
+ } finally {
811
+ status.done();
667
812
  }
668
813
  console.log(`Plan replicas synchronized: ${updated} updated, ${plans.length - updated} unchanged.`);
669
814
  for (const warning of warnings) console.error(` ${warning}`);