fullstackgtm 0.49.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/DATA-FLOWS.md +1 -0
  3. package/README.md +48 -0
  4. package/dist/acquireCheckpoint.d.ts +33 -0
  5. package/dist/acquireCheckpoint.js +149 -0
  6. package/dist/acquireLinkedIn.d.ts +2 -0
  7. package/dist/acquireLinkedIn.js +1 -1
  8. package/dist/cli/audit.js +6 -1
  9. package/dist/cli/auth.js +25 -0
  10. package/dist/cli/backfill.js +4 -0
  11. package/dist/cli/call.js +28 -6
  12. package/dist/cli/draft.js +11 -1
  13. package/dist/cli/enrich.js +286 -81
  14. package/dist/cli/fix.js +6 -3
  15. package/dist/cli/help.js +30 -26
  16. package/dist/cli/icp.js +15 -1
  17. package/dist/cli/market.js +14 -1
  18. package/dist/cli/planOutput.d.ts +5 -0
  19. package/dist/cli/planOutput.js +27 -0
  20. package/dist/cli/plans.js +269 -24
  21. package/dist/cli/schedule.js +22 -11
  22. package/dist/cli/shared.d.ts +3 -1
  23. package/dist/cli/shared.js +2 -2
  24. package/dist/cli/signals.js +22 -3
  25. package/dist/cli/tam.js +10 -1
  26. package/dist/cli/ui.d.ts +10 -5
  27. package/dist/cli/ui.js +28 -9
  28. package/dist/connectors/hubspot.d.ts +4 -0
  29. package/dist/connectors/hubspot.js +36 -18
  30. package/dist/connectors/linkedin.d.ts +2 -0
  31. package/dist/connectors/linkedin.js +5 -0
  32. package/dist/connectors/prospectSources.d.ts +23 -0
  33. package/dist/connectors/prospectSources.js +21 -3
  34. package/dist/enrich.d.ts +44 -1
  35. package/dist/hostedAcquireCheckpoint.d.ts +43 -0
  36. package/dist/hostedAcquireCheckpoint.js +129 -0
  37. package/dist/hostedPatchPlan.d.ts +87 -0
  38. package/dist/hostedPatchPlan.js +275 -0
  39. package/dist/icp.js +13 -2
  40. package/dist/index.d.ts +4 -1
  41. package/dist/index.js +3 -0
  42. package/dist/planStore.d.ts +14 -0
  43. package/dist/planStore.js +73 -0
  44. package/dist/progress.d.ts +2 -2
  45. package/dist/progress.js +2 -2
  46. package/docs/api.md +24 -0
  47. package/docs/architecture.md +9 -0
  48. package/package.json +1 -1
  49. package/skills/fullstackgtm/SKILL.md +1 -1
  50. package/src/acquireCheckpoint.ts +186 -0
  51. package/src/acquireLinkedIn.ts +3 -1
  52. package/src/cli/audit.ts +5 -1
  53. package/src/cli/auth.ts +24 -0
  54. package/src/cli/backfill.ts +3 -0
  55. package/src/cli/call.ts +25 -6
  56. package/src/cli/draft.ts +9 -1
  57. package/src/cli/enrich.ts +325 -80
  58. package/src/cli/fix.ts +5 -3
  59. package/src/cli/help.ts +31 -26
  60. package/src/cli/icp.ts +13 -1
  61. package/src/cli/market.ts +12 -1
  62. package/src/cli/planOutput.ts +30 -0
  63. package/src/cli/plans.ts +259 -25
  64. package/src/cli/schedule.ts +21 -15
  65. package/src/cli/shared.ts +2 -1
  66. package/src/cli/signals.ts +22 -3
  67. package/src/cli/tam.ts +8 -1
  68. package/src/cli/ui.ts +31 -13
  69. package/src/connectors/hubspot.ts +41 -17
  70. package/src/connectors/linkedin.ts +6 -0
  71. package/src/connectors/prospectSources.ts +46 -4
  72. package/src/enrich.ts +47 -1
  73. package/src/hostedAcquireCheckpoint.ts +156 -0
  74. package/src/hostedPatchPlan.ts +291 -0
  75. package/src/icp.ts +14 -2
  76. package/src/index.ts +20 -0
  77. package/src/planStore.ts +87 -0
  78. package/src/progress.ts +2 -2
package/dist/cli/plans.js CHANGED
@@ -14,9 +14,10 @@ import { createFilePlanStore } from "../planStore.js";
14
14
  import { ENRICH_CONFIG_FILE_NAME, loadEnrichConfig } from "../enrich.js";
15
15
  import { loadMeter, recordConsumption, remaining } from "../acquireMeter.js";
16
16
  import { progressReporter, reportCounts } from "../runReport.js";
17
+ import { claimHostedPlanApply, reconcileHostedPatchPlan, releaseHostedPlanApply, reportHostedPlanLifecycle, uploadHostedPatchPlan } from "../hostedPatchPlan.js";
17
18
  import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progress.js";
18
19
  import { connectorFor, isOptionValue, numericOption, option, repeatedOption, selectedRules } from "./shared.js";
