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 +30 -0
- package/README.md +17 -0
- package/dist/cli/enrich.js +14 -0
- package/dist/cli/help.js +2 -2
- package/dist/cli/icp.js +99 -6
- package/dist/cli/signals.js +8 -4
- package/dist/hostedArtifacts.d.ts +35 -1
- package/dist/hostedArtifacts.js +35 -10
- package/dist/icp.d.ts +3 -0
- package/dist/icp.js +7 -4
- package/dist/icpSync.d.ts +55 -0
- package/dist/icpSync.js +93 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/portable/clay.js +1 -1
- package/docs/architecture.md +5 -0
- package/package.json +1 -1
- package/src/cli/enrich.ts +12 -0
- package/src/cli/help.ts +2 -2
- package/src/cli/icp.ts +80 -5
- package/src/cli/signals.ts +8 -3
- package/src/hostedArtifacts.ts +51 -22
- package/src/icp.ts +10 -4
- package/src/icpSync.ts +74 -0
- package/src/index.ts +13 -0
- package/src/portable/clay.ts +1 -1
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.
|
package/dist/cli/enrich.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/cli/signals.js
CHANGED
|
@@ -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:
|
|
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;
|
package/dist/hostedArtifacts.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
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
|
|
389
|
-
|
|
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 "${
|
|
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
|
+
}>;
|
package/dist/icpSync.js
ADDED
|
@@ -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";
|
package/dist/portable/clay.js
CHANGED
|
@@ -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),
|
package/docs/architecture.md
CHANGED
|
@@ -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.
|
|
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")
|
|
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>]");
|
package/src/cli/signals.ts
CHANGED
|
@@ -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.`);
|
package/src/hostedArtifacts.ts
CHANGED
|
@@ -1,21 +1,38 @@
|
|
|
1
|
-
/**
|
|
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:
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 (
|
|
49
|
-
|
|
50
|
-
|
|
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: "
|
|
54
|
-
|
|
55
|
-
return { status: "unavailable", reason: "hosted artifact
|
|
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
|
|
466
|
-
|
|
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 "${
|
|
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,
|
package/src/portable/clay.ts
CHANGED
|
@@ -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),
|