fullstackgtm 0.56.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ The path to 1.0 is planned in [docs/roadmap-to-1.0.md](https://github.com/fullst
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.57.0] — 2026-07-13
11
+
12
+ ### Added
13
+
14
+ - Paired CLIs can inspect and safely reconcile hosted ICPs with `icp status`,
15
+ `icp pull`, `icp push`, and `icp sync`. A private adjacent sidecar records
16
+ the last synchronized revision and content hash without changing `icp.json`.
17
+ - Hosted ICP edits publish immutable numbered revisions with author, timestamp,
18
+ origin, content hash, change summary, comparison, and restore-as-new-revision
19
+ history.
20
+ - Signal evidence and ICP-scored lead previews record the exact ICP revision
21
+ and hash used to produce them.
22
+
23
+ ### Changed
24
+
25
+ - Hosted changes never silently replace a local ICP. Concurrent local and
26
+ hosted edits are reported as conflicts, and `icp sync` writes the hosted
27
+ revision to a separate review file.
28
+ - Older CLIs may continue updating CLI-origin artifacts, but blind writes can
29
+ no longer overwrite an artifact whose latest revision was published in the
30
+ hosted editor.
31
+
32
+ ## [0.56.1] — 2026-07-13
33
+
34
+ ### Fixed
35
+
36
+ - Portable Clay normalization retains provider `work_email` and fallback
37
+ `personal_email` fields, preserving the homepage demo's pre-existing contact
38
+ disclosure behavior while still returning only the canonical prospect shape.
39
+
10
40
  ## [0.56.0] — 2026-07-13
11
41
 
12
42
  ### Added
package/README.md CHANGED
@@ -199,6 +199,23 @@ fullstackgtm apply --plan-id <id> --provider hubspot # the only step that
199
199
 
200
200
  The **ICP** (`icp.json`) is the single targeting artifact: it generates each provider's discovery filters (Explorium, pipe0/Crustdata) *and* fit-scores every discovered prospect — only above-threshold leads are proposed. Develop one by interview; the CLI can't run `AskUserQuestion` itself, so `icp interview` emits the spec and an agent (Claude Code / Codex) drives it.
201
201
 
202
+ When the CLI is paired, the hosted workspace and local ICP can be edited
203
+ without last-write-wins data loss:
204
+
205
+ ```bash
206
+ fullstackgtm icp status --domain acme.com --icp ./icp.json
207
+ fullstackgtm icp push --domain acme.com --icp ./icp.json --change-summary "Narrowed buyer titles"
208
+ fullstackgtm icp sync --domain acme.com --icp ./icp.json
209
+ fullstackgtm icp pull --domain acme.com --out ./icp.json --force
210
+ ```
211
+
212
+ Published hosted edits are immutable numbered revisions. The CLI stores only
213
+ sync metadata in `./.fullstackgtm/icp.json.sync.json`; the portable `icp.json`
214
+ schema is unchanged. `sync` never silently replaces the local file: if hosted
215
+ changed, it writes `icp.json.hosted-rN.json` for review. If both sides changed,
216
+ it reports a conflict rather than unioning ordered targeting arrays. Signal and
217
+ lead-preview records retain the exact ICP revision used for later analysis.
218
+
202
219
  **You don't pay to re-discover dupes.** Before the (credit-spending) email step, acquire drops prospects already in your CRM and any seen in a prior run:
203
220
 
204
221
  - **Pre-email CRM dedup** matches the live snapshot. The LinkedIn URL (`hs_linkedin_url`, read into the snapshot by default — safe everywhere, HubSpot ignores unknown properties) is the strong key; name+domain is the fallback. Created contacts get their LinkedIn URL written back, so coverage — and dedup precision — grows over time. If your CRM has no LinkedIn URLs, acquire says so and recommends populating it.
@@ -25,6 +25,8 @@ import { providerKey } from "./tam.js";
25
25
  import { unknownSubcommandError } from "./suggest.js";
26
26
  import { box, colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, scoreColor, truncateToWidth } from "./ui.js";
27
27
  import { compactPlan, verbosePlanRequested } from "./planOutput.js";