19
- import { colorEnabled, createProgressRenderer, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.js";
20
+ import { box, colorEnabled, createProgressRenderer, createStatusLine, formatDuration, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.js";
20
21
  import { unknownSubcommandError } from "./suggest.js";
21
22
  function parseValueOverrides(args) {
22
23
  const valueOverrides = {};
@@ -28,6 +29,119 @@ function parseValueOverrides(args) {
28
29
  }
29
30
  return valueOverrides;
30
31
  }
32
+ function recordHeadline(operation) {
33
+ const after = operation.afterValue && typeof operation.afterValue === "object" && !Array.isArray(operation.afterValue)
34
+ ? operation.afterValue : {};
35
+ const properties = after.properties && typeof after.properties === "object" && !Array.isArray(after.properties)
36
+ ? after.properties : {};
37
+ const first = typeof properties.firstname === "string" ? properties.firstname : "";
38
+ const last = typeof properties.lastname === "string" ? properties.lastname : "";
39
+ const company = typeof properties.company === "string" ? properties.company
40
+ : typeof after.associateCompanyName === "string" ? after.associateCompanyName : undefined;
41
+ const title = [first, last].filter(Boolean).join(" ") || company || operation.objectId || operation.id;
42
+ const role = typeof properties.jobtitle === "string" ? properties.jobtitle : undefined;
43
+ return { title, ...(role ? { subtitle: role } : {}), ...(company ? { company } : {}) };
44
+ }
45
+ function planEffect(stored) {
46
+ const total = stored.plan.operations.length;
47
+ const approved = stored.approvedOperationIds.length;
48
+ if (stored.status === "applied") {
49
+ const results = stored.runs.at(-1)?.results ?? [];
50
+ const applied = results.filter((result) => result.status === "applied").length;
51
+ const skipped = results.filter((result) => result.status === "skipped").length;
52
+ return `Completed ${applied} operation${applied === 1 ? "" : "s"}${skipped ? `; ${skipped} skipped` : ""}. No further writes will run.`;
53
+ }
54
+ if (stored.status === "approved")
55
+ return `Apply will execute ${approved} selected operation${approved === 1 ? "" : "s"}; ${total - approved} will not run.`;
56
+ if (stored.status === "rejected")
57
+ return "Rejected. No provider writes can run.";
58
+ return `No provider writes can run until operations are approved (0 of ${total} selected).`;
59
+ }
60
+ function printPlanCards(stored, hostedUrl) {
61
+ const p = paint(colorEnabled(process.stdout));
62
+ const total = stored.plan.operations.length;
63
+ const approved = new Set(stored.approvedOperationIds);
64
+ const width = Math.max(56, Math.min(100, (process.stdout.columns ?? 100) - 4));
65
+ const fit = (value) => truncateToWidth(value, width);
66
+ const summary = [
67
+ fit(stored.plan.title),
68
+ `Status ${stored.status.toUpperCase().replaceAll("_", " ")}`,
69
+ `Selection ${approved.size} of ${total} operation${total === 1 ? "" : "s"} approved`,
70
+ `Runs ${stored.runs.length}`,
71
+ fit(`Effect ${planEffect(stored)}`),
72
+ ];
73
+ console.log(box(summary, p, "Plan").join("\n"));
74
+ if (hostedUrl)
75
+ console.log(`Hosted: ${hostedUrl}`);
76
+ console.log("\nOperations");
77
+ let operationIndex = 0;
78
+ const latestResults = new Map((stored.runs.at(-1)?.results ?? []).map((result) => [result.operationId, result.status]));
79
+ for (const operation of stored.plan.operations) {
80
+ const selected = approved.has(operation.id);
81
+ const resultStatus = latestResults.get(operation.id);
82
+ const headline = recordHeadline(operation);
83
+ const action = operation.operation === "create_record"
84
+ ? `Create ${operation.objectType}${headline.company ? ` and resolve/link ${headline.company}` : ""}`
85
+ : `${operation.operation.replaceAll("_", " ")} ${operation.objectType}`;
86
+ const lines = [
87
+ fit(headline.title),
88
+ ...(headline.subtitle || headline.company
89
+ ? [fit([headline.subtitle, headline.company].filter(Boolean).join(" · "))]
90
+ : []),
91
+ fit(`Action ${action}`),
92
+ `Operation ${operation.id}`,
93
+ ];
94
+ if (operationIndex++ > 0)
95
+ console.log("");
96
+ const state = resultStatus === "applied" ? "✓ APPLIED"
97
+ : resultStatus && selected ? `! ${resultStatus.toUpperCase()}`
98
+ : selected ? "✓ APPROVED" : stored.status === "applied" ? "○ EXCLUDED" : "○ NOT APPROVED";
99
+ console.log(box(lines, p, state).join("\n"));
100
+ }
101
+ if (stored.status === "approved") {
102
+ console.log(`\nNext: fullstackgtm apply --plan-id ${stored.plan.id} --provider <hubspot|salesforce>`);
103
+ }
104
+ console.log(`Details: fullstackgtm plans show ${stored.plan.id} --verbose`);
105
+ }
106
+ function printApplyCards(run, plan, approvedOperationIds, planIdStored) {
107
+ const p = paint(colorEnabled(process.stdout));
108
+ const approved = new Set(approvedOperationIds);
109
+ const operations = new Map(plan.operations.map((operation) => [operation.id, operation]));
110
+ const counts = { applied: 0, skipped: 0, failed: 0, conflict: 0 };
111
+ for (const result of run.results)
112
+ counts[result.status] += 1;
113
+ const elapsed = Math.max(0, Date.parse(run.finishedAt) - Date.parse(run.startedAt));
114
+ const summary = [
115
+ `Status ${run.status.toUpperCase()}`,
116
+ `Provider ${run.provider}`,
117
+ `Outcome ${counts.applied} applied · ${run.results.length - approved.size} excluded · ${counts.failed + counts.conflict} failed/conflicted`,
118
+ `Duration ${formatDuration(elapsed)}`,
119
+ ...(planIdStored ? ["Receipt recorded locally; hosted reconciliation requested"] : []),
120
+ ];
121
+ console.log(box(summary, p, "Apply result").join("\n"));
122
+ console.log("\nOperations");
123
+ let index = 0;
124
+ for (const result of run.results) {
125
+ const operation = operations.get(result.operationId);
126
+ if (!operation)
127
+ continue;
128
+ const selected = approved.has(result.operationId);
129
+ const headline = recordHeadline(operation);
130
+ const state = !selected ? "○ EXCLUDED" : result.status === "applied" ? "✓ APPLIED" : `! ${result.status.toUpperCase()}`;
131
+ const lines = [
132
+ headline.title,
133
+ ...(headline.subtitle || headline.company ? [[headline.subtitle, headline.company].filter(Boolean).join(" · ")] : []),
134
+ `Operation ${result.operationId}`,
135
+ ...(result.detail ? [truncateToWidth(`Result ${result.detail}`, 100)] : []),
136
+ ];
137
+ if (index++ > 0)
138
+ console.log("");
139
+ console.log(box(lines, p, state).join("\n"));
140
+ }
141
+ if (planIdStored)
142
+ console.log(`\nInspect: fullstackgtm plans show ${run.planId}`);
143
+ console.log("Details: rerun with --verbose; machine output: --json");
144
+ }
31
145
  function tryLoadAcquireConfig(args) {
32
146
  try {
33
147
  const path = resolve(process.cwd(), option(args, "--config") ?? ENRICH_CONFIG_FILE_NAME);
@@ -132,9 +246,20 @@ export async function apply(args) {
132
246
  let valueOverrides;
133
247
  const store = planId ? createFilePlanStore() : null;
134
248
  if (planId && store) {
135
- const stored = await store.get(planId);
249
+ let stored = await store.get(planId);
136
250
  if (!stored)
137
251
  throw new Error(`No stored plan with id ${planId}.`);
252
+ const reconciliation = await reconcileHostedPatchPlan(store, stored);
253
+ if (reconciliation.status === "conflict")
254
+ throw new Error(`Refusing to apply plan ${planId}: ${reconciliation.reason}.`);
255
+ if (reconciliation.status === "updated") {
256
+ stored = reconciliation.stored;
257
+ console.error(`Synced hosted ${reconciliation.remoteStatus} state for ${planId}.`);
258
+ }
259
+ if (stored.status === "applied") {
260
+ console.log(`Plan ${planId} was already applied by another replica; imported its execution receipt. No provider writes were attempted.`);
261
+ return;
262
+ }
138
263
  if (stored.status !== "approved") {
139
264
  throw new Error(`Plan ${planId} is ${stored.status}; approve operations first with \`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`);
140
265
  }
@@ -211,11 +336,38 @@ export async function apply(args) {
211
336
  // transmits nothing; a CRM provider writes records. Same governed apply path.
212
337
  const connector = channel ? createChannelConnector(channel) : await connectorFor(provider, args);
213
338
  let applyClaimId;
339
+ let hostedApplyClaimId;
214
340
  if (planId && store) {
215
341
  const claimed = await store.claimApply(planId, { provider: provider ?? `channel:${channel}`, source: "cli" });
216
342
  applyClaimId = claimed.claimId;
343
+ const hostedClaim = await claimHostedPlanApply(claimed.stored);
344
+ if (hostedClaim.status === "applied") {
345
+ await store.abortApplyPreflight(planId, claimed.claimId, "Another replica completed apply before provider I/O.");
346
+ const refreshed = await store.get(planId);
347
+ if (refreshed)
348
+ await reconcileHostedPatchPlan(store, refreshed);
349
+ console.log(`Plan ${planId} was already applied by another replica. No provider writes were attempted.`);
350
+ return;
351
+ }
352
+ if (hostedClaim.status === "conflict") {
353
+ await store.abortApplyPreflight(planId, claimed.claimId, `Hosted execution claim failed: ${hostedClaim.reason}`);
354
+ throw new Error(`Refusing to apply plan ${planId}: could not coordinate with its hosted replica (${hostedClaim.reason}).`);
355
+ }
356
+ if (hostedClaim.status === "unavailable") {
357
+ console.error(`Hosted replica is unavailable (${hostedClaim.reason}); continuing local-first. Resolve-before-create and provider conflict guards remain active, and the execution receipt will sync on a later check-in.`);
358
+ }
359
+ if (hostedClaim.status === "claimed") {
360
+ hostedApplyClaimId = hostedClaim.claimId;
361
+ await store.recordHostedClaim(planId, claimed.claimId, hostedClaim.claimId);
362
+ }
217
363
  const claimedVerification = verifyApprovalDigests(claimed.stored.plan.operations, claimed.stored.approvedOperationIds, claimed.stored.valueOverrides, claimed.stored.approvalDigests);
218
364
  if (!claimedVerification.ok) {
365
+ if (hostedApplyClaimId) {
366
+ const released = await releaseHostedPlanApply(claimed.stored, hostedApplyClaimId, "Approval integrity verification failed after claim and before provider I/O.");
367
+ if (released.status === "conflict" || released.status === "unavailable") {
368
+ console.error(`Hosted apply claim may require recovery: ${released.reason}.`);
369
+ }
370
+ }
219
371
  await store.abortApplyPreflight(planId, claimed.claimId, "Approval integrity verification failed after the claim and before provider I/O.");
220
372
  throw new Error(`Refusing to apply plan ${planId}: approval changed while acquiring the apply claim.`);
221
373
  }
@@ -248,7 +400,12 @@ export async function apply(args) {
248
400
  renderer.done();
249
401
  }
250
402
  if (planId && store) {
251
- await store.recordRun(planId, run, applyClaimId);
403
+ const recorded = await store.recordRun(planId, run, applyClaimId);
404
+ const durableHostedClaimId = recorded.applyAttempts?.find((attempt) => attempt.id === applyClaimId)?.hostedClaimId;
405
+ const mirrored = await reportHostedPlanLifecycle(recorded, { claimId: durableHostedClaimId ?? hostedApplyClaimId });
406
+ if (mirrored.status === "unavailable" || mirrored.status === "conflict") {
407
+ console.error(`Hosted plan status is stale: ${mirrored.reason}. Local apply history remains authoritative.`);
408
+ }
252
409
  }
253
410
  // Charge the acquire meter for the creates that actually landed.
254
411
  if (createOps.length > 0) {
@@ -278,9 +435,12 @@ export async function apply(args) {
278
435
  if (args.includes("--json")) {
279
436
  console.log(JSON.stringify(run, null, 2));
280
437
  }
281
- else {
438
+ else if (args.includes("--verbose")) {
282
439
  console.log(formatPatchPlanRun(run));
283
440
  }
441
+ else {
442
+ printApplyCards(run, plan, approvedOperationIds, Boolean(planId && store));
443
+ }
284
444
  if (run.status === "failed")
285
445
  process.exitCode = 1;
286
446
  }
@@ -353,7 +513,14 @@ export async function plansCommand(args) {
353
513
  const [subcommand, ...rest] = args;
354
514
  if (subcommand === "list" || subcommand === undefined) {
355
515
  const status = option(rest, "--status");
356
- const plans = await store.list(status ?? undefined);
516
+ let plans = await store.list(status ?? undefined);
517
+ const synced = await Promise.all(plans.map(async (stored) => {
518
+ const result = await reconcileHostedPatchPlan(store, stored);
519
+ if (result.status === "updated")
520
+ return result.stored;
521
+ return stored;
522
+ }));
523
+ plans = status ? synced.filter((stored) => stored.status === status) : synced;
357
524
  if (rest.includes("--json") || args.includes("--json")) {
358
525
  console.log(JSON.stringify(plans.map((stored) => ({
359
526
  id: stored.plan.id,
@@ -371,31 +538,48 @@ export async function plansCommand(args) {
371
538
  }
372
539
  const p = paint(colorEnabled(process.stdout));
373
540
  if (p.enabled) {
374
- // Interactive terminals get an aligned table with status color-banding;
375
- // the plain per-line format below is unchanged for pipes.
541
+ const statusCounts = new Map();
542
+ for (const stored of plans)
543
+ statusCounts.set(stored.status, (statusCounts.get(stored.status) ?? 0) + 1);
544
+ const awaiting = plans.filter((stored) => stored.status === "needs_approval").length;
545
+ const approved = plans.filter((stored) => stored.status === "approved").length;
546
+ console.log(box([
547
+ `${plans.length} plan${plans.length === 1 ? "" : "s"} · ${awaiting} awaiting approval · ${approved} ready to apply`,
548
+ [...statusCounts.entries()].map(([name, count]) => `${name.replaceAll("_", " ")}: ${count}`).join(" · "),
549
+ ], p, "Change queue").join("\n"));
550
+ console.log("");
551
+ // Interactive terminals get an aligned decision table; the plain
552
+ // per-line format below remains stable for pipes and scripts.
376
553
  const rows = plans.map((stored) => [
377
- stored.plan.id,
378
554
  stored.status,
379
- `${stored.approvedOperationIds.length} approved`,
555
+ stored.plan.title,
556
+ `${stored.approvedOperationIds.length}/${stored.plan.operations.length} selected`,
380
557
  `${stored.runs.length} run${stored.runs.length === 1 ? "" : "s"}`,
381
- stored.plan.summary,
558
+ stored.plan.id,
382
559
  ]);
383
560
  // Long summaries would wrap and break the table's alignment. The summary
384
561
  // is the LAST column: cap it to what's left of the terminal width after
385
562
  // the fixed columns and their two-space gutters (floor of 24 so narrow
386
563
  // terminals still show something useful).
387
564
  const columns = process.stdout.columns ?? 80;
388
- const fixedWidth = [0, 1, 2, 3].reduce((sum, index) => sum + Math.max(...rows.map((row) => row[index].length)), 0) + 2 * 4;
389
- const summaryWidth = Math.max(24, columns - fixedWidth);
565
+ const fixedWidth = [0, 2, 3, 4].reduce((sum, index) => sum + Math.max(...rows.map((row) => row[index].length)), 0) + 2 * 4;
566
+ const titleWidth = Math.max(24, columns - fixedWidth);
390
567
  for (const row of rows)
391
- row[4] = truncateToWidth(row[4], summaryWidth);
568
+ row[1] = truncateToWidth(row[1], titleWidth);
392
569
  const statusPainter = (cell) => {
393
570
  const painted = planStatusWord(cell.trimEnd(), p);
394
571
  return painted + cell.slice(cell.trimEnd().length);
395
572
  };
396
- for (const line of table(rows, [null, statusPainter, p.dim, p.dim, null])) {
573
+ for (const line of table(rows, [statusPainter, null, p.dim, p.dim, p.dim])) {
397
574
  console.log(line);
398
575
  }
576
+ const next = plans.find((stored) => stored.status === "approved") ?? plans.find((stored) => stored.status === "needs_approval");
577
+ if (next) {
578
+ const command = next.status === "approved"
579
+ ? `fullstackgtm apply --plan-id ${next.plan.id} --provider <name>`
580
+ : `fullstackgtm plans show ${next.plan.id}`;
581
+ console.log(`\nNext: ${command}`);
582
+ }
399
583
  return;
400
584
  }
401
585
  for (const stored of plans) {
@@ -407,18 +591,26 @@ export async function plansCommand(args) {
407
591
  const planId = rest.find((arg) => !arg.startsWith("--"));
408
592
  if (!planId)
409
593
  throw new Error("Usage: fullstackgtm plans show <planId>");
410
- const stored = await store.get(planId);
594
+ let stored = await store.get(planId);
411
595
  if (!stored)
412
596
  throw new Error(`No stored plan with id ${planId}.`);
597
+ const reconciliation = await reconcileHostedPatchPlan(store, stored);
598
+ if (reconciliation.status === "updated") {
599
+ stored = reconciliation.stored;
600
+ console.error(`Synced hosted ${reconciliation.remoteStatus} state for ${planId}.`);
601
+ }
602
+ else if (reconciliation.status === "conflict") {
603
+ console.error(`Hosted sync conflict: ${reconciliation.reason}.`);
604
+ }
413
605
  if (rest.includes("--json")) {
414
606
  console.log(JSON.stringify(stored, null, 2));
415
607
  return;
416
608
  }
417
- const showPaint = paint(colorEnabled(process.stdout));
418
- console.log(`Status: ${planStatusWord(stored.status, showPaint)}`);
419
- console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
420
- console.log(`Runs: ${stored.runs.length}`);
421
- if (stored.applyAttempts?.length) {
609
+ const mirror = await uploadHostedPatchPlan(stored);
610
+ printPlanCards(stored, mirror.status === "saved" ? mirror.state.url : undefined);
611
+ if (mirror.status === "unavailable" || mirror.status === "conflict")
612
+ console.log(`Hosted sync pending: ${mirror.reason}`);
613
+ if (rest.includes("--verbose") && stored.applyAttempts?.length) {
422
614
  console.log("Apply attempts:");
423
615
  for (const attempt of stored.applyAttempts) {
424
616
  console.log(` ${attempt.id} ${attempt.status} ${attempt.provider} via ${attempt.source} ${attempt.claimedAt}`);
@@ -426,8 +618,11 @@ export async function plansCommand(args) {
426
618
  console.log(` ${attempt.note}`);
427
619
  }
428
620
  }
429
- console.log("");
430
- console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
621
+ if (rest.includes("--verbose")) {
622
+ const showPaint = paint(colorEnabled(process.stdout));
623
+ console.log("\nFull plan document\n");
624
+ console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
625
+ }
431
626
  return;
432
627
  }
433
628
  if (subcommand === "approve") {
@@ -470,6 +665,10 @@ export async function plansCommand(args) {
470
665
  ...fileOverrides,
471
666
  ...explicitOverrides,
472
667
  });
668
+ const mirror = await reportHostedPlanLifecycle(updated);
669
+ if (mirror.status === "unavailable" || mirror.status === "conflict") {
670
+ console.error(`Hosted plan status is stale: ${mirror.reason}. Local approval remains authoritative.`);
671
+ }
473
672
  console.log(`Approved ${updated.approvedOperationIds.length} operation(s) on ${planId}. Apply with \`fullstackgtm apply --plan-id ${planId} --provider <name>\`.`);
474
673
  return;
475
674
  }
@@ -477,7 +676,11 @@ export async function plansCommand(args) {
477
676
  const planId = rest.find((arg) => !arg.startsWith("--"));
478
677
  if (!planId)
479
678
  throw new Error("Usage: fullstackgtm plans reject <planId>");
480
- await store.reject(planId);
679
+ const rejected = await store.reject(planId);
680
+ const mirror = await reportHostedPlanLifecycle(rejected);
681
+ if (mirror.status === "unavailable" || mirror.status === "conflict") {
682
+ console.error(`Hosted plan status is stale: ${mirror.reason}. Local rejection remains authoritative.`);
683
+ }
481
684
  console.log(`Rejected ${planId}.`);
482
685
  return;
483
686
  }
@@ -503,5 +706,47 @@ export async function plansCommand(args) {
503
706
  `No writes were replayed. Re-audit provider state, review the plan, then approve operations again before any apply.`);
504
707
  return;
505
708
  }
506
- throw unknownSubcommandError("plans", subcommand, ["list", "show", "approve", "reject", "recover"]);
709
+ if (subcommand === "sync") {
710
+ const plans = await store.list();
711
+ let updated = 0;
712
+ let completed = 0;
713
+ const warnings = [];
714
+ const status = createStatusLine();
715
+ status.set(`Synchronizing plan replicas 0/${plans.length}`);
716
+ // A workspace can accumulate hundreds of local plans. Reconcile with a
717
+ // small worker pool so one network round-trip per plan does not turn into
718
+ // a minutes-long silent serial wait, without stampeding the hosted API.
719
+ let next = 0;
720
+ const worker = async () => {
721
+ while (next < plans.length) {
722
+ const stored = plans[next++];
723
+ const result = await reconcileHostedPatchPlan(store, stored);
724
+ if (result.status === "updated")
725
+ updated += 1;
726
+ else if (result.status === "conflict" || result.status === "unavailable")
727
+ warnings.push(`${stored.plan.id}: ${result.reason}`);
728
+ // Historical local-only plans have no remote row to receive a receipt.
729
+ // `backfill runs` is the explicit import path for that old history.
730
+ if (stored.status === "applied" && result.status !== "missing" && (stored.applyAttempts?.length ?? 0) > 0) {
731
+ const hostedClaimId = [...(stored.applyAttempts ?? [])].reverse().find((attempt) => attempt.hostedClaimId)?.hostedClaimId;
732
+ const pushed = await reportHostedPlanLifecycle(stored, { claimId: hostedClaimId });
733
+ if (pushed.status === "conflict" || pushed.status === "unavailable")
734
+ warnings.push(`${stored.plan.id} receipt: ${pushed.reason}`);
735
+ }
736
+ completed += 1;
737
+ status.set(`Synchronizing plan replicas ${completed}/${plans.length}`);
738
+ }
739
+ };
740
+ try {
741
+ await Promise.all(Array.from({ length: Math.min(6, plans.length) }, () => worker()));
742
+ }
743
+ finally {
744
+ status.done();
745
+ }
746
+ console.log(`Plan replicas synchronized: ${updated} updated, ${plans.length - updated} unchanged.`);
747
+ for (const warning of warnings)
748
+ console.error(` ${warning}`);
749
+ return;
750
+ }
751
+ throw unknownSubcommandError("plans", subcommand, ["list", "show", "sync", "approve", "reject", "recover"]);
507
752
  }
@@ -8,6 +8,7 @@ import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleSto
8
8
  import { runCli } from "../cli.js";
9
9
  import { isOptionValue, numericOption, option } from "./shared.js";
10
10
  import { unknownSubcommandError } from "./suggest.js";
11
+ import { colorEnabled, paint, table } from "./ui.js";
11
12
  /**
12
13
  * The schedule layer: declarative cadences for read/plan-side commands,
13
14
  * materialized through a provider (MVP: the user crontab), with an
@@ -113,10 +114,19 @@ trigger: manual. status shows next firing and surfaces missed firings
113
114
  console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
114
115
  return;
115
116
  }
116
- for (const entry of withNext) {
117
- console.log(`${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron.padEnd(14)} next ${entry.nextFiring ?? ""} ${entry.label}: ${entry.argv.join(" ")}`);
118
- }
119
- console.log("\nEntries are declarative `schedule install` materializes enabled ones into the crontab.");
117
+ const p = paint(colorEnabled(process.stdout));
118
+ const verbose = rest.includes("--verbose");
119
+ const rows = [
120
+ ["STATE", "SCHEDULE", "NEXT", ...(verbose ? ["CRON", "COMMAND"] : [])],
121
+ ...withNext.map((entry) => [
122
+ entry.enabled ? "on" : "off",
123
+ `${entry.label} · ${entry.id}`,
124
+ entry.nextFiring ?? "—",
125
+ ...(verbose ? [entry.cron, entry.argv.join(" ")] : []),
126
+ ]),
127
+ ];
128
+ console.log(table(rows, [p.dim, null, null]).join("\n"));
129
+ console.log("\nDeclarative only · run `fullstackgtm schedule install` after changes.");
120
130
  return;
121
131
  }
122
132
  if (subcommand === "remove") {
@@ -251,20 +261,21 @@ trigger: manual. status shows next firing and surfaces missed firings
251
261
  const last = entry.lastRun;
252
262
  const streak = entry.streak;
253
263
  const missed = entry.missedFirings;
254
- console.log(`${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron} ${entry.label}: ${entry.argv.join(" ")}`);
255
- console.log(` next: ${entry.nextFiring ?? " (disabled)"}`);
264
+ const verbose = rest.includes("--verbose");
265
+ const outcome = !last ? "never run" : last.exitCode === 0 ? (last.noopReason ? `no-op · ${last.noopReason}` : "healthy") : `failed · exit ${last.exitCode}`;
266
+ console.log(`${entry.enabled ? "●" : "○"} ${entry.label} · ${outcome}`);
267
+ console.log(` ${entry.id} · next ${entry.nextFiring ?? "— (disabled)"}`);
268
+ if (verbose)
269
+ console.log(` ${entry.argv.join(" ")} · cron ${entry.cron}`);
256
270
  if (last) {
257
271
  const artifacts = [
258
272
  ...last.artifacts.planIds.map((planId) => `plan ${planId}`),
259
273
  ...last.artifacts.runLabels.map((runLabel) => `run ${runLabel}`),
260
274
  ];
261
- console.log(` last: ${last.firedAt} (${last.trigger}) exit ${last.exitCode}` +
262
- (last.noopReason ? ` — no-op: ${last.noopReason}` : "") +
263
- (artifacts.length ? ` — ${artifacts.join(", ")}` : ""));
264
- console.log(` streak: ${streak.length} ${streak.outcome}(s)`);
275
+ console.log(` last ${last.firedAt} · ${last.trigger} · ${streak.length} ${streak.outcome}${streak.length === 1 ? "" : "s"}${artifacts.length ? ` · ${artifacts.join(", ")}` : ""}`);
265
276
  }
266
277
  else {
267
- console.log(" last: never fired");
278
+ console.log(" last never fired");
268
279
  }
269
280
  if (missed.length > 0) {
270
281
  console.log(` missed: ${missed.length}${entry.missedFiringsCapped ? "+" : ""} expected firing(s) with no run record ` +
@@ -23,7 +23,9 @@ export declare function saveRequested(args: string[]): boolean;
23
23
  export declare function confirmRequested(args: string[], ...legacyAliases: string[]): boolean;
24
24
  export declare function numericOption(args: string[], name: string): number | undefined;
25
25
  export declare function connectorFor(provider: string, args: string[], progress?: ProgressEmitter): Promise<GtmConnector>;
26
- export declare function readSnapshot(args: string[], progress?: ProgressEmitter): Promise<CanonicalGtmSnapshot>;
26
+ export declare function readSnapshot(args: string[], progress?: ProgressEmitter, options?: {
27
+ persistProgress?: boolean;
28
+ }): Promise<CanonicalGtmSnapshot>;
27
29
  /**
28
30
  * Validate that an --input file actually has the canonical snapshot shape
29
31
  * (the JSON `snapshot --out` writes) instead of blindly casting — a plan or
@@ -131,7 +131,7 @@ export async function connectorFor(provider, args, progress) {
131
131
  throw new Error(`Unknown provider: ${provider}.${providerSuggestion ? ` Did you mean ${providerSuggestion}?` : ""} ` +
132
132
  "Supported providers: hubspot, salesforce, stripe");
133
133
  }
134
- export async function readSnapshot(args, progress) {
134
+ export async function readSnapshot(args, progress, options = {}) {
135
135
  const provider = option(args, "--provider");
136
136
  if (provider) {
137
137
  // A verb driving its own progress board (e.g. `backfill stripe`) passes
@@ -153,7 +153,7 @@ export async function readSnapshot(args, progress) {
153
153
  return await connector.fetchSnapshot();
154
154
  }
155
155
  finally {
156
- renderer.done();
156
+ renderer.done({ persist: options.persistProgress });
157
157
  }
158
158
  }
159
159
  if (args.includes("--demo")) {
@@ -22,6 +22,20 @@ function resolveSignalsConfig(args) {
22
22
  return loadSignalsConfig(local);
23
23
  return DEFAULT_SIGNALS_CONFIG;
24
24
  }
25
+ function concise(value, max = 72) {
26
+ const oneLine = value.replace(/\s+/g, " ").trim();
27
+ return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
28
+ }
29
+ function renderSignals(signals) {
30
+ if (signals.length === 0)
31
+ return "No signals matched.";
32
+ const lines = [`Fresh signals (${signals.length})`, ""];
33
+ for (const signal of signals) {
34
+ lines.push(`${signal.weight.toFixed(2).padStart(5)} ${signal.accountDomain} · ${signal.bucket}`);
35
+ lines.push(` ${concise(signal.trigger)}`);
36
+ }
37
+ return lines.join("\n");
38
+ }
25
39
  /**
26
40
  * Resolve the watchlist of accounts to scan. Sources, in precedence order:
27
41
  * - a --watchlist file (JSON array of {domain, boards?} or bare domain strings),
@@ -211,7 +225,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
211
225
  const { fresh, deduped } = dedupeSignals(candidates, priorSignals, config.dedupWindowDays, now);
212
226
  const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
213
227
  // Ranked fresh signals to stdout; guidance to stderr.
214
- console.log(JSON.stringify(ranked, null, 2));
228
+ console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
215
229
  console.error(`Fetched ${fetched} candidate signal(s); ${fresh.length} fresh, ${deduped.length} deduped ` +
216
230
  `(window ${config.dedupWindowDays}d). NO plan emitted — signals are read-only re: CRM.`);
217
231
  const save = saveRequested(rest);
@@ -259,7 +273,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
259
273
  return true;
260
274
  });
261
275
  const ranked = filtered.sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
262
- console.log(JSON.stringify(ranked, null, 2));
276
+ console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
263
277
  console.error(`${ranked.length} signal(s)${unjudgedOnly ? " (unjudged)" : ""}.`);
264
278
  return;
265
279
  }
@@ -286,7 +300,12 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
286
300
  const outcomes = await store.listOutcomes();
287
301
  const signalsById = new Map((await store.allSignals()).map((s) => [s.id, s]));
288
302
  const weights = computeWeights(config, outcomes, signalsById);
289
- console.log(JSON.stringify(weights, null, 2));
303
+ if (rest.includes("--json") || rest.includes("--verbose")) {
304
+ console.log(JSON.stringify(weights, null, 2));
305
+ }
306
+ else {
307
+ console.log(["Learned signal weights", "", ...SIGNAL_BUCKETS.map((bucket) => `${bucket.padEnd(10)} ${weights[bucket].toFixed(4)}`)].join("\n"));
308
+ }
290
309
  if (rest.includes("--explain")) {
291
310
  // Per-bucket config default vs learned + booked/total over credited signals.
292
311
  const booked = new Map();
package/dist/cli/tam.js CHANGED
@@ -253,9 +253,18 @@ RevOps universe, with real names. --source explorium is a firmographic count onl
253
253
  writeFileSync(resolve(process.cwd(), out), md);
254
254
  console.log(`Wrote ${out}.`);
255
255
  }
256
- else {
256
+ else if (rest.includes("--verbose")) {
257
257
  console.log(md);
258
258
  }
259
+ else if (timeline.length > 0) {
260
+ console.log(coverageToText(model, timeline.at(-1), eta));
261
+ console.log("\nUse --verbose for assumptions, cross-checks, and the full coverage history.");
262
+ }
263
+ else {
264
+ console.log(`TAM "${model.name}" · ${model.universe.accounts.toLocaleString()} accounts · ${model.universe.contacts.toLocaleString()} buyers · $${Math.round(model.tamUsd).toLocaleString()}`);
265
+ console.log(`ICP ${model.icpName} · ${model.acv.basis}-basis ACV $${Math.round(model.acv.valueUsd).toLocaleString()} (${model.acv.source})`);
266
+ console.log("No coverage readings yet. Run `fullstackgtm tam status --save` to establish one; use --verbose for the full assumptions report.");
267
+ }
259
268
  return;
260
269
  }
261
270
  if (sub === "populate") {
package/dist/cli/ui.d.ts CHANGED
@@ -88,14 +88,17 @@ export declare function startElapsedStatus(label: (elapsed: string) => string, s
88
88
  };
89
89
  export type Checklist = {
90
90
  update(id: string, state: "pending" | "running" | "ok" | "warn" | "fail", note?: string): void;
91
- /** Stop animating and erase the board (callers print their own summary). */
92
- done(): void;
91
+ /** Stop animating; optionally leave the final board in the terminal history. */
92
+ done(options?: {
93
+ persist?: boolean;
94
+ }): void;
93
95
  readonly active: boolean;
94
96
  };
95
97
  /**
96
98
  * A multi-line live board — one line per item, each flipping ○ → ⠹ → ✓ as work
97
99
  * progresses (the audit rule registry renders through this). Repaints with
98
- * cursor-up; erases itself on done() so the verb's real output owns stdout.
100
+ * cursor-up; done() normally erases it, while done({ persist: true }) leaves a
101
+ * final static history for multi-phase human workflows.
99
102
  */
100
103
  export declare function createChecklist(items: Array<{
101
104
  id: string;
@@ -107,8 +110,10 @@ export type ProgressRenderer = {
107
110
  * listener via `composeListeners` when the run should also heartbeat).
108
111
  */
109
112
  listener: ProgressListener;
110
- /** Stop animating and erase the board (callers print their own summary). */
111
- done(): void;
113
+ /** Stop animating; optionally leave the completed board in terminal history. */
114
+ done(options?: {
115
+ persist?: boolean;
116
+ }): void;
112
117
  readonly active: boolean;
113
118
  };
114
119
  /**