fullstackgtm 0.49.0 → 0.50.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/DATA-FLOWS.md +1 -0
- package/README.md +48 -0
- package/dist/acquireCheckpoint.d.ts +33 -0
- package/dist/acquireCheckpoint.js +149 -0
- package/dist/acquireLinkedIn.d.ts +2 -0
- package/dist/acquireLinkedIn.js +1 -1
- package/dist/cli/audit.js +6 -1
- package/dist/cli/auth.js +25 -0
- package/dist/cli/backfill.js +4 -0
- package/dist/cli/call.js +28 -6
- package/dist/cli/draft.js +11 -1
- package/dist/cli/enrich.js +286 -81
- package/dist/cli/fix.js +6 -3
- package/dist/cli/help.js +30 -26
- package/dist/cli/icp.js +15 -1
- package/dist/cli/market.js +14 -1
- package/dist/cli/planOutput.d.ts +5 -0
- package/dist/cli/planOutput.js +27 -0
- package/dist/cli/plans.js +269 -24
- package/dist/cli/schedule.js +22 -11
- package/dist/cli/shared.d.ts +3 -1
- package/dist/cli/shared.js +2 -2
- package/dist/cli/signals.js +22 -3
- package/dist/cli/tam.js +10 -1
- package/dist/cli/ui.d.ts +10 -5
- package/dist/cli/ui.js +28 -9
- package/dist/connectors/hubspot.d.ts +4 -0
- package/dist/connectors/hubspot.js +36 -18
- package/dist/connectors/linkedin.d.ts +2 -0
- package/dist/connectors/linkedin.js +5 -0
- package/dist/connectors/prospectSources.d.ts +23 -0
- package/dist/connectors/prospectSources.js +21 -3
- package/dist/enrich.d.ts +44 -1
- package/dist/hostedAcquireCheckpoint.d.ts +43 -0
- package/dist/hostedAcquireCheckpoint.js +129 -0
- package/dist/hostedPatchPlan.d.ts +87 -0
- package/dist/hostedPatchPlan.js +275 -0
- package/dist/icp.js +13 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/planStore.d.ts +14 -0
- package/dist/planStore.js +73 -0
- package/dist/progress.d.ts +2 -2
- package/dist/progress.js +2 -2
- package/docs/api.md +24 -0
- package/docs/architecture.md +9 -0
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +1 -1
- package/src/acquireCheckpoint.ts +186 -0
- package/src/acquireLinkedIn.ts +3 -1
- package/src/cli/audit.ts +5 -1
- package/src/cli/auth.ts +24 -0
- package/src/cli/backfill.ts +3 -0
- package/src/cli/call.ts +25 -6
- package/src/cli/draft.ts +9 -1
- package/src/cli/enrich.ts +325 -80
- package/src/cli/fix.ts +5 -3
- package/src/cli/help.ts +31 -26
- package/src/cli/icp.ts +13 -1
- package/src/cli/market.ts +12 -1
- package/src/cli/planOutput.ts +30 -0
- package/src/cli/plans.ts +259 -25
- package/src/cli/schedule.ts +21 -15
- package/src/cli/shared.ts +2 -1
- package/src/cli/signals.ts +22 -3
- package/src/cli/tam.ts +8 -1
- package/src/cli/ui.ts +31 -13
- package/src/connectors/hubspot.ts +41 -17
- package/src/connectors/linkedin.ts +6 -0
- package/src/connectors/prospectSources.ts +46 -4
- package/src/enrich.ts +47 -1
- package/src/hostedAcquireCheckpoint.ts +156 -0
- package/src/hostedPatchPlan.ts +291 -0
- package/src/icp.ts +14 -2
- package/src/index.ts +20 -0
- package/src/planStore.ts +87 -0
- package/src/progress.ts +2 -2
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated acquisition-checkpoint transport for paired CLI installs.
|
|
3
|
+
*
|
|
4
|
+
* The local checkpoint remains authoritative for running a job. This module
|
|
5
|
+
* only mirrors/query-scoped continuation state to the paired hosted app so a
|
|
6
|
+
* different authenticated worker can resume it. Writes use compare-and-swap:
|
|
7
|
+
* a stale client receives a conflict and must explicitly reconcile instead of
|
|
8
|
+
* silently moving a provider cursor backwards or overwriting another worker.
|
|
9
|
+
*/
|
|
10
|
+
import { getCredential } from "./credentials.ts";
|
|
11
|
+
import type { AcquireDiscoveryCheckpoint } from "./enrich.ts";
|
|
12
|
+
|
|
13
|
+
const ENDPOINT = "/api/cli/acquisition-checkpoint";
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 4000;
|
|
15
|
+
const CHECKPOINT_KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
|
16
|
+
|
|
17
|
+
export type HostedAcquireCheckpoint = {
|
|
18
|
+
checkpoint: AcquireDiscoveryCheckpoint;
|
|
19
|
+
/** Opaque server revision used only for compare-and-swap. */
|
|
20
|
+
version: number;
|
|
21
|
+
updatedAt: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type HostedCheckpointReadResult =
|
|
25
|
+
| { status: "unpaired" }
|
|
26
|
+
| { status: "missing" }
|
|
27
|
+
| { status: "found"; record: HostedAcquireCheckpoint }
|
|
28
|
+
| { status: "unavailable"; reason: string };
|
|
29
|
+
|
|
30
|
+
export type HostedCheckpointWriteResult =
|
|
31
|
+
| { status: "unpaired" }
|
|
32
|
+
| { status: "saved"; record: HostedAcquireCheckpoint }
|
|
33
|
+
| { status: "conflict"; current: HostedAcquireCheckpoint | null }
|
|
34
|
+
| { status: "unavailable"; reason: string };
|
|
35
|
+
|
|
36
|
+
type Options = { fetchImpl?: typeof fetch; timeoutMs?: number };
|
|
37
|
+
|
|
38
|
+
function validKey(key: string): string {
|
|
39
|
+
if (!CHECKPOINT_KEY.test(key)) {
|
|
40
|
+
throw new Error("Acquisition checkpoint key must be 1-256 URL-safe characters.");
|
|
41
|
+
}
|
|
42
|
+
return key;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function broker(): { baseUrl: string; accessToken: string } | null {
|
|
46
|
+
const credential = getCredential("broker");
|
|
47
|
+
if (!credential?.baseUrl || !credential.accessToken) return null;
|
|
48
|
+
let url: URL;
|
|
49
|
+
try {
|
|
50
|
+
url = new URL(credential.baseUrl);
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
55
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) return null;
|
|
56
|
+
return { baseUrl: url.href.replace(/\/+$/, ""), accessToken: credential.accessToken };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isCheckpoint(value: unknown): value is AcquireDiscoveryCheckpoint {
|
|
60
|
+
if (!value || typeof value !== "object") return false;
|
|
61
|
+
const item = value as Record<string, unknown>;
|
|
62
|
+
return (
|
|
63
|
+
typeof item.queryFingerprint === "string" &&
|
|
64
|
+
item.queryFingerprint.length > 0 &&
|
|
65
|
+
typeof item.exhausted === "boolean" &&
|
|
66
|
+
(item.cursor === undefined || item.cursor === null || typeof item.cursor === "string") &&
|
|
67
|
+
(item.offset === undefined || item.offset === null ||
|
|
68
|
+
(typeof item.offset === "number" && Number.isSafeInteger(item.offset) && item.offset >= 0))
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseRecord(value: unknown): HostedAcquireCheckpoint | null {
|
|
73
|
+
if (!value || typeof value !== "object") return null;
|
|
74
|
+
const body = value as Record<string, unknown>;
|
|
75
|
+
if (!isCheckpoint(body.checkpoint) || !Number.isSafeInteger(body.version) || (body.version as number) < 1) return null;
|
|
76
|
+
if (!Number.isSafeInteger(body.updatedAt) || (body.updatedAt as number) < 0) return null;
|
|
77
|
+
return {
|
|
78
|
+
checkpoint: body.checkpoint,
|
|
79
|
+
version: body.version as number,
|
|
80
|
+
updatedAt: body.updatedAt as number,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function reasonFor(status: number): string {
|
|
85
|
+
if (status === 401 || status === 403) return "hosted authentication was rejected; pair the CLI again";
|
|
86
|
+
return `hosted checkpoint request failed (HTTP ${status})`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Read a query/list checkpoint. Inert unless this profile is paired. */
|
|
90
|
+
export async function readHostedAcquireCheckpoint(
|
|
91
|
+
key: string,
|
|
92
|
+
options: Options = {},
|
|
93
|
+
): Promise<HostedCheckpointReadResult> {
|
|
94
|
+
validKey(key);
|
|
95
|
+
const paired = broker();
|
|
96
|
+
if (!paired) return { status: "unpaired" };
|
|
97
|
+
try {
|
|
98
|
+
const response = await (options.fetchImpl ?? fetch)(
|
|
99
|
+
`${paired.baseUrl}${ENDPOINT}?key=${encodeURIComponent(key)}`,
|
|
100
|
+
{
|
|
101
|
+
headers: { Authorization: `Bearer ${paired.accessToken}`, Accept: "application/json" },
|
|
102
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
if (response.status === 404) return { status: "missing" };
|
|
106
|
+
if (!response.ok) return { status: "unavailable", reason: reasonFor(response.status) };
|
|
107
|
+
const record = parseRecord(await response.json());
|
|
108
|
+
return record
|
|
109
|
+
? { status: "found", record }
|
|
110
|
+
: { status: "unavailable", reason: "hosted checkpoint response was invalid" };
|
|
111
|
+
} catch {
|
|
112
|
+
return { status: "unavailable", reason: "hosted checkpoint request was unavailable" };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Mirror a local checkpoint using compare-and-swap. Pass null only when the
|
|
118
|
+
* caller observed no hosted record (sent as version zero); pass the exact
|
|
119
|
+
* positive version returned by read for an update.
|
|
120
|
+
*/
|
|
121
|
+
export async function writeHostedAcquireCheckpoint(
|
|
122
|
+
key: string,
|
|
123
|
+
checkpoint: AcquireDiscoveryCheckpoint,
|
|
124
|
+
expectedVersion: number | null,
|
|
125
|
+
options: Options = {},
|
|
126
|
+
): Promise<HostedCheckpointWriteResult> {
|
|
127
|
+
validKey(key);
|
|
128
|
+
if (!isCheckpoint(checkpoint)) throw new Error("Refusing to sync an invalid acquisition checkpoint.");
|
|
129
|
+
if (expectedVersion !== null && (!Number.isSafeInteger(expectedVersion) || expectedVersion < 1)) {
|
|
130
|
+
throw new Error("expectedVersion must be null or a positive integer.");
|
|
131
|
+
}
|
|
132
|
+
const paired = broker();
|
|
133
|
+
if (!paired) return { status: "unpaired" };
|
|
134
|
+
try {
|
|
135
|
+
const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}`, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: { Authorization: `Bearer ${paired.accessToken}`, "Content-Type": "application/json" },
|
|
138
|
+
body: JSON.stringify({ key, checkpoint, expectedVersion: expectedVersion ?? 0 }),
|
|
139
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
140
|
+
});
|
|
141
|
+
if (response.status === 409) {
|
|
142
|
+
const body: unknown = await response.json().catch(() => null);
|
|
143
|
+
const current = body && typeof body === "object"
|
|
144
|
+
? parseRecord((body as Record<string, unknown>).current)
|
|
145
|
+
: null;
|
|
146
|
+
return { status: "conflict", current };
|
|
147
|
+
}
|
|
148
|
+
if (!response.ok) return { status: "unavailable", reason: reasonFor(response.status) };
|
|
149
|
+
const record = parseRecord(await response.json());
|
|
150
|
+
return record
|
|
151
|
+
? { status: "saved", record }
|
|
152
|
+
: { status: "unavailable", reason: "hosted checkpoint response was invalid" };
|
|
153
|
+
} catch {
|
|
154
|
+
return { status: "unavailable", reason: "hosted checkpoint request was unavailable" };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/** Broker-authenticated mirror for locally governed patch plans. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { getCredential } from "./credentials.ts";
|
|
4
|
+
import type { PlanStore, StoredPlan } from "./planStore.ts";
|
|
5
|
+
import type { CrmProvider, PatchOperation, PatchOperationResult, PatchPlan, PatchPlanRun } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
const ENDPOINT = "/api/cli/patch-plan";
|
|
8
|
+
const TIMEOUT_MS = 5000;
|
|
9
|
+
|
|
10
|
+
type Options = { fetchImpl?: typeof fetch; timeoutMs?: number };
|
|
11
|
+
export type HostedPlanState = {
|
|
12
|
+
patchPlanId: string;
|
|
13
|
+
externalPlanId: string;
|
|
14
|
+
documentSha256: string;
|
|
15
|
+
status: string;
|
|
16
|
+
approvedOperationIds: string[];
|
|
17
|
+
valueOverrides: Record<string, unknown>;
|
|
18
|
+
updatedAt: number;
|
|
19
|
+
runRecord?: PatchPlanRun;
|
|
20
|
+
url: string;
|
|
21
|
+
};
|
|
22
|
+
export type HostedPlanResult =
|
|
23
|
+
| { status: "unpaired" }
|
|
24
|
+
| { status: "missing" }
|
|
25
|
+
| { status: "found" | "saved"; state: HostedPlanState }
|
|
26
|
+
| { status: "conflict"; reason: string }
|
|
27
|
+
| { status: "unavailable"; reason: string };
|
|
28
|
+
|
|
29
|
+
function broker(): { baseUrl: string; token: string } | null {
|
|
30
|
+
const value = getCredential("broker");
|
|
31
|
+
if (!value?.baseUrl || !value.accessToken) return null;
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(value.baseUrl);
|
|
34
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
35
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) return null;
|
|
36
|
+
return { baseUrl: url.href.replace(/\/+$/, ""), token: value.accessToken };
|
|
37
|
+
} catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Review document intentionally omits evidence/findings and all mutable store security state. */
|
|
41
|
+
export function hostedReviewDocument(plan: PatchPlan) {
|
|
42
|
+
return {
|
|
43
|
+
id: plan.id,
|
|
44
|
+
title: plan.title,
|
|
45
|
+
createdAt: plan.createdAt,
|
|
46
|
+
status: "needs_approval",
|
|
47
|
+
dryRun: true,
|
|
48
|
+
summary: plan.summary,
|
|
49
|
+
operations: plan.operations,
|
|
50
|
+
...(plan.filter ? { filter: plan.filter } : {}),
|
|
51
|
+
...(plan.guards ? { guards: plan.guards } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function hostedPlanDigest(plan: PatchPlan): string {
|
|
56
|
+
return createHash("sha256").update(JSON.stringify(hostedReviewDocument(plan))).digest("hex");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function mapOperation(op: PatchOperation) {
|
|
60
|
+
return {
|
|
61
|
+
packageOpId: op.id,
|
|
62
|
+
objectType: op.objectType,
|
|
63
|
+
...(op.objectId ? { objectId: op.objectId } : {}),
|
|
64
|
+
fieldOrAction: op.field ?? op.operation,
|
|
65
|
+
beforeValue: op.beforeValue ?? null,
|
|
66
|
+
afterValue: op.afterValue ?? null,
|
|
67
|
+
...(op.reason ? { reason: op.reason } : {}),
|
|
68
|
+
...(op.sourceRuleOrPolicy ? { sourceRuleOrPolicy: op.sourceRuleOrPolicy } : {}),
|
|
69
|
+
riskLevel: op.riskLevel,
|
|
70
|
+
approvalRequired: op.approvalRequired,
|
|
71
|
+
...(op.rollback ? { rollback: op.rollback } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseState(value: unknown, baseUrl: string): HostedPlanState | null {
|
|
76
|
+
if (!value || typeof value !== "object") return null;
|
|
77
|
+
const row = value as Record<string, unknown>;
|
|
78
|
+
const id = typeof row.patchPlanId === "string" ? row.patchPlanId : null;
|
|
79
|
+
if (!id) return null;
|
|
80
|
+
const approvedOperationIds = Array.isArray(row.approvedOperationIds) ? row.approvedOperationIds.filter((id): id is string => typeof id === "string") : [];
|
|
81
|
+
const runRecord = parseRunRecord(row.runRecord, row.operationReceipts, row.externalPlanId, row.updatedAt);
|
|
82
|
+
return {
|
|
83
|
+
patchPlanId: id,
|
|
84
|
+
externalPlanId: typeof row.externalPlanId === "string" ? row.externalPlanId : "",
|
|
85
|
+
documentSha256: typeof row.documentSha256 === "string" ? row.documentSha256 : "",
|
|
86
|
+
status: typeof row.status === "string" ? row.status : "pending_approval",
|
|
87
|
+
approvedOperationIds,
|
|
88
|
+
valueOverrides: row.valueOverrides && typeof row.valueOverrides === "object" && !Array.isArray(row.valueOverrides) ? row.valueOverrides as Record<string, unknown> : {},
|
|
89
|
+
updatedAt: typeof row.updatedAt === "number" ? row.updatedAt : Date.now(),
|
|
90
|
+
...(runRecord ? { runRecord } : {}),
|
|
91
|
+
url: `${baseUrl}/dashboard/patch-plans?plan=${encodeURIComponent(id)}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseRunRecord(value: unknown, receiptsValue: unknown, externalPlanId: unknown, updatedAt: unknown): PatchPlanRun | undefined {
|
|
96
|
+
const row = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
97
|
+
const planId = typeof row?.planId === "string" ? row.planId : typeof externalPlanId === "string" ? externalPlanId : undefined;
|
|
98
|
+
if (!planId) return undefined;
|
|
99
|
+
const rawResults = Array.isArray(row?.results) ? row.results : Array.isArray(receiptsValue) ? receiptsValue : [];
|
|
100
|
+
const results = rawResults.flatMap((entry): PatchOperationResult[] => {
|
|
101
|
+
if (!entry || typeof entry !== "object") return [];
|
|
102
|
+
const receipt = entry as Record<string, unknown>;
|
|
103
|
+
const operationId = typeof receipt.operationId === "string" ? receipt.operationId : typeof receipt.packageOpId === "string" ? receipt.packageOpId : undefined;
|
|
104
|
+
const rawStatus = receipt.status;
|
|
105
|
+
const status = rawStatus === "applied" || rawStatus === "failed" || rawStatus === "skipped" || rawStatus === "conflict" ? rawStatus : undefined;
|
|
106
|
+
if (!operationId || !status) return [];
|
|
107
|
+
return [{ operationId, status, ...(typeof receipt.detail === "string" ? { detail: receipt.detail } : typeof receipt.error === "string" ? { detail: receipt.error } : {}), ...(receipt.providerData !== undefined ? { providerData: receipt.providerData } : {}) }];
|
|
108
|
+
});
|
|
109
|
+
if (results.length === 0) return undefined;
|
|
110
|
+
const timestamp = typeof updatedAt === "number" ? new Date(updatedAt).toISOString() : new Date().toISOString();
|
|
111
|
+
const provider = typeof row?.provider === "string" ? row.provider as CrmProvider : "mock";
|
|
112
|
+
const status = row?.status === "applied" || row?.status === "partial" || row?.status === "failed" || row?.status === "rejected"
|
|
113
|
+
? row.status : results.every((result) => result.status === "applied" || result.status === "skipped") ? "applied" : "partial";
|
|
114
|
+
return {
|
|
115
|
+
planId, provider,
|
|
116
|
+
startedAt: typeof row?.startedAt === "string" ? row.startedAt : timestamp,
|
|
117
|
+
finishedAt: typeof row?.finishedAt === "string" ? row.finishedAt : timestamp,
|
|
118
|
+
status,
|
|
119
|
+
results,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function failure(status: number): HostedPlanResult {
|
|
124
|
+
if (status === 409) return { status: "conflict", reason: "hosted mirror has different immutable content" };
|
|
125
|
+
if (status === 401 || status === 403) return { status: "unavailable", reason: "hosted authentication was rejected; pair the CLI again" };
|
|
126
|
+
return { status: "unavailable", reason: `hosted plan request failed (HTTP ${status})` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function uploadHostedPatchPlan(stored: StoredPlan, options: Options = {}): Promise<HostedPlanResult> {
|
|
130
|
+
const paired = broker();
|
|
131
|
+
if (!paired) return { status: "unpaired" };
|
|
132
|
+
const document = hostedReviewDocument(stored.plan);
|
|
133
|
+
const digest = hostedPlanDigest(stored.plan);
|
|
134
|
+
const riskOrder = { low: 0, medium: 1, high: 2 } as const;
|
|
135
|
+
const riskLevel = stored.plan.operations.reduce<"low" | "medium" | "high">(
|
|
136
|
+
(risk, op) => riskOrder[op.riskLevel] > riskOrder[risk] ? op.riskLevel : risk,
|
|
137
|
+
"low",
|
|
138
|
+
);
|
|
139
|
+
try {
|
|
140
|
+
const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { Authorization: `Bearer ${paired.token}`, "Content-Type": "application/json" },
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
externalPlanId: stored.plan.id, documentSha256: digest, externalRevision: stored.revision,
|
|
145
|
+
title: stored.plan.title, summary: stored.plan.summary, riskLevel, document,
|
|
146
|
+
operations: stored.plan.operations.map(mapOperation), createdAt: Date.parse(stored.plan.createdAt),
|
|
147
|
+
}),
|
|
148
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
|
|
149
|
+
});
|
|
150
|
+
if (!response.ok) return failure(response.status);
|
|
151
|
+
const raw = await response.json() as Record<string, unknown>;
|
|
152
|
+
const state = parseState({ ...raw, externalPlanId: stored.plan.id, documentSha256: digest, approvedOperationIds: [], valueOverrides: {}, updatedAt: Date.now() }, paired.baseUrl);
|
|
153
|
+
return state ? { status: "saved", state } : { status: "unavailable", reason: "hosted plan response was invalid" };
|
|
154
|
+
} catch { return { status: "unavailable", reason: "hosted plan request was unavailable" }; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function readHostedPatchPlan(planId: string, options: Options = {}): Promise<HostedPlanResult> {
|
|
158
|
+
const paired = broker();
|
|
159
|
+
if (!paired) return { status: "unpaired" };
|
|
160
|
+
try {
|
|
161
|
+
const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}?externalPlanId=${encodeURIComponent(planId)}`, {
|
|
162
|
+
headers: { Authorization: `Bearer ${paired.token}`, Accept: "application/json" },
|
|
163
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
|
|
164
|
+
});
|
|
165
|
+
if (response.status === 404) return { status: "missing" };
|
|
166
|
+
if (!response.ok) return failure(response.status);
|
|
167
|
+
const state = parseState(await response.json(), paired.baseUrl);
|
|
168
|
+
return state ? { status: "found", state } : { status: "unavailable", reason: "hosted plan response was invalid" };
|
|
169
|
+
} catch { return { status: "unavailable", reason: "hosted plan request was unavailable" }; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export type HostedReconcileResult =
|
|
173
|
+
| { status: "unchanged" | "unpaired" | "missing" }
|
|
174
|
+
| { status: "updated"; stored: StoredPlan; remoteStatus: string }
|
|
175
|
+
| { status: "conflict" | "unavailable"; reason: string };
|
|
176
|
+
|
|
177
|
+
/** Pull a peer's newer lifecycle into the local replica without weakening local integrity gates. */
|
|
178
|
+
export async function reconcileHostedPatchPlan(
|
|
179
|
+
store: PlanStore,
|
|
180
|
+
stored: StoredPlan,
|
|
181
|
+
options: Options = {},
|
|
182
|
+
): Promise<HostedReconcileResult> {
|
|
183
|
+
const hosted = await readHostedPatchPlan(stored.plan.id, options);
|
|
184
|
+
if (hosted.status === "unpaired" || hosted.status === "missing") return { status: hosted.status };
|
|
185
|
+
if (hosted.status === "unavailable" || hosted.status === "conflict") return hosted;
|
|
186
|
+
if (hosted.state.documentSha256 !== hostedPlanDigest(stored.plan)) {
|
|
187
|
+
return { status: "conflict", reason: "hosted review content does not match the immutable local plan" };
|
|
188
|
+
}
|
|
189
|
+
if (!(["approved", "rejected", "applied"] as string[]).includes(hosted.state.status)) return { status: "unchanged" };
|
|
190
|
+
try {
|
|
191
|
+
const reconciled = await store.reconcileReplica(stored.plan.id, {
|
|
192
|
+
status: hosted.state.status as "approved" | "rejected" | "applied",
|
|
193
|
+
approvedOperationIds: hosted.state.approvedOperationIds,
|
|
194
|
+
valueOverrides: hosted.state.valueOverrides,
|
|
195
|
+
...(hosted.state.runRecord ? { run: hosted.state.runRecord } : {}),
|
|
196
|
+
});
|
|
197
|
+
return reconciled.changed
|
|
198
|
+
? { status: "updated", stored: reconciled.stored, remoteStatus: hosted.state.status }
|
|
199
|
+
: { status: "unchanged" };
|
|
200
|
+
} catch (error) {
|
|
201
|
+
return { status: "conflict", reason: error instanceof Error ? error.message : "replica reconciliation failed" };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function reportHostedPlanLifecycle(stored: StoredPlan, options: Options & { claimId?: string } = {}): Promise<HostedPlanResult> {
|
|
206
|
+
const paired = broker();
|
|
207
|
+
if (!paired) return { status: "unpaired" };
|
|
208
|
+
const status = stored.status === "applied" ? "applied" : stored.status === "rejected" ? "rejected" : "approved";
|
|
209
|
+
const runRecord = status === "applied" ? stored.runs.at(-1) : undefined;
|
|
210
|
+
// The hosted terminal transition is authority-bound to the exact approved
|
|
211
|
+
// subset. Local runs also record excluded operations as `skipped` for a
|
|
212
|
+
// complete human audit trail; those are not execution receipts and must not
|
|
213
|
+
// be echoed as though they were authorized provider work.
|
|
214
|
+
const approved = new Set(stored.approvedOperationIds);
|
|
215
|
+
const operationReceipts = runRecord?.results.filter((result) => approved.has(result.operationId)).map((result) => ({
|
|
216
|
+
packageOpId: result.operationId,
|
|
217
|
+
status: result.status,
|
|
218
|
+
...(result.detail ? { error: result.detail.slice(0, 1000) } : {}),
|
|
219
|
+
...(result.providerData !== undefined ? { providerData: result.providerData } : {}),
|
|
220
|
+
updatedAt: Date.parse(runRecord.finishedAt),
|
|
221
|
+
}));
|
|
222
|
+
try {
|
|
223
|
+
const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}`, {
|
|
224
|
+
method: "POST", headers: { Authorization: `Bearer ${paired.token}`, "Content-Type": "application/json" },
|
|
225
|
+
body: JSON.stringify({ action: "lifecycle", externalPlanId: stored.plan.id, documentSha256: hostedPlanDigest(stored.plan), status, approvedOperationIds: stored.approvedOperationIds, ...(options.claimId ? { claimId: options.claimId } : {}), ...(runRecord ? { runRecord, operationReceipts } : {}) }),
|
|
226
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
|
|
227
|
+
});
|
|
228
|
+
if (!response.ok) return failure(response.status);
|
|
229
|
+
const raw = await response.json() as Record<string, unknown>;
|
|
230
|
+
const state = parseState({ ...raw, externalPlanId: stored.plan.id, documentSha256: hostedPlanDigest(stored.plan), approvedOperationIds: stored.approvedOperationIds, valueOverrides: stored.valueOverrides, updatedAt: Date.now() }, paired.baseUrl);
|
|
231
|
+
return state ? { status: "saved", state } : { status: "unavailable", reason: "hosted lifecycle response was invalid" };
|
|
232
|
+
} catch { return { status: "unavailable", reason: "hosted lifecycle request was unavailable" }; }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export type HostedApplyClaimResult =
|
|
236
|
+
| { status: "unpaired" }
|
|
237
|
+
| { status: "claimed"; claimId: string; expiresAt?: number }
|
|
238
|
+
| { status: "applied" }
|
|
239
|
+
| { status: "conflict" | "unavailable"; reason: string };
|
|
240
|
+
|
|
241
|
+
/** Coordinate online replicas before provider I/O; provider idempotency remains the offline fallback. */
|
|
242
|
+
export async function claimHostedPlanApply(stored: StoredPlan, options: Options = {}): Promise<HostedApplyClaimResult> {
|
|
243
|
+
const paired = broker();
|
|
244
|
+
if (!paired) return { status: "unpaired" };
|
|
245
|
+
try {
|
|
246
|
+
const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}`, {
|
|
247
|
+
method: "POST", headers: { Authorization: `Bearer ${paired.token}`, "Content-Type": "application/json" },
|
|
248
|
+
body: JSON.stringify({ action: "claim", externalPlanId: stored.plan.id, documentSha256: hostedPlanDigest(stored.plan) }),
|
|
249
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
|
|
250
|
+
});
|
|
251
|
+
if (!response.ok) return failure(response.status) as HostedApplyClaimResult;
|
|
252
|
+
const row = await response.json() as Record<string, unknown>;
|
|
253
|
+
if (row.alreadyApplied === true || row.status === "applied") return { status: "applied" };
|
|
254
|
+
if (row.status === "claimed" && typeof row.claimId === "string") {
|
|
255
|
+
return { status: "claimed", claimId: row.claimId, ...(typeof row.expiresAt === "number" ? { expiresAt: row.expiresAt } : {}) };
|
|
256
|
+
}
|
|
257
|
+
return { status: "unavailable", reason: "hosted apply claim response was invalid" };
|
|
258
|
+
} catch { return { status: "unavailable", reason: "hosted apply claim request was unavailable" }; }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Release a shared CLI claim only when preflight aborts before provider I/O. */
|
|
262
|
+
export async function releaseHostedPlanApply(
|
|
263
|
+
stored: StoredPlan,
|
|
264
|
+
claimId: string,
|
|
265
|
+
reason: string,
|
|
266
|
+
options: Options = {},
|
|
267
|
+
): Promise<{ status: "released" | "unpaired" } | { status: "conflict" | "unavailable"; reason: string }> {
|
|
268
|
+
const paired = broker();
|
|
269
|
+
if (!paired) return { status: "unpaired" };
|
|
270
|
+
try {
|
|
271
|
+
const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}`, {
|
|
272
|
+
method: "POST",
|
|
273
|
+
headers: { Authorization: `Bearer ${paired.token}`, "Content-Type": "application/json" },
|
|
274
|
+
body: JSON.stringify({
|
|
275
|
+
action: "release_claim",
|
|
276
|
+
externalPlanId: stored.plan.id,
|
|
277
|
+
documentSha256: hostedPlanDigest(stored.plan),
|
|
278
|
+
claimId,
|
|
279
|
+
reason,
|
|
280
|
+
}),
|
|
281
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
|
|
282
|
+
});
|
|
283
|
+
if (!response.ok) return failure(response.status) as { status: "conflict" | "unavailable"; reason: string };
|
|
284
|
+
const row = await response.json() as Record<string, unknown>;
|
|
285
|
+
return row.released === true
|
|
286
|
+
? { status: "released" }
|
|
287
|
+
: { status: "unavailable", reason: "hosted apply claim release response was invalid" };
|
|
288
|
+
} catch {
|
|
289
|
+
return { status: "unavailable", reason: "hosted apply claim release was unavailable" };
|
|
290
|
+
}
|
|
291
|
+
}
|
package/src/icp.ts
CHANGED
|
@@ -193,6 +193,15 @@ const CRUSTDATA_INDUSTRY: Record<string, string[]> = {
|
|
|
193
193
|
"financial services": ["Financial Services"],
|
|
194
194
|
};
|
|
195
195
|
|
|
196
|
+
// Common NAICS groups translated into Crustdata's LinkedIn industry vocabulary.
|
|
197
|
+
// This keeps an ICP authored with provider-neutral NAICS codes from silently
|
|
198
|
+
// dropping its industry constraint on the Pipe0 path.
|
|
199
|
+
const NAICS_CRUSTDATA_INDUSTRY: Record<string, string[]> = {
|
|
200
|
+
"5112": ["Software Development", "Computer Software"],
|
|
201
|
+
"5182": ["IT Services and IT Consulting", "Information Technology & Services"],
|
|
202
|
+
"5415": ["IT Services and IT Consulting", "Information Technology & Services"],
|
|
203
|
+
};
|
|
204
|
+
|
|
196
205
|
/**
|
|
197
206
|
* pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
|
|
198
207
|
* matches real LinkedIn title strings (case-sensitive), so keywords are Title
|
|
@@ -219,9 +228,12 @@ export function icpToCrustdataFilters(icp: Icp): Record<string, unknown> {
|
|
|
219
228
|
if (icp.firmographics.geos?.length) {
|
|
220
229
|
f.locations = icp.firmographics.geos.map((g) => COUNTRY_NAMES[g.toLowerCase()] ?? g);
|
|
221
230
|
}
|
|
222
|
-
if (icp.firmographics.industries?.length) {
|
|
231
|
+
if (icp.firmographics.industries?.length || icp.firmographics.naics?.length) {
|
|
223
232
|
const inds = [
|
|
224
|
-
...new Set(
|
|
233
|
+
...new Set([
|
|
234
|
+
...(icp.firmographics.industries ?? []).flatMap((i) => CRUSTDATA_INDUSTRY[i.toLowerCase()] ?? [titleCase(i)]),
|
|
235
|
+
...(icp.firmographics.naics ?? []).flatMap((code) => NAICS_CRUSTDATA_INDUSTRY[code] ?? []),
|
|
236
|
+
]),
|
|
225
237
|
];
|
|
226
238
|
if (inds.length) f.current_employers_linkedin_industries = inds;
|
|
227
239
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
export { auditSnapshot, defaultPolicy } from "./audit.ts";
|
|
2
|
+
export {
|
|
3
|
+
acquireCheckpointId,
|
|
4
|
+
acquireCheckpointsDir,
|
|
5
|
+
createFileAcquireCheckpointStore,
|
|
6
|
+
validateAcquireCheckpoint,
|
|
7
|
+
validateAcquireCheckpointKey,
|
|
8
|
+
type AcquireCheckpoint,
|
|
9
|
+
type AcquireCheckpointKey,
|
|
10
|
+
type AcquireCheckpointStore,
|
|
11
|
+
type AcquireContinuation,
|
|
12
|
+
} from "./acquireCheckpoint.ts";
|
|
2
13
|
export {
|
|
3
14
|
matchesTerritory,
|
|
4
15
|
parseAssignmentPolicy,
|
|
@@ -132,6 +143,7 @@ export {
|
|
|
132
143
|
stagedSourceRecords,
|
|
133
144
|
staleDaysFor,
|
|
134
145
|
type BuildEnrichPlanOptions,
|
|
146
|
+
type AcquireDiscoveryCheckpoint,
|
|
135
147
|
type EnrichAmbiguity,
|
|
136
148
|
type EnrichConfig,
|
|
137
149
|
type EnrichCounts,
|
|
@@ -148,6 +160,14 @@ export {
|
|
|
148
160
|
type EnrichWorkItem,
|
|
149
161
|
type MatchOutcome,
|
|
150
162
|
} from "./enrich.ts";
|
|
163
|
+
export {
|
|
164
|
+
readHostedAcquireCheckpoint,
|
|
165
|
+
writeHostedAcquireCheckpoint,
|
|
166
|
+
type HostedAcquireCheckpoint,
|
|
167
|
+
type HostedCheckpointReadResult,
|
|
168
|
+
type HostedCheckpointWriteResult,
|
|
169
|
+
} from "./hostedAcquireCheckpoint.ts";
|
|
170
|
+
export { claimHostedPlanApply, hostedPlanDigest, hostedReviewDocument, readHostedPatchPlan, reconcileHostedPatchPlan, releaseHostedPlanApply, reportHostedPlanLifecycle, uploadHostedPatchPlan } from "./hostedPatchPlan.ts";
|
|
151
171
|
export {
|
|
152
172
|
scaffoldWorkspace,
|
|
153
173
|
starterEnrichConfig,
|
package/src/planStore.ts
CHANGED
|
@@ -32,6 +32,7 @@ export type StoredPlan = {
|
|
|
32
32
|
id: string;
|
|
33
33
|
claimedAt: string;
|
|
34
34
|
revision: number;
|
|
35
|
+
hostedClaimId?: string;
|
|
35
36
|
};
|
|
36
37
|
/** Durable journal of apply ownership. An unresolved entry must never be replayed automatically. */
|
|
37
38
|
applyAttempts?: ApplyAttempt[];
|
|
@@ -49,6 +50,7 @@ export type ApplyAttempt = {
|
|
|
49
50
|
resolvedAt?: string;
|
|
50
51
|
/** Deliberately generic: provider errors can contain CRM data or credentials. */
|
|
51
52
|
note?: string;
|
|
53
|
+
hostedClaimId?: string;
|
|
52
54
|
};
|
|
53
55
|
|
|
54
56
|
export interface PlanStore {
|
|
@@ -62,12 +64,22 @@ export interface PlanStore {
|
|
|
62
64
|
): Promise<StoredPlan>;
|
|
63
65
|
reject(planId: string): Promise<StoredPlan>;
|
|
64
66
|
claimApply(planId: string, metadata?: Pick<ApplyAttempt, "provider" | "source">): Promise<{ stored: StoredPlan; claimId: string }>;
|
|
67
|
+
recordHostedClaim(planId: string, claimId: string, hostedClaimId: string): Promise<StoredPlan>;
|
|
65
68
|
recordRun(planId: string, run: PatchPlanRun, claimId: string): Promise<StoredPlan>;
|
|
66
69
|
abortApplyPreflight(planId: string, claimId: string, note: string): Promise<StoredPlan>;
|
|
67
70
|
markApplyUncertain(planId: string, claimId: string): Promise<StoredPlan>;
|
|
68
71
|
recoverApply(planId: string): Promise<StoredPlan>;
|
|
72
|
+
reconcileReplica(planId: string, remote: ReplicaPlanState): Promise<{ stored: StoredPlan; changed: boolean }>;
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
/** A validated lifecycle snapshot received from another plan replica. */
|
|
76
|
+
export type ReplicaPlanState = {
|
|
77
|
+
status: "approved" | "rejected" | "applied";
|
|
78
|
+
approvedOperationIds: string[];
|
|
79
|
+
valueOverrides?: Record<string, unknown>;
|
|
80
|
+
run?: PatchPlanRun;
|
|
81
|
+
};
|
|
82
|
+
|
|
71
83
|
/**
|
|
72
84
|
* Plans as JSON files in a directory (default `$FSGTM_HOME/plans`), one file
|
|
73
85
|
* per plan id. Filesystem-shaped on purpose: greppable, diffable, and any
|
|
@@ -294,6 +306,21 @@ export function createFilePlanStore(directory?: string): PlanStore {
|
|
|
294
306
|
});
|
|
295
307
|
},
|
|
296
308
|
|
|
309
|
+
async recordHostedClaim(planId, claimId, hostedClaimId) {
|
|
310
|
+
return withPlanLock(planId, () => {
|
|
311
|
+
const stored = mustRead(planId);
|
|
312
|
+
if (stored.status !== "applying" || stored.applyClaim?.id !== claimId) {
|
|
313
|
+
throw new Error(`Refusing to attach hosted claim for plan ${planId}: local apply claim does not match.`);
|
|
314
|
+
}
|
|
315
|
+
return write({
|
|
316
|
+
...stored,
|
|
317
|
+
applyClaim: { ...stored.applyClaim, hostedClaimId },
|
|
318
|
+
applyAttempts: (stored.applyAttempts ?? []).map((attempt) =>
|
|
319
|
+
attempt.id === claimId ? { ...attempt, hostedClaimId } : attempt),
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
|
|
297
324
|
async abortApplyPreflight(planId, claimId, note) {
|
|
298
325
|
return withPlanLock(planId, () => {
|
|
299
326
|
const stored = mustRead(planId);
|
|
@@ -348,5 +375,65 @@ export function createFilePlanStore(directory?: string): PlanStore {
|
|
|
348
375
|
});
|
|
349
376
|
});
|
|
350
377
|
},
|
|
378
|
+
|
|
379
|
+
async reconcileReplica(planId, remote) {
|
|
380
|
+
return withPlanLock(planId, () => {
|
|
381
|
+
const stored = mustRead(planId);
|
|
382
|
+
if (stored.status === "applying" || stored.applyClaim) {
|
|
383
|
+
throw new Error(`Plan ${planId} is applying locally; replica reconciliation will retry after it finishes.`);
|
|
384
|
+
}
|
|
385
|
+
const known = new Set(stored.plan.operations.map((operation) => operation.id));
|
|
386
|
+
const approvedOperationIds = Array.from(new Set(remote.approvedOperationIds));
|
|
387
|
+
if (approvedOperationIds.some((id) => !known.has(id))) {
|
|
388
|
+
throw new Error(`Replica state for ${planId} contains an unknown operation.`);
|
|
389
|
+
}
|
|
390
|
+
// Terminal receipts are monotonic. A delayed approval or rejection can
|
|
391
|
+
// never roll an already-applied local replica backwards.
|
|
392
|
+
if (stored.status === "applied" && remote.status !== "applied") {
|
|
393
|
+
return { stored, changed: false };
|
|
394
|
+
}
|
|
395
|
+
if (remote.status === "rejected") {
|
|
396
|
+
if (stored.status === "rejected") return { stored, changed: false };
|
|
397
|
+
return { stored: write({
|
|
398
|
+
...stored, status: "rejected", approvedOperationIds: [], valueOverrides: {},
|
|
399
|
+
approvalDigests: undefined, applyClaim: undefined,
|
|
400
|
+
}), changed: true };
|
|
401
|
+
}
|
|
402
|
+
if (approvedOperationIds.length === 0) {
|
|
403
|
+
throw new Error(`Replica ${remote.status} state for ${planId} has no approved operations.`);
|
|
404
|
+
}
|
|
405
|
+
const valueOverrides = remote.valueOverrides ?? {};
|
|
406
|
+
const approvalDigests = computeApprovalDigests(
|
|
407
|
+
stored.plan.operations, approvedOperationIds, valueOverrides, loadOrCreateSigningKey(),
|
|
408
|
+
);
|
|
409
|
+
if (remote.status === "approved") {
|
|
410
|
+
const unchanged = stored.status === "approved"
|
|
411
|
+
&& JSON.stringify(stored.approvedOperationIds) === JSON.stringify(approvedOperationIds)
|
|
412
|
+
&& JSON.stringify(stored.valueOverrides) === JSON.stringify(valueOverrides);
|
|
413
|
+
if (unchanged) return { stored, changed: false };
|
|
414
|
+
return { stored: write({
|
|
415
|
+
...stored, status: "approved", approvedOperationIds, valueOverrides, approvalDigests,
|
|
416
|
+
}), changed: true };
|
|
417
|
+
}
|
|
418
|
+
if (!remote.run || remote.run.planId !== planId) {
|
|
419
|
+
throw new Error(`Replica applied state for ${planId} is missing a matching execution receipt.`);
|
|
420
|
+
}
|
|
421
|
+
if (remote.run.results.some((result) => !known.has(result.operationId))) {
|
|
422
|
+
throw new Error(`Replica execution receipt for ${planId} contains an unknown operation.`);
|
|
423
|
+
}
|
|
424
|
+
const runKey = (run: PatchPlanRun) => `${run.startedAt}\0${run.finishedAt}\0${run.provider}`;
|
|
425
|
+
const alreadyRecorded = stored.runs.some((run) => runKey(run) === runKey(remote.run!));
|
|
426
|
+
if (stored.status === "applied" && alreadyRecorded) return { stored, changed: false };
|
|
427
|
+
return { stored: write({
|
|
428
|
+
...stored,
|
|
429
|
+
status: "applied",
|
|
430
|
+
approvedOperationIds,
|
|
431
|
+
valueOverrides,
|
|
432
|
+
approvalDigests,
|
|
433
|
+
applyClaim: undefined,
|
|
434
|
+
runs: alreadyRecorded ? stored.runs : [...stored.runs, remote.run],
|
|
435
|
+
}), changed: true };
|
|
436
|
+
});
|
|
437
|
+
},
|
|
351
438
|
};
|
|
352
439
|
}
|
package/src/progress.ts
CHANGED
|
@@ -186,8 +186,8 @@ export const RUNS_REPLAY_STAGES = ["runs", "health"] as const;
|
|
|
186
186
|
|
|
187
187
|
export const APPLY_STAGES = ["preflight", "operations", "results"] as const;
|
|
188
188
|
|
|
189
|
-
/** `enrich acquire` —
|
|
190
|
-
export const ACQUIRE_STAGES = ["
|
|
189
|
+
/** `enrich acquire` — discovery through a governed create plan. */
|
|
190
|
+
export const ACQUIRE_STAGES = ["discovery", "resolution", "plan"] as const;
|
|
191
191
|
|
|
192
192
|
export const MARKET_CAPTURE_STAGES = ["sources", "capture", "classify", "persist"] as const;
|
|
193
193
|
|