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/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> | 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
@@ -204,7 +204,11 @@ Profiles (multi-organization use):
204
204
  Plan lifecycle:
205
205
  audit --save persists the dry-run plan to ~/.fullstackgtm/plans. Approve
206
206
  specific operations (optionally with --value <opId>=<v> for placeholders),
207
- then apply by id — the store enforces approval and records every run.
207
+ then apply by id — the store enforces approval and records every run. When
208
+ paired, local and hosted are replicas of the same immutable plan: either
209
+ capable surface may approve or apply, and each CLI check-in imports missing
210
+ approvals and exact operation receipts. Use \`plans sync\` for an explicit
211
+ check-in; offline changes reconcile later without blocking local work.
208
212
 
209
213
  Authentication (checked in order):
210
214
  1. --token-env <name> explicit env var for this invocation (hubspot)
@@ -543,27 +547,28 @@ export const HELP: Record<string, HelpEntry> = {
543
547
  seeAlso: ["audit", "plans", "apply"],
544
548
  },
545
549
  plans: {
546
- summary: "plan lifecycle: list / show / approve / reject saved plans",
550
+ summary: "replicated plan lifecycle: list / show / sync / approve / reject",
547
551
  phase: "Govern",
548
552
  synopsis: [
549
- "fullstackgtm plans list [--status <s>] | show <id> | reject <id>",
553
+ "fullstackgtm plans list [--status <s>] | show <id> [--verbose] | sync | reject <id>",
550
554
  "fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]",
551
555
  "fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low]",
552
556
  "fullstackgtm plans recover <id> --acknowledge-uncertain-writes",
553
557
  ],
554
- detail: "Approval is explicit and per-operation; placeholders need a concrete --value or a suggestion to be approvable.",
558
+ detail:
559
+ "Approval is explicit and per-operation; placeholders need a concrete --value or a suggestion to be approvable. When paired, hosted and CLI are eventually consistent replicas of one immutable plan. `plans sync` explicitly exchanges approval revisions, execution state, and exact per-operation receipts; ordinary plan commands also check in best-effort. Either surface may apply when its CRM connector supports the selected operations.",
555
560
  seeAlso: ["audit", "suggest", "apply"],
556
561
  },
557
562
  apply: {
558
563
  summary: "write ONLY explicitly approved operations to a provider",
559
564
  phase: "Govern / Verify",
560
565
  synopsis: [
561
- "fullstackgtm apply --plan-id <id> --provider <name>",
566
+ "fullstackgtm apply --plan-id <id> --provider <name> [--verbose|--json]",
562
567
  "fullstackgtm apply --plan-id <id> --channel outbox (render approved drafted openers to the outbox; transmits nothing)",
563
568
  "fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [--value <opId>=<v>]",
564
569
  ],
565
570
  detail:
566
- "The only verb that mutates a CRM. Writes only operations approved via `plans approve` or `--approve`, with compare-and-set and readback. Never writes requires_human_* placeholders without a --value override.",
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.",
567
572
  seeAlso: ["plans", "suggest", "audit-log"],
568
573
  },
569
574
  "audit-log": {
@@ -653,7 +658,7 @@ export const BESPOKE_HELP = ["init", "call", "market", "tam", "enrich", "schedul
653
658
  export const GLOBAL_FLAGS = ["--help", "--full"];
654
659
  export const GLOBAL_SHORT_FLAGS = ["-h"];
655
660
  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"];
661
+ export const AUDIT_FLAGS = ["--config", "--allow-plugins", "--no-plugins", "--rules", "--stale-days", "--fail-on", "--save", "--dry-run", "--json", "--out", "--full", "--verbose"];
657
662
 
658
663
  // Complete per-command flag registry used by runCli's fail-closed flag
659
664
  // validation. Keep this next to HELP so focused help, machine capabilities,
@@ -662,7 +667,7 @@ export const COMMAND_FLAGS: Record<string, string[]> = {
662
667
  init: ["--source", "--provider", "--out", "--force"],
663
668
  login: ["--via", "--hosted", "--private-token", "--token", "--key", "--api-key", "--client-id", "--client-secret", "--scopes", "--port", "--oauth", "--device", "--instance-url", "--login-url", "--no-validate"],
664
669
  logout: [],
665
- doctor: ["--json"],
670
+ doctor: ["--verbose", "--json"],
666
671
  capabilities: ["--json"],
667
672
  "robot-docs": [],
668
673
  profiles: ["--json"],
@@ -676,28 +681,28 @@ export const COMMAND_FLAGS: Record<string, string[]> = {
676
681
  hierarchy: [...SOURCE_FLAGS, "--json", "--out"],
677
682
  relationships: [...SOURCE_FLAGS, "--account-id", "--domain", "--json", "--out"],
678
683
  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"],
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"],
686
691
  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"],
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"],
689
694
  "audit-log": ["--in", "--out", "--json"],
690
695
  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"],
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"],
697
702
  };
698
703
 
699
704
  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",
705
+ "--account", "--account-id", "--accounts", "--acv", "--acv-basis", "--after", "--anchor", "--api-key", "--approve", "--archive", "--assign-owner", "--attendees", "--before", "--bucket", "--buyers-per-account", "--call", "--call-type", "--calls", "--capture-run", "--category", "--channel", "--client", "--client-id", "--client-secret", "--config", "--connector", "--connector-opt", "--contact", "--cron", "--cross-checks", "--deal", "--deal-period", "--diff", "--domain", "--email", "--except-deal-stage", "--format", "--from", "--from-judge", "--golden", "--guard", "--icp", "--in", "--input", "--instance-url", "--keep", "--key", "--keywords", "--label", "--list", "--login-url", "--match", "--match-property", "--max", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--min-accuracy", "--min-confidence", "--min-mentions", "--min-score", "--model", "--name", "--objects", "--operations", "--out", "--pipeline", "--plan", "--plan-id", "--policy", "--port", "--prepared-by", "--prior-run", "--profile", "--promote-lift", "--prompt", "--provider", "--reason", "--require", "--result", "--rubric", "--rule", "--rules", "--run", "--run-label", "--runs", "--scan-limit", "--scopes", "--seed", "--set", "--signals-from", "--since", "--snapshot", "--source", "--staged-run", "--stale-days", "--status", "--task-account", "--task-deal", "--timer", "--title", "--to", "--today", "--token", "--token-env", "--touch", "--transcript", "--trigger", "--usd-per-credit", "--value", "--values-from", "--vendor", "--via", "--watchlist", "--where",
701
706
  ]);
702
707
 
703
708
  // Lifecycle-grouped front door. One line per verb, organized by the
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,15 +11,16 @@ 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";
18
+ import { claimHostedPlanApply, reconcileHostedPatchPlan, releaseHostedPlanApply, reportHostedPlanLifecycle, uploadHostedPatchPlan } from "../hostedPatchPlan.ts";
18
19
  import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
19
20
  import type { ValueSuggestion } from "../suggest.ts";
20
21
  import type { CanonicalGtmSnapshot, CreateRecordPayload, PatchOperation, PatchPlan, PatchPlanRun } from "../types.ts";
21
22
  import { connectorFor, isOptionValue, numericOption, option, repeatedOption, selectedRules } from "./shared.ts";
22
- 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";
23
24
  import { unknownSubcommandError } from "./suggest.ts";
24
25
 
25
26
 
@@ -33,6 +34,115 @@ function parseValueOverrides(args: string[]) {
33
34
  return valueOverrides;
34
35
  }
35
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
+
36
146
  function tryLoadAcquireConfig(args: string[]): EnrichConfig | undefined {
37
147
  try {
38
148
  const path = resolve(process.cwd(), option(args, "--config") ?? ENRICH_CONFIG_FILE_NAME);
@@ -141,8 +251,18 @@ export async function apply(args: string[]) {
141
251
  const store = planId ? createFilePlanStore() : null;
142
252
 
143
253
  if (planId && store) {
144
- const stored = await store.get(planId);
254
+ let stored = await store.get(planId);
145
255
  if (!stored) throw new Error(`No stored plan with id ${planId}.`);
256
+ const reconciliation = await reconcileHostedPatchPlan(store, stored);
257
+ if (reconciliation.status === "conflict") throw new Error(`Refusing to apply plan ${planId}: ${reconciliation.reason}.`);
258
+ if (reconciliation.status === "updated") {
259
+ stored = reconciliation.stored;
260
+ console.error(`Synced hosted ${reconciliation.remoteStatus} state for ${planId}.`);
261
+ }
262
+ if (stored.status === "applied") {
263
+ console.log(`Plan ${planId} was already applied by another replica; imported its execution receipt. No provider writes were attempted.`);
264
+ return;
265
+ }
146
266
  if (stored.status !== "approved") {
147
267
  throw new Error(
148
268
  `Plan ${planId} is ${stored.status}; approve operations first with \`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`,