28
+ import { writeHostedArtifact } from "../hostedArtifacts.js";
29
+ import { readIcpSyncState } from "../icpSync.js";
28
30
  /**
29
31
  * The enrich layer: governed append/refresh of third-party data (Apollo pull,
30
32
  * Clay ingest) into the CRM through the normal dry-run → approval → apply
@@ -265,6 +267,18 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
265
267
  });
266
268
  const meterLine = formatAcquireMeter(headroom, costPerRecord);
267
269
  const gaugeLine = acquireGaugeLine(headroom, config.acquire.budget ?? {}, paint(colorEnabled(process.stdout)));
270
+ const icpPath = resolve(process.cwd(), option(rest, "--icp") ?? "icp.json");
271
+ const trackedIcp = existsSync(icpPath) ? readIcpSyncState(icpPath) : null;
272
+ const leadMirror = await writeHostedArtifact({
273
+ kind: "lead_run", key: `leads:${result.plan.id}`, label: result.plan.title ?? result.plan.id,
274
+ domain: trackedIcp?.domain,
275
+ document: { runLabel: result.plan.id, createdAt: new Date().toISOString(), source, counts: result.counts,
276
+ plan: result.plan, icpRef: trackedIcp ? { artifactId: trackedIcp.artifactId, domain: trackedIcp.domain, revision: trackedIcp.revision, localIcpSha256: trackedIcp.localIcpSha256 } : undefined },
277
+ });
278
+ if (leadMirror.status === "saved")
279
+ console.error(`Recorded lead preview against${trackedIcp ? ` ICP revision ${trackedIcp.revision}` : " the local untracked ICP"}.`);
280
+ else if (leadMirror.status === "unavailable")
281
+ console.error(`Warning: ${leadMirror.reason}. The local lead preview is unchanged.`);
268
282
  if (!save) {
269
283
  printAcquireOutput({
270
284
  args: rest,
package/dist/cli/help.js CHANGED
@@ -639,13 +639,13 @@ export const COMMAND_FLAGS = {
639
639
  merge: ["--input", "--out", "--json"],
640
640
  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"],
641
641
  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"],
642
- icp: [...SOURCE_FLAGS, "--name", "--icp", "--domain", "--no-interactive", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--save", "--verbose", "--json", "--out"],
642
+ icp: [...SOURCE_FLAGS, "--name", "--icp", "--domain", "--no-interactive", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--change-summary", "--force", "--save", "--verbose", "--json", "--out"],
643
643
  signals: ["--bucket", "--source", "--connector", "--connector-opt", "--watchlist", "--keywords", "--from", "--label", "--since", "--icp", "--max-accounts", "--max-results", "--max-searches", "--max-usd", "--account", "--contact", "--unjudged", "--touch", "--result", "--save", "--verbose", "--json", "--explain"],
644
644
  draft: ["--from-judge", "--min-score", "--prompt", "--model", "--channel", "--save", "--dry-run", "--verbose", "--json", "--out"],
645
645
  schedule: ["--cron", "--label", "--provider", "--trigger", "--timer", "--runs", "--verbose", "--json"],
646
646
  };
647
647
  export const FLAGS_WITH_VALUES = new Set([
648
- "--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", "--company-domain", "--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-accounts", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--max-results", "--max-searches", "--max-usd", "--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",
648
+ "--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", "--change-summary", "--channel", "--client", "--client-id", "--client-secret", "--company-domain", "--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-accounts", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--max-results", "--max-searches", "--max-usd", "--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",
649
649
  ]);
650
650
  // Lifecycle-grouped front door. One line per verb, organized by the
651
651
  // Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
package/dist/cli/icp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
2
- import { readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { emitKeypressEvents } from "node:readline";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { resolve } from "node:path";
@@ -8,13 +8,15 @@ import { fitThreshold, icpFromAnswers, icpToCrustdataFilters, icpToExploriumFilt
8
8
  import { createFileSignalStore, DEFAULT_SIGNALS_CONFIG } from "../signals.js";
9
9
  import { createFileJudgeStore, DEFAULT_JUDGE_PROMPT, judgeRunId, judgeSignals } from "../judge.js";
10
10
  import { DEFAULT_GOLDEN_NOW_ISO, DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet } from "../judgeEval.js";
11
- import { loadIcp, numericOption, option, readSecret, readSnapshot, resolveLlmBaseUrls, saveRequested } from "./shared.js";
11
+ import { loadIcp, numericOption, option, readPackageInfo, readSecret, readSnapshot, resolveLlmBaseUrls, saveRequested } from "./shared.js";
12
12
  import { createStatusLine } from "./ui.js";
13
13
  import { box, colorEnabled, paint, truncateToWidth } from "./ui.js";
14
14
  import { getCredential, storeCredential } from "../credentials.js";
15
15
  import { deriveWebsiteIcp, icpReviewSegments, OPENROUTER_API_BASE } from "../icpDerive.js";
16
16
  import { unknownSubcommandError } from "./suggest.js";
17
- import { writeHostedArtifact } from "../hostedArtifacts.js";
17
+ import { readHostedArtifact, writeHostedArtifact } from "../hostedArtifacts.js";
18
+ import { artifactKeyForDomain, extractHostedIcp, getIcpSyncStatus, markIcpSynced, pushIcp, readIcpSyncState } from "../icpSync.js";
19
+ import { writeSecureFileAtomic } from "../secureFile.js";
18
20
  function renderJudgeDecisions(decisions) {
19
21
  if (decisions.length === 0)
20
22
  return "No accounts cleared the score threshold.";
@@ -192,6 +194,14 @@ export async function icpCommand(args) {
192
194
  fullstackgtm icp interview emit the interview spec (an agent drives it with AskUserQuestion)
193
195
  fullstackgtm icp derive --domain <site> [--model <id>] [--out icp.json] [--json]
194
196
  derive an evidence-backed ICP from a public website (OpenRouter/OpenAI/Anthropic)
197
+ fullstackgtm icp status --domain <site> [--icp icp.json] [--json]
198
+ compare local and hosted revisions without changing either
199
+ fullstackgtm icp pull --domain <site> [--out icp.json] [--force] [--json]
200
+ explicitly download hosted canonical ICP; refuses to replace an existing file without --force
201
+ fullstackgtm icp push --domain <site> [--icp icp.json] [--change-summary <text>] [--json]
202
+ publish the local ICP with revision conflict protection
203
+ fullstackgtm icp sync --domain <site> [--icp icp.json] [--json]
204
+ reconcile safe one-sided changes; hosted changes become a sibling review file
195
205
  fullstackgtm icp set <answers.json> [--name <n>] [--out <path>] write icp.json from interview answers
196
206
  fullstackgtm icp show [--icp <path>] render the ICP + the discovery filters it produces
197
207
  fullstackgtm icp judge [--signals-from latest|<label>] [--with-history] [--prompt <path>] [--min-score 0] [--save] rank unjudged signals into send/nurture/skip decisions (timing x fit x memory)
@@ -245,10 +255,15 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
245
255
  }
246
256
  const mirrored = await writeHostedArtifact({
247
257
  kind: "icp", key: `icp:${derived.company.domain}`, label: derived.icp.name,
248
- domain: derived.company.domain, document: derived,
258
+ domain: derived.company.domain, document: derived, sourceVersion: readPackageInfo().version,
249
259
  });
250
- if (mirrored.status === "saved")
251
- console.error("Mirrored the reviewed ICP to the paired hosted workspace.");
260
+ if (mirrored.status === "saved") {
261
+ if (out)
262
+ markIcpSynced(resolve(process.cwd(), out), derived.company.domain, mirrored, derived.icp);
263
+ console.error(`Mirrored the reviewed ICP to the paired hosted workspace at revision ${mirrored.revision}.`);
264
+ }
265
+ else if (mirrored.status === "conflict")
266
+ console.error(`Warning: ${mirrored.reason}. Run \`fullstackgtm icp sync --domain ${derived.company.domain}${out ? ` --icp ${out}` : ""}\` to reconcile.`);
252
267
  else if (mirrored.status === "unavailable")
253
268
  console.error(`Warning: ${mirrored.reason}. The local ICP is still authoritative.`);
254
269
  if (rest.includes("--json"))
@@ -257,6 +272,84 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
257
272
  console.log(renderDerivedIcp(derived));
258
273
  return;
259
274
  }
275
+ if (["status", "pull", "push", "sync"].includes(sub)) {
276
+ const domain = option(rest, "--domain");
277
+ if (!domain)
278
+ throw new Error(`icp ${sub}: --domain <company.com> is required.`);
279
+ const asJson = rest.includes("--json");
280
+ const icpPath = resolve(process.cwd(), option(rest, "--icp") ?? option(rest, "--out") ?? "icp.json");
281
+ if (sub === "status") {
282
+ if (!existsSync(icpPath))
283
+ throw new Error(`icp status: local ICP not found at ${icpPath}.`);
284
+ const checked = await getIcpSyncStatus(icpPath, domain);
285
+ if (asJson)
286
+ console.log(JSON.stringify({ path: icpPath, domain, ...checked.status }, null, 2));
287
+ else
288
+ console.log(`ICP ${checked.status.state.replaceAll("_", " ")} · local ${checked.status.localIcpSha256.slice(0, 12)}${checked.status.hostedRevision ? ` · hosted r${checked.status.hostedRevision}` : ""}${checked.status.trackedRevision ? ` · last synced r${checked.status.trackedRevision}` : ""}`);
289
+ return;
290
+ }
291
+ if (sub === "pull") {
292
+ const out = resolve(process.cwd(), option(rest, "--out") ?? "icp.json");
293
+ const hosted = await readHostedArtifact("icp", artifactKeyForDomain(domain));
294
+ if (hosted.status !== "found")
295
+ throw new Error(hosted.status === "missing" ? `No hosted ICP exists for ${domain}.` : hosted.status === "unpaired" ? "This CLI is not paired with a hosted workspace." : hosted.reason);
296
+ const icp = extractHostedIcp(hosted.state);
297
+ if (existsSync(out) && !rest.includes("--force"))
298
+ throw new Error(`Refusing to replace ${out}. Re-run with --force after reviewing hosted revision ${hosted.state.revision}.`);
299
+ writeSecureFileAtomic(out, `${JSON.stringify(icp, null, 2)}\n`);
300
+ markIcpSynced(out, domain, hosted.state, icp);
301
+ if (asJson)
302
+ console.log(JSON.stringify({ status: "pulled", path: out, revision: hosted.state.revision, documentSha256: hosted.state.documentSha256 }, null, 2));
303
+ else
304
+ console.log(`Pulled hosted ICP revision ${hosted.state.revision} to ${out}.`);
305
+ return;
306
+ }
307
+ if (!existsSync(icpPath))
308
+ throw new Error(`icp ${sub}: local ICP not found at ${icpPath}.`);
309
+ if (sub === "push") {
310
+ const pushed = await pushIcp(icpPath, domain, option(rest, "--change-summary") ?? undefined);
311
+ if (pushed.status === "conflict")
312
+ throw new Error(`ICP conflict: hosted revision ${pushed.checked.status.hostedRevision ?? "?"} changed since local revision ${pushed.checked.status.trackedRevision ?? "untracked"}. Run \`icp sync\` to write a review copy.`);
313
+ if (pushed.result.status !== "saved")
314
+ throw new Error(pushed.result.status === "unpaired" ? "This CLI is not paired with a hosted workspace." : pushed.result.status === "conflict" ? pushed.result.reason : pushed.result.reason);
315
+ if (asJson)
316
+ console.log(JSON.stringify({ status: "pushed", revision: pushed.result.revision, documentSha256: pushed.result.documentSha256, unchanged: pushed.result.unchanged }, null, 2));
317
+ else
318
+ console.log(`${pushed.result.unchanged ? "Already at" : "Published"} hosted ICP revision ${pushed.result.revision}.`);
319
+ return;
320
+ }
321
+ const checked = await getIcpSyncStatus(icpPath, domain);
322
+ if (checked.status.state === "local_changed" || checked.status.state === "missing_hosted") {
323
+ const pushed = await pushIcp(icpPath, domain, "Synchronized local ICP");
324
+ if (pushed.result?.status !== "saved")
325
+ throw new Error("ICP sync could not publish the local change.");
326
+ if (asJson)
327
+ console.log(JSON.stringify({ status: "pushed", revision: pushed.result.revision }, null, 2));
328
+ else
329
+ console.log(`Published local ICP as hosted revision ${pushed.result.revision}.`);
330
+ return;
331
+ }
332
+ if ((checked.status.state === "hosted_changed" || checked.status.state === "conflict" || checked.status.state === "untracked") && checked.hosted) {
333
+ const reviewPath = `${icpPath}.hosted-r${checked.hosted.revision}.json`;
334
+ writeSecureFileAtomic(reviewPath, `${JSON.stringify(extractHostedIcp(checked.hosted), null, 2)}\n`);
335
+ const result = { status: checked.status.state, reviewPath, hostedRevision: checked.hosted.revision, trackedRevision: readIcpSyncState(icpPath)?.revision };
336
+ if (asJson)
337
+ console.log(JSON.stringify(result, null, 2));
338
+ else
339
+ console.log(`Hosted changes need review. Wrote revision ${checked.hosted.revision} to ${reviewPath}; your local ICP was not replaced.`);
340
+ return;
341
+ }
342
+ if (checked.status.state === "in_sync") {
343
+ if (!readIcpSyncState(icpPath) && checked.hosted)
344
+ markIcpSynced(icpPath, domain, checked.hosted, checked.local);
345
+ if (asJson)
346
+ console.log(JSON.stringify({ status: "in_sync", revision: checked.status.hostedRevision }, null, 2));
347
+ else
348
+ console.log(`ICP is in sync${checked.status.hostedRevision ? ` at revision ${checked.status.hostedRevision}` : ""}.`);
349
+ return;
350
+ }
351
+ throw new Error(checked.status.reason ?? `ICP sync unavailable (${checked.status.state}).`);
352
+ }
260
353
  if (sub === "set") {
261
354
  const file = rest.find((a) => !a.startsWith("--"));
262
355
  if (!file)
@@ -11,6 +11,7 @@ import { loadIcp, option, readSnapshot, repeatedOption, saveRequested } from "./
11
11
  import { createStatusLine } from "./ui.js";
12
12
  import { unknownSubcommandError } from "./suggest.js";
13
13
  import { writeHostedArtifact } from "../hostedArtifacts.js";
14
+ import { readIcpSyncState } from "../icpSync.js";
14
15
  /**
15
16
  * Resolve a signals config: explicit --config, else signals.config.json in cwd,
16
17
  * else the zero-config DEFAULT_SIGNALS_CONFIG (preset-first, like enrich).
@@ -170,7 +171,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
170
171
  await store.appendRun({ id: signalRunId(runLabel), runLabel, startedAt: now.toISOString(), completedAt: new Date().toISOString(),
171
172
  buckets: ["job"], counts: { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, signals: ranked });
172
173
  console.error(`Saved signal run "${runLabel}". Next: \`fullstackgtm icp judge --signals-from ${runLabel} --save\`.`);
173
- await mirrorSignalRun(runLabel, now, ["job"], { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, ranked);
174
+ await mirrorSignalRun(runLabel, now, ["job"], { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, ranked, rest);
174
175
  }
175
176
  else {
176
177
  console.error("(not saved — re-run with --save to persist this evidence to the signal ledger)");
@@ -299,7 +300,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
299
300
  signals: ranked,
300
301
  });
301
302
  console.error(`Saved signal run "${runLabel}" (${fresh.length} fresh). Next: \`fullstackgtm icp judge --save\`.`);
302
- await mirrorSignalRun(runLabel, now, buckets, { fetched, new: fresh.length, deduped: deduped.length }, ranked);
303
+ await mirrorSignalRun(runLabel, now, buckets, { fetched, new: fresh.length, deduped: deduped.length }, ranked, rest);
303
304
  }
304
305
  else {
305
306
  console.error("(not saved — re-run with --save to persist this run to the signal ledger)");
@@ -388,10 +389,13 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
388
389
  }
389
390
  throw unknownSubcommandError("signals", sub, ["fetch", "discover", "list", "outcome", "weights"]);
390
391
  }
391
- async function mirrorSignalRun(runLabel, startedAt, buckets, counts, signals) {
392
+ async function mirrorSignalRun(runLabel, startedAt, buckets, counts, signals, args) {
393
+ const icpPath = resolve(process.cwd(), option(args, "--icp") ?? "icp.json");
394
+ const tracked = existsSync(icpPath) ? readIcpSyncState(icpPath) : null;
392
395
  const mirrored = await writeHostedArtifact({
393
396
  kind: "signal_run", key: `signals:${signalRunId(runLabel)}`, label: runLabel,
394
- document: { runLabel, startedAt: startedAt.toISOString(), completedAt: new Date().toISOString(), buckets, counts, signals },
397
+ document: { runLabel, startedAt: startedAt.toISOString(), completedAt: new Date().toISOString(), buckets, counts, signals,
398
+ icpRef: tracked ? { artifactId: tracked.artifactId, domain: tracked.domain, revision: tracked.revision, localIcpSha256: tracked.localIcpSha256 } : undefined },
395
399
  });
396
400
  if (mirrored.status === "saved")
397
401
  console.error("Mirrored the signal run to the paired hosted workspace.");
@@ -1,21 +1,55 @@
1
+ export type HostedArtifactKind = "icp" | "signal_run" | "lead_run";
1
2
  export type HostedArtifact = {
2
- kind: "icp" | "signal_run";
3
+ kind: HostedArtifactKind;
3
4
  key: string;
4
5
  label: string;
5
6
  domain?: string;
6
7
  document: unknown;
7
8
  sourceVersion?: string;
9
+ expectedRevision?: number;
10
+ changeSummary?: string;
11
+ };
12
+ export type HostedArtifactState = HostedArtifact & {
13
+ artifactId: string;
14
+ revision: number;
15
+ documentSha256?: string;
16
+ origin: "cli" | "hosted";
17
+ updatedAt: number;
8
18
  };
9
19
  export type HostedArtifactResult = {
10
20
  status: "unpaired";
11
21
  } | {
12
22
  status: "saved";
23
+ artifactId: string;
13
24
  created: boolean;
14
25
  updatedAt: number;
26
+ revision: number;
27
+ documentSha256: string;
28
+ unchanged: boolean;
29
+ } | {
30
+ status: "conflict";
31
+ reason: string;
32
+ currentRevision?: number;
33
+ documentSha256?: string;
15
34
  } | {
16
35
  status: "unavailable";
17
36
  reason: string;
18
37
  };
38
+ export type HostedArtifactReadResult = {
39
+ status: "unpaired";
40
+ } | {
41
+ status: "found";
42
+ state: HostedArtifactState;
43
+ } | {
44
+ status: "missing";
45
+ } | {
46
+ status: "unavailable";
47
+ reason: string;
48
+ };
49
+ export declare function readHostedArtifact(kind: HostedArtifactKind, key: string, options?: {
50
+ fetchImpl?: typeof fetch;
51
+ timeoutMs?: number;
52
+ }): Promise<HostedArtifactReadResult>;
19
53
  export declare function writeHostedArtifact(artifact: HostedArtifact, options?: {
20
54
  fetchImpl?: typeof fetch;
21
55
  timeoutMs?: number;
@@ -1,4 +1,4 @@
1
- /** Best-effort artifact mirroring for a human-paired CLI profile. */
1
+ /** Version-aware hosted artifact transport for a human-paired CLI profile. */
2
2
  import { getCredential } from "./credentials.js";