@@ -235,9 +355,29 @@ export async function apply(args: string[]) {
235
355
  // transmits nothing; a CRM provider writes records. Same governed apply path.
236
356
  const connector = channel ? createChannelConnector(channel) : await connectorFor(provider!, args);
237
357
  let applyClaimId: string | undefined;
358
+ let hostedApplyClaimId: string | undefined;
238
359
  if (planId && store) {
239
360
  const claimed = await store.claimApply(planId, { provider: provider ?? `channel:${channel}`, source: "cli" });
240
361
  applyClaimId = claimed.claimId;
362
+ const hostedClaim = await claimHostedPlanApply(claimed.stored);
363
+ if (hostedClaim.status === "applied") {
364
+ await store.abortApplyPreflight(planId, claimed.claimId, "Another replica completed apply before provider I/O.");
365
+ const refreshed = await store.get(planId);
366
+ if (refreshed) await reconcileHostedPatchPlan(store, refreshed);
367
+ console.log(`Plan ${planId} was already applied by another replica. No provider writes were attempted.`);
368
+ return;
369
+ }
370
+ if (hostedClaim.status === "conflict") {
371
+ await store.abortApplyPreflight(planId, claimed.claimId, `Hosted execution claim failed: ${hostedClaim.reason}`);
372
+ throw new Error(`Refusing to apply plan ${planId}: could not coordinate with its hosted replica (${hostedClaim.reason}).`);
373
+ }
374
+ if (hostedClaim.status === "unavailable") {
375
+ 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.`);
376
+ }
377
+ if (hostedClaim.status === "claimed") {
378
+ hostedApplyClaimId = hostedClaim.claimId;
379
+ await store.recordHostedClaim(planId, claimed.claimId, hostedClaim.claimId);
380
+ }
241
381
  const claimedVerification = verifyApprovalDigests(
242
382
  claimed.stored.plan.operations,
243
383
  claimed.stored.approvedOperationIds,
@@ -245,6 +385,16 @@ export async function apply(args: string[]) {
245
385
  claimed.stored.approvalDigests,
246
386
  );
247
387
  if (!claimedVerification.ok) {
388
+ if (hostedApplyClaimId) {
389
+ const released = await releaseHostedPlanApply(
390
+ claimed.stored,
391
+ hostedApplyClaimId,
392
+ "Approval integrity verification failed after claim and before provider I/O.",
393
+ );
394
+ if (released.status === "conflict" || released.status === "unavailable") {
395
+ console.error(`Hosted apply claim may require recovery: ${released.reason}.`);
396
+ }
397
+ }
248
398
  await store.abortApplyPreflight(
249
399
  planId,
250
400
  claimed.claimId,
@@ -280,7 +430,12 @@ export async function apply(args: string[]) {
280
430
  renderer.done();
281
431
  }
282
432
  if (planId && store) {
283
- await store.recordRun(planId, run, applyClaimId!);
433
+ const recorded = await store.recordRun(planId, run, applyClaimId!);
434
+ const durableHostedClaimId = recorded.applyAttempts?.find((attempt) => attempt.id === applyClaimId)?.hostedClaimId;
435
+ const mirrored = await reportHostedPlanLifecycle(recorded, { claimId: durableHostedClaimId ?? hostedApplyClaimId });
436
+ if (mirrored.status === "unavailable" || mirrored.status === "conflict") {
437
+ console.error(`Hosted plan status is stale: ${mirrored.reason}. Local apply history remains authoritative.`);
438
+ }
284
439
  }
285
440
 
286
441
  // Charge the acquire meter for the creates that actually landed.
@@ -315,8 +470,10 @@ export async function apply(args: string[]) {
315
470
 
316
471
  if (args.includes("--json")) {
317
472
  console.log(JSON.stringify(run, null, 2));
318
- } else {
473
+ } else if (args.includes("--verbose")) {
319
474
  console.log(formatPatchPlanRun(run));
475
+ } else {
476
+ printApplyCards(run, plan, approvedOperationIds, Boolean(planId && store));
320
477
  }
321
478
  if (run.status === "failed") process.exitCode = 1;
322
479
  }
@@ -408,7 +565,13 @@ export async function plansCommand(args: string[]) {
408
565
  const status = option(rest, "--status") as
409
566
  | "draft" | "needs_approval" | "approved" | "rejected" | "applied"
410
567
  | null;
411
- const plans = await store.list(status ?? undefined);
568
+ let plans = await store.list(status ?? undefined);
569
+ const synced = await Promise.all(plans.map(async (stored) => {
570
+ const result = await reconcileHostedPatchPlan(store, stored);
571
+ if (result.status === "updated") return result.stored;
572
+ return stored;
573
+ }));
574
+ plans = status ? synced.filter((stored) => stored.status === status) : synced;
412
575
  if (rest.includes("--json") || args.includes("--json")) {
413
576
  console.log(
414
577
  JSON.stringify(
@@ -432,14 +595,23 @@ export async function plansCommand(args: string[]) {
432
595
  }
433
596
  const p = paint(colorEnabled(process.stdout));
434
597
  if (p.enabled) {
435
- // Interactive terminals get an aligned table with status color-banding;
436
- // 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.
437
609
  const rows = plans.map((stored) => [
438
- stored.plan.id,
439
610
  stored.status,
440
- `${stored.approvedOperationIds.length} approved`,
611
+ stored.plan.title,
612
+ `${stored.approvedOperationIds.length}/${stored.plan.operations.length} selected`,
441
613
  `${stored.runs.length} run${stored.runs.length === 1 ? "" : "s"}`,
442
- stored.plan.summary,
614
+ stored.plan.id,
443
615
  ]);
444
616
  // Long summaries would wrap and break the table's alignment. The summary
445
617
  // is the LAST column: cap it to what's left of the terminal width after
@@ -447,19 +619,26 @@ export async function plansCommand(args: string[]) {
447
619
  // terminals still show something useful).
448
620
  const columns = process.stdout.columns ?? 80;
449
621
  const fixedWidth =
450
- [0, 1, 2, 3].reduce(
622
+ [0, 2, 3, 4].reduce(
451
623
  (sum, index) => sum + Math.max(...rows.map((row) => row[index].length)),
452
624
  0,
453
625
  ) + 2 * 4;
454
- const summaryWidth = Math.max(24, columns - fixedWidth);
455
- 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);
456
628
  const statusPainter = (cell: string) => {
457
629
  const painted = planStatusWord(cell.trimEnd(), p);
458
630
  return painted + cell.slice(cell.trimEnd().length);
459
631
  };
460
- 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])) {
461
633
  console.log(line);
462
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
+ }
463
642
  return;
464
643
  }
465
644
  for (const stored of plans) {
@@ -473,25 +652,34 @@ export async function plansCommand(args: string[]) {
473
652
  if (subcommand === "show") {
474
653
  const planId = rest.find((arg) => !arg.startsWith("--"));
475
654
  if (!planId) throw new Error("Usage: fullstackgtm plans show <planId>");
476
- const stored = await store.get(planId);
655
+ let stored = await store.get(planId);
477
656
  if (!stored) throw new Error(`No stored plan with id ${planId}.`);
657
+ const reconciliation = await reconcileHostedPatchPlan(store, stored);
658
+ if (reconciliation.status === "updated") {
659
+ stored = reconciliation.stored;
660
+ console.error(`Synced hosted ${reconciliation.remoteStatus} state for ${planId}.`);
661
+ } else if (reconciliation.status === "conflict") {
662
+ console.error(`Hosted sync conflict: ${reconciliation.reason}.`);
663
+ }
478
664
  if (rest.includes("--json")) {
479
665
  console.log(JSON.stringify(stored, null, 2));
480
666
  return;
481
667
  }
482
- const showPaint = paint(colorEnabled(process.stdout));
483
- console.log(`Status: ${planStatusWord(stored.status, showPaint)}`);
484
- console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
485
- console.log(`Runs: ${stored.runs.length}`);
486
- if (stored.applyAttempts?.length) {
668
+ const mirror = await uploadHostedPatchPlan(stored);
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) {
487
672
  console.log("Apply attempts:");
488
673
  for (const attempt of stored.applyAttempts) {
489
674
  console.log(` ${attempt.id} ${attempt.status} ${attempt.provider} via ${attempt.source} ${attempt.claimedAt}`);
490
675
  if (attempt.note) console.log(` ${attempt.note}`);
491
676
  }
492
677
  }
493
- console.log("");
494
- 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
+ }
495
683
  return;
496
684
  }
497
685
 
@@ -541,6 +729,10 @@ export async function plansCommand(args: string[]) {
541
729
  ...fileOverrides,
542
730
  ...explicitOverrides,
543
731
  });
732
+ const mirror = await reportHostedPlanLifecycle(updated);
733
+ if (mirror.status === "unavailable" || mirror.status === "conflict") {
734
+ console.error(`Hosted plan status is stale: ${mirror.reason}. Local approval remains authoritative.`);
735
+ }
544
736
  console.log(
545
737
  `Approved ${updated.approvedOperationIds.length} operation(s) on ${planId}. Apply with \`fullstackgtm apply --plan-id ${planId} --provider <name>\`.`,
546
738
  );
@@ -550,7 +742,11 @@ export async function plansCommand(args: string[]) {
550
742
  if (subcommand === "reject") {
551
743
  const planId = rest.find((arg) => !arg.startsWith("--"));
552
744
  if (!planId) throw new Error("Usage: fullstackgtm plans reject <planId>");
553
- await store.reject(planId);
745
+ const rejected = await store.reject(planId);
746
+ const mirror = await reportHostedPlanLifecycle(rejected);
747
+ if (mirror.status === "unavailable" || mirror.status === "conflict") {
748
+ console.error(`Hosted plan status is stale: ${mirror.reason}. Local rejection remains authoritative.`);
749
+ }
554
750
  console.log(`Rejected ${planId}.`);
555
751
  return;
556
752
  }
@@ -581,5 +777,43 @@ export async function plansCommand(args: string[]) {
581
777
  return;
582
778
  }
583
779
 
584
- throw unknownSubcommandError("plans", subcommand, ["list", "show", "approve", "reject", "recover"]);
780
+ if (subcommand === "sync") {
781
+ const plans = await store.list();
782
+ let updated = 0;
783
+ let completed = 0;
784
+ const warnings: string[] = [];
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}`);
806
+ }
807
+ };
808
+ try {
809
+ await Promise.all(Array.from({ length: Math.min(6, plans.length) }, () => worker()));
810
+ } finally {
811
+ status.done();
812
+ }
813
+ console.log(`Plan replicas synchronized: ${updated} updated, ${plans.length - updated} unchanged.`);
814
+ for (const warning of warnings) console.error(` ${warning}`);
815
+ return;
816
+ }
817
+
818
+ throw unknownSubcommandError("plans", subcommand, ["list", "show", "sync", "approve", "reject", "recover"]);
585
819
  }