3
3
  const ENDPOINT = "/api/cli/artifact";
4
4
  const DEFAULT_TIMEOUT_MS = 4000;
@@ -17,27 +17,52 @@ function broker() {
17
17
  return null;
18
18
  }
19
19
  }
20
+ function requestOptions(accessToken, timeoutMs) {
21
+ return { headers: { Authorization: `Bearer ${accessToken}` }, signal: AbortSignal.timeout(timeoutMs) };
22
+ }
23
+ export async function readHostedArtifact(kind, key, options = {}) {
24
+ const paired = broker();
25
+ if (!paired)
26
+ return { status: "unpaired" };
27
+ try {
28
+ const url = new URL(`${paired.baseUrl}${ENDPOINT}`);
29
+ url.searchParams.set("kind", kind);
30
+ url.searchParams.set("key", key);
31
+ const response = await (options.fetchImpl ?? fetch)(url, requestOptions(paired.accessToken, options.timeoutMs ?? DEFAULT_TIMEOUT_MS));
32
+ if (response.status === 404)
33
+ return { status: "missing" };
34
+ if (!response.ok)
35
+ return { status: "unavailable", reason: `hosted artifact request failed (HTTP ${response.status})` };
36
+ const state = await response.json();
37
+ if (!state.artifactId || !Number.isSafeInteger(state.revision) || !state.document)
38
+ return { status: "unavailable", reason: "hosted artifact response was invalid" };
39
+ return { status: "found", state };
40
+ }
41
+ catch {
42
+ return { status: "unavailable", reason: "hosted artifact request was unavailable" };
43
+ }
44
+ }
20
45
  export async function writeHostedArtifact(artifact, options = {}) {
21
- if (!artifact.key || artifact.key.length > 256 || !artifact.label || artifact.label.length > 200) {
46
+ if (!artifact.key || artifact.key.length > 256 || !artifact.label || artifact.label.length > 200)
22
47
  throw new Error("Hosted artifact key/label is invalid.");
23
- }
24
48
  const paired = broker();
25
49
  if (!paired)
26
50
  return { status: "unpaired" };
27
51
  try {
28
52
  const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}`, {
29
- method: "POST",
30
- headers: { Authorization: `Bearer ${paired.accessToken}`, "Content-Type": "application/json" },
31
- body: JSON.stringify(artifact),
32
- signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
53
+ method: "POST", ...requestOptions(paired.accessToken, options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
54
+ headers: { Authorization: `Bearer ${paired.accessToken}`, "Content-Type": "application/json" }, body: JSON.stringify(artifact),
33
55
  });
56
+ if (response.status === 409) {
57
+ const body = await response.json();
58
+ return { status: "conflict", reason: "hosted ICP changed since the last sync", currentRevision: body.currentRevision, documentSha256: body.documentSha256 };
59
+ }
34
60
  if (!response.ok)
35
61
  return { status: "unavailable", reason: `hosted artifact request failed (HTTP ${response.status})` };
36
62
  const body = await response.json();
37
- if (typeof body.created !== "boolean" || typeof body.updatedAt !== "number") {
63
+ if (typeof body.artifactId !== "string" || typeof body.created !== "boolean" || typeof body.updatedAt !== "number" || typeof body.revision !== "number" || typeof body.documentSha256 !== "string")
38
64
  return { status: "unavailable", reason: "hosted artifact response was invalid" };
39
- }
40
- return { status: "saved", created: body.created, updatedAt: body.updatedAt };
65
+ return { status: "saved", artifactId: body.artifactId, created: body.created, updatedAt: body.updatedAt, revision: body.revision, documentSha256: body.documentSha256, unchanged: body.unchanged === true };
41
66
  }
42
67
  catch {
43
68
  return { status: "unavailable", reason: "hosted artifact request was unavailable" };
package/dist/icp.d.ts CHANGED
@@ -71,6 +71,9 @@ export type Icp = {
71
71
  scoring?: {
72
72
  /** minimum fit (0..1) for a prospect to become a create_record op. Default 0.5. */
73
73
  threshold?: number;
74
+ /** Require a literal persona.titleKeywords phrase in title/headline. This
75
+ * disables the broader function fallback for high-precision sourcing. */
76
+ requireTitleKeyword?: boolean;
74
77
  };
75
78
  };
76
79
  export declare const DEFAULT_FIT_THRESHOLD = 0.5;
package/dist/icp.js CHANGED
@@ -381,15 +381,18 @@ export function scoreProspectAgainstIcp(prospect, icp) {
381
381
  const keywords = (icp.persona.titleKeywords ?? []).map((k) => k.toLowerCase());
382
382
  const levels = (icp.persona.jobLevels ?? []).map((l) => l.toLowerCase());
383
383
  const depts = (icp.persona.departments ?? []).map((d) => d.toLowerCase());
384
+ const exactTitleKeyword = keywords.find((keyword) => title.includes(keyword));
385
+ if (icp.scoring?.requireTitleKeyword && keywords.length && !exactTitleKeyword) {
386
+ return { score: 0, reasons: ["title does not contain a required ICP keyword"] };
387
+ }
384
388
  let score = 0;
385
389
  let weightSum = 0;
386
390
  if (keywords.length) {
387
391
  weightSum += 0.6;
388
- const exact = keywords.find((k) => title.includes(k));
389
- const functional = exact ? undefined : roleKeywords(icp).find((keyword) => title.includes(keyword));
390
- if (exact) {
392
+ const functional = exactTitleKeyword ? undefined : roleKeywords(icp).find((keyword) => title.includes(keyword));
393
+ if (exactTitleKeyword) {
391
394
  score += 0.6;
392
- reasons.push(`title matches ICP keyword "${exact}"`);
395
+ reasons.push(`title matches ICP keyword "${exactTitleKeyword}"`);
393
396
  }
394
397
  else if (functional) {
395
398
  score += 0.45;
@@ -0,0 +1,55 @@
1
+ import { type Icp } from "./icp.ts";
2
+ import { type HostedArtifactState } from "./hostedArtifacts.ts";
3
+ export type IcpSyncState = {
4
+ version: 1;
5
+ artifactId: string;
6
+ key: string;
7
+ domain: string;
8
+ revision: number;
9
+ localIcpSha256: string;
10
+ hostedDocumentSha256?: string;
11
+ syncedAt: string;
12
+ };
13
+ export type IcpSyncStatus = {
14
+ state: "unpaired" | "missing_hosted" | "in_sync" | "local_changed" | "hosted_changed" | "conflict" | "untracked" | "unavailable";
15
+ localIcpSha256: string;
16
+ hostedRevision?: number;
17
+ trackedRevision?: number;
18
+ reason?: string;
19
+ };
20
+ export declare function classifyIcpSync(localHash: string, hostedRevision: number, hostedHash: string, tracked: IcpSyncState | null): IcpSyncStatus["state"];
21
+ export declare function canonicalJson(value: unknown): string;
22
+ export declare const icpSha256: (icp: Icp) => string;
23
+ export declare function artifactKeyForDomain(domain: string): string;
24
+ export declare const sidecarPathFor: (icpPath: string) => string;
25
+ export declare function readLocalIcp(path: string): Icp;
26
+ export declare function extractHostedIcp(state: HostedArtifactState): Icp;
27
+ export declare function readIcpSyncState(icpPath: string): IcpSyncState | null;
28
+ export declare function writeIcpSyncState(icpPath: string, state: IcpSyncState): void;
29
+ export declare function getIcpSyncStatus(icpPath: string, domain: string): Promise<{
30
+ status: IcpSyncStatus;
31
+ hosted?: HostedArtifactState;
32
+ local: Icp;
33
+ }>;
34
+ export declare function markIcpSynced(icpPath: string, domain: string, hosted: HostedArtifactState | {
35
+ artifactId: string;
36
+ revision: number;
37
+ documentSha256?: string;
38
+ }, icp: Icp): void;
39
+ export declare function pushIcp(icpPath: string, domain: string, changeSummary?: string): Promise<{
40
+ status: "conflict";
41
+ checked: {
42
+ status: IcpSyncStatus;
43
+ hosted?: HostedArtifactState;
44
+ local: Icp;
45
+ };
46
+ result?: undefined;
47
+ } | {
48
+ status: "conflict" | "unpaired" | "unavailable" | "saved";
49
+ result: import("./hostedArtifacts.ts").HostedArtifactResult;
50
+ checked: {
51
+ status: IcpSyncStatus;
52
+ hosted?: HostedArtifactState;
53
+ local: Icp;
54
+ };
55
+ }>;
@@ -0,0 +1,93 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { parseIcp } from "./icp.js";
5
+ import { readSecureRegularFile, writeSecureFileAtomic } from "./secureFile.js";
6
+ import { readHostedArtifact, writeHostedArtifact } from "./hostedArtifacts.js";
7
+ export function classifyIcpSync(localHash, hostedRevision, hostedHash, tracked) {
8
+ if (!tracked)
9
+ return localHash === hostedHash ? "in_sync" : "untracked";
10
+ const localChanged = localHash !== tracked.localIcpSha256;
11
+ const hostedChanged = hostedRevision !== tracked.revision;
12
+ return localChanged && hostedChanged ? "conflict" : localChanged ? "local_changed" : hostedChanged ? "hosted_changed" : "in_sync";
13
+ }
14
+ export function canonicalJson(value) {
15
+ if (value === null || typeof value !== "object")
16
+ return JSON.stringify(value);
17
+ if (Array.isArray(value))
18
+ return `[${value.map(canonicalJson).join(",")}]`;
19
+ const object = value;
20
+ return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`;
21
+ }
22
+ export const icpSha256 = (icp) => createHash("sha256").update(canonicalJson(icp)).digest("hex");
23
+ export function artifactKeyForDomain(domain) {
24
+ const candidate = /^https?:\/\//i.test(domain.trim()) ? domain.trim() : `https://${domain.trim()}`;
25
+ let parsed;
26
+ try {
27
+ parsed = new URL(candidate);
28
+ }
29
+ catch {
30
+ throw new Error(`Invalid company domain: ${domain}`);
31
+ }
32
+ const hostname = parsed.hostname.toLowerCase().replace(/^www\./, "").replace(/\.$/, "");
33
+ if (!hostname.includes("."))
34
+ throw new Error(`Invalid public company domain: ${domain}`);
35
+ return `icp:${hostname}`;
36
+ }
37
+ export const sidecarPathFor = (icpPath) => resolve(dirname(resolve(icpPath)), ".fullstackgtm", `${resolve(icpPath).split(/[\\/]/).pop()}.sync.json`);
38
+ export function readLocalIcp(path) { return parseIcp(readSecureRegularFile(resolve(path))); }
39
+ export function extractHostedIcp(state) {
40
+ const root = state.document && typeof state.document === "object" && !Array.isArray(state.document) ? state.document : {};
41
+ return parseIcp(JSON.stringify(root.icp ?? state.document));
42
+ }
43
+ export function readIcpSyncState(icpPath) {
44
+ const path = sidecarPathFor(icpPath);
45
+ if (!existsSync(path))
46
+ return null;
47
+ try {
48
+ const value = JSON.parse(readSecureRegularFile(path));
49
+ return value?.version === 1 ? value : null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ export function writeIcpSyncState(icpPath, state) {
56
+ const path = sidecarPathFor(icpPath);
57
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
58
+ writeSecureFileAtomic(path, `${JSON.stringify(state, null, 2)}\n`);
59
+ }
60
+ export async function getIcpSyncStatus(icpPath, domain) {
61
+ const local = readLocalIcp(icpPath);
62
+ const localHash = icpSha256(local);
63
+ const tracked = readIcpSyncState(icpPath);
64
+ const read = await readHostedArtifact("icp", artifactKeyForDomain(domain));
65
+ if (read.status === "unpaired")
66
+ return { local, status: { state: "unpaired", localIcpSha256: localHash } };
67
+ if (read.status === "missing")
68
+ return { local, status: { state: "missing_hosted", localIcpSha256: localHash } };
69
+ if (read.status === "unavailable")
70
+ return { local, status: { state: "unavailable", localIcpSha256: localHash, reason: read.reason } };
71
+ const hostedHash = icpSha256(extractHostedIcp(read.state));
72
+ const state = classifyIcpSync(localHash, read.state.revision, hostedHash, tracked);
73
+ return { local, hosted: read.state, status: { state, localIcpSha256: localHash, hostedRevision: read.state.revision, trackedRevision: tracked?.revision } };
74
+ }
75
+ export function markIcpSynced(icpPath, domain, hosted, icp) {
76
+ const key = artifactKeyForDomain(domain);
77
+ const normalizedDomain = key.slice("icp:".length);
78
+ writeIcpSyncState(icpPath, { version: 1, artifactId: hosted.artifactId, key, domain: normalizedDomain, revision: hosted.revision, localIcpSha256: icpSha256(icp), hostedDocumentSha256: hosted.documentSha256, syncedAt: new Date().toISOString() });
79
+ }
80
+ export async function pushIcp(icpPath, domain, changeSummary) {
81
+ const checked = await getIcpSyncStatus(icpPath, domain);
82
+ if (checked.status.state === "conflict" || checked.status.state === "hosted_changed" || checked.status.state === "untracked")
83
+ return { status: "conflict", checked };
84
+ const current = checked.hosted;
85
+ const root = current?.document && typeof current.document === "object" && !Array.isArray(current.document) ? current.document : {};
86
+ const key = artifactKeyForDomain(domain);
87
+ const normalizedDomain = key.slice("icp:".length);
88
+ const document = Object.keys(root).length ? { ...root, icp: checked.local, humanReview: { reviewedAt: new Date().toISOString(), source: "cli" } } : { company: { domain: normalizedDomain }, icp: checked.local, evidence: [], humanReview: { reviewedAt: new Date().toISOString(), source: "cli" } };
89
+ const result = await writeHostedArtifact({ kind: "icp", key, label: checked.local.name, domain: normalizedDomain, document, expectedRevision: current?.revision ?? 0, changeSummary });
90
+ if (result.status === "saved")
91
+ markIcpSynced(icpPath, domain, { artifactId: result.artifactId, revision: result.revision, documentSha256: result.documentSha256 }, checked.local);
92
+ return { status: result.status, result, checked };
93
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { auditSnapshot, defaultPolicy } from "./audit.ts";
2
2
  export { DEFAULT_ICP_DERIVATION_MODEL, OPENROUTER_API_BASE, deriveWebsiteIcp, normalizeCompanyWebsite, websiteText, icpReviewSegments, type WebsiteIcpDerivation, type WebsiteIcpEvidence, type IcpReviewSegment, type IcpDerivationProgress, } from "./icpDerive.ts";
3
+ export { artifactKeyForDomain, classifyIcpSync, extractHostedIcp, getIcpSyncStatus, icpSha256, markIcpSynced, pushIcp, readIcpSyncState, sidecarPathFor, type IcpSyncState, type IcpSyncStatus, } from "./icpSync.ts";
3
4
  export { acquireCheckpointId, acquireCheckpointsDir, createFileAcquireCheckpointStore, validateAcquireCheckpoint, validateAcquireCheckpointKey, type AcquireCheckpoint, type AcquireCheckpointKey, type AcquireCheckpointStore, type AcquireContinuation, } from "./acquireCheckpoint.ts";
4
5
  export { matchesTerritory, parseAssignmentPolicy, resolveAssignment, type AssignmentContext, type AssignmentPolicy, type AssignmentResult, type AssignmentStrategy, type TerritoryRule, } from "./assign.ts";
5
6
  export { buildBulkUpdatePlan, isFilterableField, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { auditSnapshot, defaultPolicy } from "./audit.js";
2
2
  export { DEFAULT_ICP_DERIVATION_MODEL, OPENROUTER_API_BASE, deriveWebsiteIcp, normalizeCompanyWebsite, websiteText, icpReviewSegments, } from "./icpDerive.js";
3
+ export { artifactKeyForDomain, classifyIcpSync, extractHostedIcp, getIcpSyncStatus, icpSha256, markIcpSynced, pushIcp, readIcpSyncState, sidecarPathFor, } from "./icpSync.js";
3
4
  export { acquireCheckpointId, acquireCheckpointsDir, createFileAcquireCheckpointStore, validateAcquireCheckpoint, validateAcquireCheckpointKey, } from "./acquireCheckpoint.js";
4
5
  export { matchesTerritory, parseAssignmentPolicy, resolveAssignment, } from "./assign.js";
5
6
  export { buildBulkUpdatePlan, isFilterableField, parseWhere } from "./bulkUpdate.js";
@@ -26,7 +26,7 @@ export function normalizeClayPerson(value) {
26
26
  companyName: stringValue(row.latest_experience_company),
27
27
  companyDomain: bareDomain(stringValue(row.domain)),
28
28
  linkedin: normalizeLinkedin(stringValue(row.url)),
29
- email: stringValue(row.email),
29
+ email: stringValue(row.work_email) ?? stringValue(row.email) ?? stringValue(row.personal_email),
30
30
  sourceId: normalizeLinkedin(stringValue(row.url)) ?? fullName,
31
31
  location: {
32
32
  city: stringValue(location.city),
@@ -103,6 +103,11 @@ replays automatically: reconcile provider state, then `plans recover ...
103
103
  reconciliation. Review documents are immutable/hash-bound; hosted approval
104
104
  becomes executable only after the CLI verifies it and generates local HMAC
105
105
  signatures. Hosted never owns the apply lease for CLI-origin plans.
106
+ - `hostedArtifacts.ts` + `icpSync.ts` — version-aware artifact transport and
107
+ conservative bidirectional ICP reconciliation. Published revisions use
108
+ compare-and-swap; a private sidecar binds a local ICP hash to its last hosted
109
+ revision. Divergent arrays are never auto-merged and hosted changes never
110
+ silently replace the execution-local ICP file.
106
111
  - `assign.ts` — `AssignmentPolicy` (fixed / round-robin / territory /
107
112
  account-owner): the pure owner-routing rule `buildAcquirePlan` stamps onto new
108
113
  leads (never born ownerless) and `reassign --assign-unowned` reuses to backfill.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.56.0",
3
+ "version": "0.57.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
package/src/cli/enrich.ts CHANGED
@@ -28,6 +28,8 @@ import { unknownSubcommandError } from "./suggest.ts";
28
28
  import { box, colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, scoreColor, truncateToWidth, type Paint } from "./ui.ts";
29
29
  import { compactPlan, verbosePlanRequested } from "./planOutput.ts";
30
30
  import type { AcquireBudget } from "../acquireMeter.ts";
31
+ import { writeHostedArtifact } from "../hostedArtifacts.ts";
32
+ import { readIcpSyncState } from "../icpSync.ts";
31
33
 
32
34
 
33
35
  /**
@@ -302,6 +304,16 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
302
304
 
303
305
  const meterLine = formatAcquireMeter(headroom, costPerRecord);
304
306
  const gaugeLine = acquireGaugeLine(headroom, config.acquire.budget ?? {}, paint(colorEnabled(process.stdout)));
307
+ const icpPath = resolve(process.cwd(), option(rest, "--icp") ?? "icp.json");
308
+ const trackedIcp = existsSync(icpPath) ? readIcpSyncState(icpPath) : null;
309
+ const leadMirror = await writeHostedArtifact({
310
+ kind: "lead_run", key: `leads:${result.plan.id}`, label: result.plan.title ?? result.plan.id,
311
+ domain: trackedIcp?.domain,
312
+ document: { runLabel: result.plan.id, createdAt: new Date().toISOString(), source, counts: result.counts,
313
+ plan: result.plan, icpRef: trackedIcp ? { artifactId: trackedIcp.artifactId, domain: trackedIcp.domain, revision: trackedIcp.revision, localIcpSha256: trackedIcp.localIcpSha256 } : undefined },
314
+ });
315
+ if (leadMirror.status === "saved") console.error(`Recorded lead preview against${trackedIcp ? ` ICP revision ${trackedIcp.revision}` : " the local untracked ICP"}.`);
316
+ else if (leadMirror.status === "unavailable") console.error(`Warning: ${leadMirror.reason}. The local lead preview is unchanged.`);
305
317
  if (!save) {
306
318
  printAcquireOutput({
307
319
  args: rest,
package/src/cli/help.ts CHANGED
@@ -701,14 +701,14 @@ export const COMMAND_FLAGS: Record<string, string[]> = {
701
701
  merge: ["--input", "--out", "--json"],
702
702
  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"],
703
703
  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"],
704
- icp: [...SOURCE_FLAGS, "--name", "--icp", "--domain", "--no-interactive", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--save", "--verbose", "--json", "--out"],
704
+ icp: [...SOURCE_FLAGS, "--name", "--icp", "--domain", "--no-interactive", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--change-summary", "--force", "--save", "--verbose", "--json", "--out"],
705
705
  signals: ["--bucket", "--source", "--connector", "--connector-opt", "--watchlist", "--keywords", "--from", "--label", "--since", "--icp", "--max-accounts", "--max-results", "--max-searches", "--max-usd", "--account", "--contact", "--unjudged", "--touch", "--result", "--save", "--verbose", "--json", "--explain"],
706
706
  draft: ["--from-judge", "--min-score", "--prompt", "--model", "--channel", "--save", "--dry-run", "--verbose", "--json", "--out"],
707
707
  schedule: ["--cron", "--label", "--provider", "--trigger", "--timer", "--runs", "--verbose", "--json"],
708
708
  };
709
709
 
710
710
  export const FLAGS_WITH_VALUES = new Set([
711
- "--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", "--company-domain", "--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-accounts", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--max-results", "--max-searches", "--max-usd", "--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",
711
+ "--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", "--change-summary", "--channel", "--client", "--client-id", "--client-secret", "--company-domain", "--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-accounts", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--max-results", "--max-searches", "--max-usd", "--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",
712
712
  ]);
713
713
 
714
714
  // Lifecycle-grouped front door. One line per verb, organized by the
package/src/cli/icp.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
2
2
 
3
- import { readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { emitKeypressEvents } from "node:readline";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { resolve } from "node:path";
@@ -9,14 +9,16 @@ import { fitThreshold, icpFromAnswers, icpToCrustdataFilters, icpToExploriumFilt
9
9
  import { createFileSignalStore, DEFAULT_SIGNALS_CONFIG, type SignalsConfig } from "../signals.ts";
10
10
  import { createFileJudgeStore, DEFAULT_JUDGE_PROMPT, judgeRunId, judgeSignals } from "../judge.ts";
11
11
  import { DEFAULT_GOLDEN_NOW_ISO, DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet } from "../judgeEval.ts";
12
- import { loadIcp, numericOption, option, readSecret, readSnapshot, resolveLlmBaseUrls, saveRequested } from "./shared.ts";
12
+ import { loadIcp, numericOption, option, readPackageInfo, readSecret, readSnapshot, resolveLlmBaseUrls, saveRequested } from "./shared.ts";
13
13
  import { createStatusLine } from "./ui.ts";
14
14
  import { box, colorEnabled, paint, truncateToWidth } from "./ui.ts";
15
15
  import { getCredential, storeCredential } from "../credentials.ts";
16
16
  import { deriveWebsiteIcp, icpReviewSegments, OPENROUTER_API_BASE, type WebsiteIcpDerivation } from "../icpDerive.ts";
17
17
  import type { Icp } from "../icp.ts";
18
18
  import { unknownSubcommandError } from "./suggest.ts";
19
- import { writeHostedArtifact } from "../hostedArtifacts.ts";
19
+ import { readHostedArtifact, writeHostedArtifact } from "../hostedArtifacts.ts";
20
+ import { artifactKeyForDomain, extractHostedIcp, getIcpSyncStatus, markIcpSynced, pushIcp, readIcpSyncState } from "../icpSync.ts";
21
+ import { writeSecureFileAtomic } from "../secureFile.ts";
20
22
 
21
23
  function renderJudgeDecisions(decisions: Awaited<ReturnType<typeof judgeSignals>>): string {
22
24
  if (decisions.length === 0) return "No accounts cleared the score threshold.";
@@ -178,6 +180,14 @@ export async function icpCommand(args: string[]) {
178
180
  fullstackgtm icp interview emit the interview spec (an agent drives it with AskUserQuestion)
179
181
  fullstackgtm icp derive --domain <site> [--model <id>] [--out icp.json] [--json]
180
182
  derive an evidence-backed ICP from a public website (OpenRouter/OpenAI/Anthropic)
183
+ fullstackgtm icp status --domain <site> [--icp icp.json] [--json]
184
+ compare local and hosted revisions without changing either
185
+ fullstackgtm icp pull --domain <site> [--out icp.json] [--force] [--json]
186
+ explicitly download hosted canonical ICP; refuses to replace an existing file without --force
187
+ fullstackgtm icp push --domain <site> [--icp icp.json] [--change-summary <text>] [--json]
188
+ publish the local ICP with revision conflict protection
189
+ fullstackgtm icp sync --domain <site> [--icp icp.json] [--json]
190
+ reconcile safe one-sided changes; hosted changes become a sibling review file
181
191
  fullstackgtm icp set <answers.json> [--name <n>] [--out <path>] write icp.json from interview answers
182
192
  fullstackgtm icp show [--icp <path>] render the ICP + the discovery filters it produces
183
193
  fullstackgtm icp judge [--signals-from latest|<label>] [--with-history] [--prompt <path>] [--min-score 0] [--save] rank unjudged signals into send/nurture/skip decisions (timing x fit x memory)
@@ -235,14 +245,79 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
235
245
  }
236
246
  const mirrored = await writeHostedArtifact({
237
247
  kind: "icp", key: `icp:${derived.company.domain}`, label: derived.icp.name,
238
- domain: derived.company.domain, document: derived,
248
+ domain: derived.company.domain, document: derived, sourceVersion: readPackageInfo().version,
239
249
  });
240
- if (mirrored.status === "saved") console.error("Mirrored the reviewed ICP to the paired hosted workspace.");
250
+ if (mirrored.status === "saved") {
251
+ if (out) markIcpSynced(resolve(process.cwd(), out), derived.company.domain, mirrored, derived.icp);
252
+ console.error(`Mirrored the reviewed ICP to the paired hosted workspace at revision ${mirrored.revision}.`);
253
+ }
254
+ else if (mirrored.status === "conflict") console.error(`Warning: ${mirrored.reason}. Run \`fullstackgtm icp sync --domain ${derived.company.domain}${out ? ` --icp ${out}` : ""}\` to reconcile.`);
241
255
  else if (mirrored.status === "unavailable") console.error(`Warning: ${mirrored.reason}. The local ICP is still authoritative.`);
242
256
  if (rest.includes("--json")) console.log(JSON.stringify(derived, null, 2));
243
257
  else if (!reviewedInteractively) console.log(renderDerivedIcp(derived));
244
258
  return;
245
259
  }
260
+ if (["status", "pull", "push", "sync"].includes(sub)) {
261
+ const domain = option(rest, "--domain");
262
+ if (!domain) throw new Error(`icp ${sub}: --domain <company.com> is required.`);
263
+ const asJson = rest.includes("--json");
264
+ const icpPath = resolve(process.cwd(), option(rest, "--icp") ?? option(rest, "--out") ?? "icp.json");
265
+
266
+ if (sub === "status") {
267
+ if (!existsSync(icpPath)) throw new Error(`icp status: local ICP not found at ${icpPath}.`);
268
+ const checked = await getIcpSyncStatus(icpPath, domain);
269
+ if (asJson) console.log(JSON.stringify({ path: icpPath, domain, ...checked.status }, null, 2));
270
+ else console.log(`ICP ${checked.status.state.replaceAll("_", " ")} · local ${checked.status.localIcpSha256.slice(0, 12)}${checked.status.hostedRevision ? ` · hosted r${checked.status.hostedRevision}` : ""}${checked.status.trackedRevision ? ` · last synced r${checked.status.trackedRevision}` : ""}`);
271
+ return;
272
+ }
273
+
274
+ if (sub === "pull") {
275
+ const out = resolve(process.cwd(), option(rest, "--out") ?? "icp.json");
276
+ const hosted = await readHostedArtifact("icp", artifactKeyForDomain(domain));
277
+ if (hosted.status !== "found") throw new Error(hosted.status === "missing" ? `No hosted ICP exists for ${domain}.` : hosted.status === "unpaired" ? "This CLI is not paired with a hosted workspace." : hosted.reason);
278
+ const icp = extractHostedIcp(hosted.state);
279
+ if (existsSync(out) && !rest.includes("--force")) throw new Error(`Refusing to replace ${out}. Re-run with --force after reviewing hosted revision ${hosted.state.revision}.`);
280
+ writeSecureFileAtomic(out, `${JSON.stringify(icp, null, 2)}\n`);
281
+ markIcpSynced(out, domain, hosted.state, icp);
282
+ if (asJson) console.log(JSON.stringify({ status: "pulled", path: out, revision: hosted.state.revision, documentSha256: hosted.state.documentSha256 }, null, 2));
283
+ else console.log(`Pulled hosted ICP revision ${hosted.state.revision} to ${out}.`);
284
+ return;
285
+ }
286
+
287
+ if (!existsSync(icpPath)) throw new Error(`icp ${sub}: local ICP not found at ${icpPath}.`);
288
+ if (sub === "push") {
289
+ const pushed = await pushIcp(icpPath, domain, option(rest, "--change-summary") ?? undefined);
290
+ if (pushed.status === "conflict") throw new Error(`ICP conflict: hosted revision ${pushed.checked.status.hostedRevision ?? "?"} changed since local revision ${pushed.checked.status.trackedRevision ?? "untracked"}. Run \`icp sync\` to write a review copy.`);
291
+ if (pushed.result.status !== "saved") throw new Error(pushed.result.status === "unpaired" ? "This CLI is not paired with a hosted workspace." : pushed.result.status === "conflict" ? pushed.result.reason : pushed.result.reason);
292
+ if (asJson) console.log(JSON.stringify({ status: "pushed", revision: pushed.result.revision, documentSha256: pushed.result.documentSha256, unchanged: pushed.result.unchanged }, null, 2));
293
+ else console.log(`${pushed.result.unchanged ? "Already at" : "Published"} hosted ICP revision ${pushed.result.revision}.`);
294
+ return;
295
+ }
296
+
297
+ const checked = await getIcpSyncStatus(icpPath, domain);
298
+ if (checked.status.state === "local_changed" || checked.status.state === "missing_hosted") {
299
+ const pushed = await pushIcp(icpPath, domain, "Synchronized local ICP");
300
+ if (pushed.result?.status !== "saved") throw new Error("ICP sync could not publish the local change.");
301
+ if (asJson) console.log(JSON.stringify({ status: "pushed", revision: pushed.result.revision }, null, 2));
302
+ else console.log(`Published local ICP as hosted revision ${pushed.result.revision}.`);
303
+ return;
304
+ }
305
+ if ((checked.status.state === "hosted_changed" || checked.status.state === "conflict" || checked.status.state === "untracked") && checked.hosted) {
306
+ const reviewPath = `${icpPath}.hosted-r${checked.hosted.revision}.json`;
307
+ writeSecureFileAtomic(reviewPath, `${JSON.stringify(extractHostedIcp(checked.hosted), null, 2)}\n`);
308
+ const result = { status: checked.status.state, reviewPath, hostedRevision: checked.hosted.revision, trackedRevision: readIcpSyncState(icpPath)?.revision };
309
+ if (asJson) console.log(JSON.stringify(result, null, 2));
310
+ else console.log(`Hosted changes need review. Wrote revision ${checked.hosted.revision} to ${reviewPath}; your local ICP was not replaced.`);
311
+ return;
312
+ }
313
+ if (checked.status.state === "in_sync") {
314
+ if (!readIcpSyncState(icpPath) && checked.hosted) markIcpSynced(icpPath, domain, checked.hosted, checked.local);
315
+ if (asJson) console.log(JSON.stringify({ status: "in_sync", revision: checked.status.hostedRevision }, null, 2));
316
+ else console.log(`ICP is in sync${checked.status.hostedRevision ? ` at revision ${checked.status.hostedRevision}` : ""}.`);
317
+ return;
318
+ }
319
+ throw new Error(checked.status.reason ?? `ICP sync unavailable (${checked.status.state}).`);
320
+ }
246
321
  if (sub === "set") {
247
322
  const file = rest.find((a) => !a.startsWith("--"));
248
323
  if (!file) throw new Error("Usage: fullstackgtm icp set <answers.json> [--name <n>] [--out <path>]");
@@ -12,6 +12,7 @@ import { loadIcp, option, readSnapshot, repeatedOption, saveRequested } from "./
12
12
  import { createStatusLine } from "./ui.ts";
13
13
  import { unknownSubcommandError } from "./suggest.ts";
14
14
  import { writeHostedArtifact } from "../hostedArtifacts.ts";
15
+ import { readIcpSyncState } from "../icpSync.ts";
15
16
 
16
17
 
17
18
  /**
@@ -178,7 +179,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
178
179
  await store.appendRun({ id: signalRunId(runLabel), runLabel, startedAt: now.toISOString(), completedAt: new Date().toISOString(),
179
180
  buckets: ["job"], counts: { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, signals: ranked });
180
181
  console.error(`Saved signal run "${runLabel}". Next: \`fullstackgtm icp judge --signals-from ${runLabel} --save\`.`);
181
- await mirrorSignalRun(runLabel, now, ["job"], { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, ranked);
182
+ await mirrorSignalRun(runLabel, now, ["job"], { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, ranked, rest);
182
183
  } else {
183
184
  console.error("(not saved — re-run with --save to persist this evidence to the signal ledger)");
184
185
  }
@@ -317,7 +318,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
317
318
  signals: ranked,
318
319
  });
319
320
  console.error(`Saved signal run "${runLabel}" (${fresh.length} fresh). Next: \`fullstackgtm icp judge --save\`.`);
320
- await mirrorSignalRun(runLabel, now, buckets, { fetched, new: fresh.length, deduped: deduped.length }, ranked);
321
+ await mirrorSignalRun(runLabel, now, buckets, { fetched, new: fresh.length, deduped: deduped.length }, ranked, rest);
321
322
  } else {
322
323
  console.error("(not saved — re-run with --save to persist this run to the signal ledger)");
323
324
  }
@@ -412,10 +413,14 @@ async function mirrorSignalRun(
412
413
  buckets: SignalBucket[],
413
414
  counts: { fetched: number; new: number; deduped: number },
414
415
  signals: Signal[],
416
+ args: string[],
415
417
  ) {
418
+ const icpPath = resolve(process.cwd(), option(args, "--icp") ?? "icp.json");
419
+ const tracked = existsSync(icpPath) ? readIcpSyncState(icpPath) : null;
416
420
  const mirrored = await writeHostedArtifact({
417
421
  kind: "signal_run", key: `signals:${signalRunId(runLabel)}`, label: runLabel,
418
- document: { runLabel, startedAt: startedAt.toISOString(), completedAt: new Date().toISOString(), buckets, counts, signals },
422
+ document: { runLabel, startedAt: startedAt.toISOString(), completedAt: new Date().toISOString(), buckets, counts, signals,
423
+ icpRef: tracked ? { artifactId: tracked.artifactId, domain: tracked.domain, revision: tracked.revision, localIcpSha256: tracked.localIcpSha256 } : undefined },
419
424
  });
420
425
  if (mirrored.status === "saved") console.error("Mirrored the signal run to the paired hosted workspace.");
421
426
  else if (mirrored.status === "unavailable") console.error(`Warning: ${mirrored.reason}. The local signal ledger is still authoritative.`);
@@ -1,21 +1,38 @@
1
- /** Best-effort artifact mirroring for a human-paired CLI profile. */
1
+ /** Version-aware hosted artifact transport for a human-paired CLI profile. */
2
2
  import { getCredential } from "./credentials.ts";
3
3
 
4
4
  const ENDPOINT = "/api/cli/artifact";
5
5
  const DEFAULT_TIMEOUT_MS = 4000;
6
6
 
7
+ export type HostedArtifactKind = "icp" | "signal_run" | "lead_run";
7
8
  export type HostedArtifact = {
8
- kind: "icp" | "signal_run";
9
+ kind: HostedArtifactKind;
9
10
  key: string;
10
11
  label: string;
11
12
  domain?: string;
12
13
  document: unknown;
13
14
  sourceVersion?: string;
15
+ expectedRevision?: number;
16
+ changeSummary?: string;
17
+ };
18
+ export type HostedArtifactState = HostedArtifact & {
19
+ artifactId: string;
20
+ revision: number;
21
+ documentSha256?: string;
22
+ origin: "cli" | "hosted";
23
+ updatedAt: number;
14
24
  };
15
25
 
16
26
  export type HostedArtifactResult =
17
27
  | { status: "unpaired" }
18
- | { status: "saved"; created: boolean; updatedAt: number }
28
+ | { status: "saved"; artifactId: string; created: boolean; updatedAt: number; revision: number; documentSha256: string; unchanged: boolean }
29
+ | { status: "conflict"; reason: string; currentRevision?: number; documentSha256?: string }
30
+ | { status: "unavailable"; reason: string };
31
+
32
+ export type HostedArtifactReadResult =
33
+ | { status: "unpaired" }
34
+ | { status: "found"; state: HostedArtifactState }
35
+ | { status: "missing" }
19
36
  | { status: "unavailable"; reason: string };
20
37
 
21
38
  function broker(): { baseUrl: string; accessToken: string } | null {
@@ -29,29 +46,41 @@ function broker(): { baseUrl: string; accessToken: string } | null {
29
46
  } catch { return null; }
30
47
  }
31
48
 
32
- export async function writeHostedArtifact(
33
- artifact: HostedArtifact,
34
- options: { fetchImpl?: typeof fetch; timeoutMs?: number } = {},
35
- ): Promise<HostedArtifactResult> {
36
- if (!artifact.key || artifact.key.length > 256 || !artifact.label || artifact.label.length > 200) {
37
- throw new Error("Hosted artifact key/label is invalid.");
38
- }
49
+ function requestOptions(accessToken: string, timeoutMs: number): Pick<RequestInit, "headers" | "signal"> {
50
+ return { headers: { Authorization: `Bearer ${accessToken}` }, signal: AbortSignal.timeout(timeoutMs) };
51
+ }
52
+
53
+ export async function readHostedArtifact(kind: HostedArtifactKind, key: string, options: { fetchImpl?: typeof fetch; timeoutMs?: number } = {}): Promise<HostedArtifactReadResult> {
54
+ const paired = broker();
55
+ if (!paired) return { status: "unpaired" };
56
+ try {
57
+ const url = new URL(`${paired.baseUrl}${ENDPOINT}`);
58
+ url.searchParams.set("kind", kind); url.searchParams.set("key", key);
59
+ const response = await (options.fetchImpl ?? fetch)(url, requestOptions(paired.accessToken, options.timeoutMs ?? DEFAULT_TIMEOUT_MS));
60
+ if (response.status === 404) return { status: "missing" };
61
+ if (!response.ok) return { status: "unavailable", reason: `hosted artifact request failed (HTTP ${response.status})` };
62
+ const state = await response.json() as HostedArtifactState;
63
+ if (!state.artifactId || !Number.isSafeInteger(state.revision) || !state.document) return { status: "unavailable", reason: "hosted artifact response was invalid" };
64
+ return { status: "found", state };
65
+ } catch { return { status: "unavailable", reason: "hosted artifact request was unavailable" }; }
66
+ }
67
+
68
+ export async function writeHostedArtifact(artifact: HostedArtifact, options: { fetchImpl?: typeof fetch; timeoutMs?: number } = {}): Promise<HostedArtifactResult> {
69
+ if (!artifact.key || artifact.key.length > 256 || !artifact.label || artifact.label.length > 200) throw new Error("Hosted artifact key/label is invalid.");
39
70
  const paired = broker();
40
71
  if (!paired) return { status: "unpaired" };
41
72
  try {
42
73
  const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}`, {
43
- method: "POST",
44
- headers: { Authorization: `Bearer ${paired.accessToken}`, "Content-Type": "application/json" },
45
- body: JSON.stringify(artifact),
46
- signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
74
+ method: "POST", ...requestOptions(paired.accessToken, options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
75
+ headers: { Authorization: `Bearer ${paired.accessToken}`, "Content-Type": "application/json" }, body: JSON.stringify(artifact),
47
76
  });
48
- if (!response.ok) return { status: "unavailable", reason: `hosted artifact request failed (HTTP ${response.status})` };
49
- const body = await response.json() as { created?: unknown; updatedAt?: unknown };
50
- if (typeof body.created !== "boolean" || typeof body.updatedAt !== "number") {
51
- return { status: "unavailable", reason: "hosted artifact response was invalid" };
77
+ if (response.status === 409) {
78
+ const body = await response.json() as { currentRevision?: number; documentSha256?: string };
79
+ return { status: "conflict", reason: "hosted ICP changed since the last sync", currentRevision: body.currentRevision, documentSha256: body.documentSha256 };
52
80
  }
53
- return { status: "saved", created: body.created, updatedAt: body.updatedAt };
54
- } catch {
55
- return { status: "unavailable", reason: "hosted artifact request was unavailable" };
56
- }
81
+ if (!response.ok) return { status: "unavailable", reason: `hosted artifact request failed (HTTP ${response.status})` };
82
+ const body = await response.json() as { artifactId?: unknown; created?: unknown; updatedAt?: unknown; revision?: unknown; documentSha256?: unknown; unchanged?: unknown };
83
+ if (typeof body.artifactId !== "string" || typeof body.created !== "boolean" || typeof body.updatedAt !== "number" || typeof body.revision !== "number" || typeof body.documentSha256 !== "string") return { status: "unavailable", reason: "hosted artifact response was invalid" };
84
+ return { status: "saved", artifactId: body.artifactId, created: body.created, updatedAt: body.updatedAt, revision: body.revision, documentSha256: body.documentSha256, unchanged: body.unchanged === true };
85
+ } catch { return { status: "unavailable", reason: "hosted artifact request was unavailable" }; }
57
86
  }
package/src/icp.ts CHANGED
@@ -73,6 +73,9 @@ export type Icp = {
73
73
  scoring?: {
74
74
  /** minimum fit (0..1) for a prospect to become a create_record op. Default 0.5. */
75
75
  threshold?: number;
76
+ /** Require a literal persona.titleKeywords phrase in title/headline. This
77
+ * disables the broader function fallback for high-precision sourcing. */
78
+ requireTitleKeyword?: boolean;
76
79
  };
77
80
  };
78
81
 
@@ -456,17 +459,20 @@ export function scoreProspectAgainstIcp(
456
459
  const keywords = (icp.persona.titleKeywords ?? []).map((k) => k.toLowerCase());
457
460
  const levels = (icp.persona.jobLevels ?? []).map((l) => l.toLowerCase());
458
461
  const depts = (icp.persona.departments ?? []).map((d) => d.toLowerCase());
462
+ const exactTitleKeyword = keywords.find((keyword) => title.includes(keyword));
463
+ if (icp.scoring?.requireTitleKeyword && keywords.length && !exactTitleKeyword) {
464
+ return { score: 0, reasons: ["title does not contain a required ICP keyword"] };
465
+ }
459
466
 
460
467
  let score = 0;
461
468
  let weightSum = 0;
462
469
 
463
470
  if (keywords.length) {
464
471
  weightSum += 0.6;
465
- const exact = keywords.find((k) => title.includes(k));
466
- const functional = exact ? undefined : roleKeywords(icp).find((keyword) => title.includes(keyword));
467
- if (exact) {
472
+ const functional = exactTitleKeyword ? undefined : roleKeywords(icp).find((keyword) => title.includes(keyword));
473
+ if (exactTitleKeyword) {
468
474
  score += 0.6;
469
- reasons.push(`title matches ICP keyword "${exact}"`);
475
+ reasons.push(`title matches ICP keyword "${exactTitleKeyword}"`);
470
476
  } else if (functional) {
471
477
  score += 0.45;
472
478
  reasons.push(`title matches ICP function "${functional}"`);
package/src/icpSync.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { parseIcp, type Icp } from "./icp.ts";
5
+ import { readSecureRegularFile, writeSecureFileAtomic } from "./secureFile.ts";
6
+ import { readHostedArtifact, writeHostedArtifact, type HostedArtifactState } from "./hostedArtifacts.ts";
7
+
8
+ export type IcpSyncState = { version: 1; artifactId: string; key: string; domain: string; revision: number; localIcpSha256: string; hostedDocumentSha256?: string; syncedAt: string };
9
+ export type IcpSyncStatus = { state: "unpaired" | "missing_hosted" | "in_sync" | "local_changed" | "hosted_changed" | "conflict" | "untracked" | "unavailable"; localIcpSha256: string; hostedRevision?: number; trackedRevision?: number; reason?: string };
10
+
11
+ export function classifyIcpSync(localHash: string, hostedRevision: number, hostedHash: string, tracked: IcpSyncState | null): IcpSyncStatus["state"] {
12
+ if (!tracked) return localHash === hostedHash ? "in_sync" : "untracked";
13
+ const localChanged = localHash !== tracked.localIcpSha256;
14
+ const hostedChanged = hostedRevision !== tracked.revision;
15
+ return localChanged && hostedChanged ? "conflict" : localChanged ? "local_changed" : hostedChanged ? "hosted_changed" : "in_sync";
16
+ }
17
+
18
+ export function canonicalJson(value: unknown): string {
19
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
20
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
21
+ const object = value as Record<string, unknown>;
22
+ return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`;
23
+ }
24
+ export const icpSha256 = (icp: Icp) => createHash("sha256").update(canonicalJson(icp)).digest("hex");
25
+ export function artifactKeyForDomain(domain: string): string {
26
+ const candidate = /^https?:\/\//i.test(domain.trim()) ? domain.trim() : `https://${domain.trim()}`;
27
+ let parsed: URL; try { parsed = new URL(candidate); } catch { throw new Error(`Invalid company domain: ${domain}`); }
28
+ const hostname = parsed.hostname.toLowerCase().replace(/^www\./, "").replace(/\.$/, "");
29
+ if (!hostname.includes(".")) throw new Error(`Invalid public company domain: ${domain}`);
30
+ return `icp:${hostname}`;
31
+ }
32
+ export const sidecarPathFor = (icpPath: string) => resolve(dirname(resolve(icpPath)), ".fullstackgtm", `${resolve(icpPath).split(/[\\/]/).pop()}.sync.json`);
33
+
34
+ export function readLocalIcp(path: string): Icp { return parseIcp(readSecureRegularFile(resolve(path))); }
35
+ export function extractHostedIcp(state: HostedArtifactState): Icp {
36
+ const root = state.document && typeof state.document === "object" && !Array.isArray(state.document) ? state.document as Record<string, unknown> : {};
37
+ return parseIcp(JSON.stringify(root.icp ?? state.document));
38
+ }
39
+ export function readIcpSyncState(icpPath: string): IcpSyncState | null {
40
+ const path = sidecarPathFor(icpPath); if (!existsSync(path)) return null;
41
+ try { const value = JSON.parse(readSecureRegularFile(path)) as IcpSyncState; return value?.version === 1 ? value : null; } catch { return null; }
42
+ }
43
+ export function writeIcpSyncState(icpPath: string, state: IcpSyncState): void {
44
+ const path = sidecarPathFor(icpPath); mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
45
+ writeSecureFileAtomic(path, `${JSON.stringify(state, null, 2)}\n`);
46
+ }
47
+
48
+ export async function getIcpSyncStatus(icpPath: string, domain: string): Promise<{ status: IcpSyncStatus; hosted?: HostedArtifactState; local: Icp }> {
49
+ const local = readLocalIcp(icpPath); const localHash = icpSha256(local); const tracked = readIcpSyncState(icpPath);
50
+ const read = await readHostedArtifact("icp", artifactKeyForDomain(domain));
51
+ if (read.status === "unpaired") return { local, status: { state: "unpaired", localIcpSha256: localHash } };
52
+ if (read.status === "missing") return { local, status: { state: "missing_hosted", localIcpSha256: localHash } };
53
+ if (read.status === "unavailable") return { local, status: { state: "unavailable", localIcpSha256: localHash, reason: read.reason } };
54
+ const hostedHash = icpSha256(extractHostedIcp(read.state));
55
+ const state = classifyIcpSync(localHash, read.state.revision, hostedHash, tracked);
56
+ return { local, hosted: read.state, status: { state, localIcpSha256: localHash, hostedRevision: read.state.revision, trackedRevision: tracked?.revision } };
57
+ }
58
+
59
+ export function markIcpSynced(icpPath: string, domain: string, hosted: HostedArtifactState | { artifactId: string; revision: number; documentSha256?: string }, icp: Icp): void {
60
+ const key = artifactKeyForDomain(domain); const normalizedDomain = key.slice("icp:".length);
61
+ writeIcpSyncState(icpPath, { version: 1, artifactId: hosted.artifactId, key, domain: normalizedDomain, revision: hosted.revision, localIcpSha256: icpSha256(icp), hostedDocumentSha256: hosted.documentSha256, syncedAt: new Date().toISOString() });
62
+ }
63
+
64
+ export async function pushIcp(icpPath: string, domain: string, changeSummary?: string) {
65
+ const checked = await getIcpSyncStatus(icpPath, domain);
66
+ if (checked.status.state === "conflict" || checked.status.state === "hosted_changed" || checked.status.state === "untracked") return { status: "conflict" as const, checked };
67
+ const current = checked.hosted;
68
+ const root = current?.document && typeof current.document === "object" && !Array.isArray(current.document) ? current.document as Record<string, unknown> : {};
69
+ const key = artifactKeyForDomain(domain); const normalizedDomain = key.slice("icp:".length);
70
+ const document = Object.keys(root).length ? { ...root, icp: checked.local, humanReview: { reviewedAt: new Date().toISOString(), source: "cli" } } : { company: { domain: normalizedDomain }, icp: checked.local, evidence: [], humanReview: { reviewedAt: new Date().toISOString(), source: "cli" } };
71
+ const result = await writeHostedArtifact({ kind: "icp", key, label: checked.local.name, domain: normalizedDomain, document, expectedRevision: current?.revision ?? 0, changeSummary });
72
+ if (result.status === "saved") markIcpSynced(icpPath, domain, { artifactId: result.artifactId, revision: result.revision, documentSha256: result.documentSha256 }, checked.local);
73
+ return { status: result.status, result, checked };
74
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,19 @@ export {
11
11
  type IcpReviewSegment,
12
12
  type IcpDerivationProgress,
13
13
  } from "./icpDerive.ts";
14
+ export {
15
+ artifactKeyForDomain,
16
+ classifyIcpSync,
17
+ extractHostedIcp,
18
+ getIcpSyncStatus,
19
+ icpSha256,
20
+ markIcpSynced,
21
+ pushIcp,
22
+ readIcpSyncState,
23
+ sidecarPathFor,
24
+ type IcpSyncState,
25
+ type IcpSyncStatus,
26
+ } from "./icpSync.ts";
14
27
 
15
28
  export {
16
29
  acquireCheckpointId,
@@ -40,7 +40,7 @@ export function normalizeClayPerson(value: unknown): Prospect {
40
40
  companyName: stringValue(row.latest_experience_company),
41
41
  companyDomain: bareDomain(stringValue(row.domain)),
42
42
  linkedin: normalizeLinkedin(stringValue(row.url)),
43
- email: stringValue(row.email),
43
+ email: stringValue(row.work_email) ?? stringValue(row.email) ?? stringValue(row.personal_email),
44
44
  sourceId: normalizeLinkedin(stringValue(row.url)) ?? fullName,
45
45
  location: {
46
46
  city: stringValue(location.city),