fullstackgtm 0.48.0 → 0.49.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 +38 -0
- package/CONTRIBUTING.md +23 -4
- package/DATA-FLOWS.md +7 -2
- package/INSTALL_FOR_AGENTS.md +13 -4
- package/README.md +12 -1
- package/SECURITY.md +27 -3
- package/dist/cli/audit.js +10 -7
- package/dist/cli/auth.js +8 -4
- package/dist/cli/fix.js +20 -7
- package/dist/cli/help.js +10 -6
- package/dist/cli/market.js +180 -2
- package/dist/cli/plans.js +56 -5
- package/dist/cli/ui.js +2 -0
- package/dist/cli.js +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +23 -2
- package/dist/connectors/prospectSources.js +4 -11
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/salesforceAuth.d.ts +9 -0
- package/dist/connectors/salesforceAuth.js +47 -5
- package/dist/connectors/theirstack.d.ts +0 -19
- package/dist/connectors/theirstack.js +3 -10
- package/dist/credentials.js +36 -26
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/market.d.ts +1 -1
- package/dist/market.js +7 -127
- package/dist/marketClassify.d.ts +10 -0
- package/dist/marketClassify.js +52 -1
- package/dist/marketSourcing.js +7 -45
- package/dist/mcp.js +67 -115
- package/dist/planStore.d.ts +28 -1
- package/dist/planStore.js +195 -49
- package/dist/providerError.d.ts +21 -0
- package/dist/providerError.js +30 -0
- package/dist/publicHttp.d.ts +28 -0
- package/dist/publicHttp.js +143 -0
- package/dist/secureFile.d.ts +15 -0
- package/dist/secureFile.js +164 -0
- package/dist/types.d.ts +1 -1
- package/docs/api.md +29 -2
- package/docs/architecture.md +13 -2
- package/llms.txt +7 -0
- package/package.json +5 -3
- package/skills/fullstackgtm/SKILL.md +7 -1
- package/src/cli/audit.ts +10 -7
- package/src/cli/auth.ts +8 -4
- package/src/cli/fix.ts +28 -7
- package/src/cli/help.ts +10 -6
- package/src/cli/market.ts +181 -2
- package/src/cli/plans.ts +66 -5
- package/src/cli/ui.ts +1 -0
- package/src/cli.ts +1 -1
- package/src/config.ts +39 -1
- package/src/connectors/prospectSources.ts +4 -11
- package/src/connectors/salesforce.ts +12 -5
- package/src/connectors/salesforceAuth.ts +46 -6
- package/src/connectors/theirstack.ts +3 -10
- package/src/credentials.ts +47 -28
- package/src/index.ts +4 -0
- package/src/market.ts +7 -111
- package/src/marketClassify.ts +61 -1
- package/src/marketSourcing.ts +7 -43
- package/src/mcp.ts +93 -136
- package/src/planStore.ts +235 -57
- package/src/providerError.ts +37 -0
- package/src/publicHttp.ts +147 -0
- package/src/secureFile.ts +169 -0
- package/src/types.ts +1 -0
package/src/cli/fix.ts
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { auditSnapshot, defaultPolicy } from "../audit.ts";
|
|
6
|
-
import { loadConfig, mergePolicy, resolveConfiguredRules } from "../config.ts";
|
|
6
|
+
import { loadConfig, mergePolicy, resolveConfiguredRules, rulePackageTrustFromCli } from "../config.ts";
|
|
7
7
|
import { applyPatchPlan } from "../connector.ts";
|
|
8
8
|
import { patchPlanToMarkdown } from "../format.ts";
|
|
9
9
|
import { createFilePlanStore } from "../planStore.ts";
|
|
10
|
+
import { verifyApprovalDigests } from "../integrity.ts";
|
|
10
11
|
import { resolveRecord, type ResolveCandidate } from "../resolve.ts";
|
|
11
12
|
import { parseAssignmentPolicy } from "../assign.ts";
|
|
12
13
|
import { buildLeadRoutePlan } from "../route.ts";
|
|
@@ -213,8 +214,9 @@ export async function fixCommand(args: string[]) {
|
|
|
213
214
|
}
|
|
214
215
|
const includeCreates = args.includes("--include-creates");
|
|
215
216
|
|
|
216
|
-
const
|
|
217
|
-
const
|
|
217
|
+
const explicitConfig = option(args, "--config") ?? undefined;
|
|
218
|
+
const loaded = loadConfig(explicitConfig);
|
|
219
|
+
const configured = await resolveConfiguredRules(loaded, undefined, rulePackageTrustFromCli(args, explicitConfig));
|
|
218
220
|
const rule = configured.find((candidate) => candidate.id === ruleId);
|
|
219
221
|
if (!rule) {
|
|
220
222
|
throw new Error(`Unknown rule: ${ruleId}. Available rules: ${configured.map((r) => r.id).join(", ")}`);
|
|
@@ -287,6 +289,22 @@ export async function fixCommand(args: string[]) {
|
|
|
287
289
|
return;
|
|
288
290
|
}
|
|
289
291
|
const connector = await connectorFor(provider, args);
|
|
292
|
+
const claimed = await store.claimApply(plan.id, { provider, source: "fix" });
|
|
293
|
+
const { claimId } = claimed;
|
|
294
|
+
const claimedVerification = verifyApprovalDigests(
|
|
295
|
+
claimed.stored.plan.operations,
|
|
296
|
+
claimed.stored.approvedOperationIds,
|
|
297
|
+
claimed.stored.valueOverrides,
|
|
298
|
+
claimed.stored.approvalDigests,
|
|
299
|
+
);
|
|
300
|
+
if (!claimedVerification.ok) {
|
|
301
|
+
await store.abortApplyPreflight(
|
|
302
|
+
plan.id,
|
|
303
|
+
claimId,
|
|
304
|
+
"Approval integrity verification failed after the claim and before provider I/O.",
|
|
305
|
+
);
|
|
306
|
+
throw new Error(`Refusing to apply plan ${plan.id}: approval changed while acquiring the apply claim.`);
|
|
307
|
+
}
|
|
290
308
|
// Live apply board on interactive terminals (stderr; inert otherwise); the
|
|
291
309
|
// same emitter streams heartbeats to the paired hosted app on long runs.
|
|
292
310
|
const renderer = createProgressRenderer(APPLY_STAGES);
|
|
@@ -295,15 +313,18 @@ export async function fixCommand(args: string[]) {
|
|
|
295
313
|
);
|
|
296
314
|
let run: PatchPlanRun;
|
|
297
315
|
try {
|
|
298
|
-
run = await applyPatchPlan(connector, plan, {
|
|
299
|
-
approvedOperationIds:
|
|
300
|
-
valueOverrides:
|
|
316
|
+
run = await applyPatchPlan(connector, claimed.stored.plan, {
|
|
317
|
+
approvedOperationIds: claimed.stored.approvedOperationIds,
|
|
318
|
+
valueOverrides: claimed.stored.valueOverrides,
|
|
301
319
|
progress,
|
|
302
320
|
});
|
|
321
|
+
} catch (error) {
|
|
322
|
+
await store.markApplyUncertain(plan.id, claimId);
|
|
323
|
+
throw error;
|
|
303
324
|
} finally {
|
|
304
325
|
renderer.done();
|
|
305
326
|
}
|
|
306
|
-
await store.recordRun(plan.id, run);
|
|
327
|
+
await store.recordRun(plan.id, run, claimId);
|
|
307
328
|
const counts: Record<string, number> = { applied: 0, conflict: 0, skipped: 0, failed: 0 };
|
|
308
329
|
for (const result of run.results) counts[result.status] = (counts[result.status] ?? 0) + 1;
|
|
309
330
|
lines.push(
|
package/src/cli/help.ts
CHANGED
|
@@ -183,6 +183,7 @@ Usage:
|
|
|
183
183
|
fullstackgtm plans list [--status <s>] | show <id> | reject <id>
|
|
184
184
|
fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]
|
|
185
185
|
fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
|
|
186
|
+
fullstackgtm plans recover <id> --acknowledge-uncertain-writes
|
|
186
187
|
fullstackgtm apply --plan-id <id> --provider <name>
|
|
187
188
|
fullstackgtm apply --plan-id <id> --channel outbox (render approved openers; transmits nothing)
|
|
188
189
|
fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
|
|
@@ -230,6 +231,8 @@ Audit options:
|
|
|
230
231
|
--config <path> Config file (default: ./fullstackgtm.config.json if present)
|
|
231
232
|
{ "policy": {...}, "rules": {"enabled":[],"disabled":[]},
|
|
232
233
|
"rulePackages": ["./team-rules.mjs"] }
|
|
234
|
+
--allow-plugins Execute rulePackages from an explicit --config path after review
|
|
235
|
+
--no-plugins Ignore rulePackages; use declarative config and built-in rules only
|
|
233
236
|
--rules <ids> Comma-separated rule ids to run (default: all; see \`rules\`)
|
|
234
237
|
--json Print the JSON patch plan instead of markdown
|
|
235
238
|
--out <path> Also write the JSON patch plan to a file
|
|
@@ -546,6 +549,7 @@ export const HELP: Record<string, HelpEntry> = {
|
|
|
546
549
|
"fullstackgtm plans list [--status <s>] | show <id> | reject <id>",
|
|
547
550
|
"fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]",
|
|
548
551
|
"fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low]",
|
|
552
|
+
"fullstackgtm plans recover <id> --acknowledge-uncertain-writes",
|
|
549
553
|
],
|
|
550
554
|
detail: "Approval is explicit and per-operation; placeholders need a concrete --value or a suggestion to be approvable.",
|
|
551
555
|
seeAlso: ["audit", "suggest", "apply"],
|
|
@@ -644,12 +648,12 @@ export const HELP: Record<string, HelpEntry> = {
|
|
|
644
648
|
|
|
645
649
|
// Verbs that print their own richer multi-subcommand help; runCli routes their
|
|
646
650
|
// `--help` to themselves, so commandHelp() only renders these via `help <verb>`.
|
|
647
|
-
export const BESPOKE_HELP = ["init", "call", "market", "tam", "enrich", "
|
|
651
|
+
export const BESPOKE_HELP = ["init", "call", "market", "tam", "enrich", "schedule", "signals", "icp", "draft"];
|
|
648
652
|
|
|
649
653
|
export const GLOBAL_FLAGS = ["--help", "--full"];
|
|
650
654
|
export const GLOBAL_SHORT_FLAGS = ["-h"];
|
|
651
655
|
export const SOURCE_FLAGS = ["--provider", "--token-env", "--input", "--demo", "--sample", "--seed", "--today"];
|
|
652
|
-
export const AUDIT_FLAGS = ["--config", "--rules", "--stale-days", "--fail-on", "--save", "--dry-run", "--json", "--out", "--full"];
|
|
656
|
+
export const AUDIT_FLAGS = ["--config", "--allow-plugins", "--no-plugins", "--rules", "--stale-days", "--fail-on", "--save", "--dry-run", "--json", "--out", "--full"];
|
|
653
657
|
|
|
654
658
|
// Complete per-command flag registry used by runCli's fail-closed flag
|
|
655
659
|
// validation. Keep this next to HELP so focused help, machine capabilities,
|
|
@@ -666,13 +670,13 @@ export const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
666
670
|
snapshot: [...SOURCE_FLAGS, "--since", "--out", "--archive"],
|
|
667
671
|
audit: [...SOURCE_FLAGS, ...AUDIT_FLAGS],
|
|
668
672
|
report: [...SOURCE_FLAGS, ...AUDIT_FLAGS, "--plan", "--client", "--title", "--prepared-by", "--format", "--max-examples"],
|
|
669
|
-
diff: ["--before", "--after", "--config", "--stale-days", "--today", "--json", "--fail-on-new-findings"],
|
|
670
|
-
rules: ["--config", "--json"],
|
|
673
|
+
diff: ["--before", "--after", "--config", "--allow-plugins", "--no-plugins", "--stale-days", "--today", "--json", "--fail-on-new-findings"],
|
|
674
|
+
rules: ["--config", "--allow-plugins", "--no-plugins", "--json"],
|
|
671
675
|
resolve: [...SOURCE_FLAGS, "--name", "--domain", "--email", "--account-id", "--json"],
|
|
672
676
|
hierarchy: [...SOURCE_FLAGS, "--json", "--out"],
|
|
673
677
|
relationships: [...SOURCE_FLAGS, "--account-id", "--domain", "--json", "--out"],
|
|
674
678
|
route: [...SOURCE_FLAGS, "--match", "--no-inherit-owner", "--reassign-owned", "--policy", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
|
|
675
|
-
fix: [...SOURCE_FLAGS, "--config", "--rule", "--provider", "--min-confidence", "--include-creates", "--yes", "--confirm", "--dry-run"],
|
|
679
|
+
fix: [...SOURCE_FLAGS, "--config", "--allow-plugins", "--no-plugins", "--rule", "--provider", "--min-confidence", "--include-creates", "--yes", "--confirm", "--dry-run"],
|
|
676
680
|
"bulk-update": [...SOURCE_FLAGS, "--where", "--set", "--guard", "--require", "--create-task", "--archive", "--force-archive-duplicates", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
|
|
677
681
|
dedupe: [...SOURCE_FLAGS, "--key", "--keep", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
|
|
678
682
|
reassign: [...SOURCE_FLAGS, "--from", "--assign-unowned", "--to", "--objects", "--where", "--except-deal-stage", "--include-closed-deals", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
|
|
@@ -680,7 +684,7 @@ export const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
680
684
|
enrich: [...SOURCE_FLAGS, "--config", "--source", "--icp", "--input", "--provider", "--stale-days", "--assign-owner", "--objects", "--max", "--staged-run", "--run-label", "--label", "--runs", "--save", "--dry-run", "--json", "--out"],
|
|
681
685
|
call: [...SOURCE_FLAGS, "--transcript", "--title", "--source", "--model", "--deterministic", "--heuristics", "--llm", "--json", "--ndjson", "--out", "--call", "--call-type", "--rubric", "--list", "--list-rubrics", "--attendees", "--domain", "--deal", "--save", "--dry-run"],
|
|
682
686
|
suggest: [...SOURCE_FLAGS, "--plan-id", "--plan", "--json", "--out"],
|
|
683
|
-
plans: ["--status", "--operations", "--value", "--values-from", "--min-confidence", "--include-creates", "--json"],
|
|
687
|
+
plans: ["--status", "--operations", "--value", "--values-from", "--min-confidence", "--include-creates", "--acknowledge-uncertain-writes", "--json"],
|
|
684
688
|
apply: ["--plan", "--plan-id", "--provider", "--channel", "--token-env", "--approve", "--value", "--json", "--config"],
|
|
685
689
|
"audit-log": ["--in", "--out", "--json"],
|
|
686
690
|
merge: ["--input", "--out", "--json"],
|
package/src/cli/market.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { createFilePlanStore } from "../planStore.ts";
|
|
6
6
|
import type { ParsedCall } from "../calls.ts";
|
|
7
|
-
import { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, loadCaptureTexts, loadMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, type ObservationSet } from "../market.ts";
|
|
7
|
+
import { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, loadCaptureTexts, loadMarketConfig, observationId, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, type ObservationSet } from "../market.ts";
|
|
8
8
|
import { assessAxes, axesReportToText } from "../marketAxes.ts";
|
|
9
9
|
import { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, type CallDocument } from "../marketOverlay.ts";
|
|
10
10
|
import { computeScaleIndex, scaleReportToText } from "../marketScale.ts";
|
|
@@ -29,6 +29,7 @@ import { unknownSubcommandError } from "./suggest.ts";
|
|
|
29
29
|
*/
|
|
30
30
|
const MARKET_SUBCOMMANDS = [
|
|
31
31
|
"init", "capture", "classify", "worksheet", "observe", "fronts", "axes", "overlay", "scale", "report", "refresh",
|
|
32
|
+
"schema", "hints", "observe-template", "review",
|
|
32
33
|
];
|
|
33
34
|
|
|
34
35
|
export async function marketCommand(args: string[]) {
|
|
@@ -42,9 +43,13 @@ export async function marketCommand(args: string[]) {
|
|
|
42
43
|
market init --category <name> [--out <path>] write a starter market.config.json
|
|
43
44
|
market init --category <name> --auto --vendor <url> [--vendor <url>...] [--anchor <url>] [--max-claims n]
|
|
44
45
|
LLM-propose vendors + claim taxonomy from seed pages (needs an API key)
|
|
46
|
+
market schema print exact MarketConfig + ObservationSet shapes and a tiny example
|
|
45
47
|
market capture [--config <path>] [--run <label>]
|
|
46
48
|
market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model m] [--out <path>]
|
|
47
49
|
market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
|
|
50
|
+
market hints [--vendor <id>] [--capture-run <label>] [--json]
|
|
51
|
+
market observe-template [--run <label>] [--out <path>]
|
|
52
|
+
market review --from <observations.json> [--json]
|
|
48
53
|
market observe --from <observations.json|sets.jsonl|spool-dir> [--unverified]
|
|
49
54
|
market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
|
|
50
55
|
market axes [--config <path>] [--run <label>] [--json]
|
|
@@ -86,6 +91,64 @@ recomputed deterministically on every invocation — never stored.`);
|
|
|
86
91
|
return;
|
|
87
92
|
}
|
|
88
93
|
|
|
94
|
+
if (subcommand === "schema") {
|
|
95
|
+
console.log(`MarketConfig shape:
|
|
96
|
+
{
|
|
97
|
+
"category": "string",
|
|
98
|
+
"anchorVendor": "optional vendor id",
|
|
99
|
+
"vendors": [{
|
|
100
|
+
"id": "stable-vendor-id",
|
|
101
|
+
"name": "Vendor Name",
|
|
102
|
+
"urls": { "home": "https://...", "pricing": null, "product": ["https://..."] },
|
|
103
|
+
"aliases": ["optional alternate names"]
|
|
104
|
+
}],
|
|
105
|
+
"claims": [{
|
|
106
|
+
"id": "stable-claim-id",
|
|
107
|
+
"capability": "Specific differentiating capability",
|
|
108
|
+
"icp": "buyer/segment this claim matters to",
|
|
109
|
+
"pricingStructure": "pricing motion implied by the claim, or unknown",
|
|
110
|
+
"definition": "How to judge LOUD vs QUIET vs ABSENT from page text",
|
|
111
|
+
"terms": ["buyer/search terms for deterministic mention matching"]
|
|
112
|
+
}],
|
|
113
|
+
"surfaceRule": "LOUD = hero/primary positioning; QUIET = supported but secondary; ABSENT = no support; UNOBSERVABLE = no usable capture."
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
ObservationSet shape:
|
|
117
|
+
{
|
|
118
|
+
"id": "set_run1",
|
|
119
|
+
"category": "same category as config",
|
|
120
|
+
"runLabel": "run-1",
|
|
121
|
+
"runAt": "2026-06-11T00:00:00.000Z",
|
|
122
|
+
"extractor": "manual|agent:<model>|llm:<provider>:<model>",
|
|
123
|
+
"observations": [{
|
|
124
|
+
"id": "stable observation id (any unique string is accepted)",
|
|
125
|
+
"vendorId": "vendor id from config",
|
|
126
|
+
"claimId": "claim id from config",
|
|
127
|
+
"observedAt": "2026-06-11",
|
|
128
|
+
"intensity": "loud|quiet|absent|unobservable",
|
|
129
|
+
"confidence": "high|medium|low",
|
|
130
|
+
"reason": "one reviewer-facing sentence",
|
|
131
|
+
"evidence": [{
|
|
132
|
+
"id": "ev1",
|
|
133
|
+
"sourceSystem": "web",
|
|
134
|
+
"sourceObjectType": "page",
|
|
135
|
+
"sourceObjectId": "source URL",
|
|
136
|
+
"text": "VERBATIM quote from captured page text",
|
|
137
|
+
"metadata": { "url": "source URL", "captureHash": "hash from worksheet" }
|
|
138
|
+
}]
|
|
139
|
+
}]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
Rules:
|
|
143
|
+
- exactly one observation per vendor × claim cell
|
|
144
|
+
- loud/quiet require ≥1 verbatim evidence quote
|
|
145
|
+
- absent/unobservable use [] evidence
|
|
146
|
+
- unobservable means no usable captured page exists for the vendor; if pages were captured but the claim is unsupported, use absent
|
|
147
|
+
- submit with: fullstackgtm market observe --from observations.json
|
|
148
|
+
- derive outputs with: fullstackgtm market fronts --json; fullstackgtm market report --format md --out market-report.md`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
89
152
|
if (subcommand === "init") {
|
|
90
153
|
const category = option(rest, "--category");
|
|
91
154
|
if (!category) throw new Error("market init requires --category <name>");
|
|
@@ -162,7 +225,22 @@ recomputed deterministically on every invocation — never stored.`);
|
|
|
162
225
|
return;
|
|
163
226
|
}
|
|
164
227
|
if (!rest.includes("--unverified")) {
|
|
165
|
-
const { textByHash } = loadCaptureTexts(config.category);
|
|
228
|
+
const { entries, textByHash } = loadCaptureTexts(config.category);
|
|
229
|
+
const usableVendorIds = new Set(
|
|
230
|
+
entries
|
|
231
|
+
.filter((entry) => entry.runLabel === set.runLabel && entry.captureHash && textByHash.has(entry.captureHash))
|
|
232
|
+
.map((entry) => entry.vendorId),
|
|
233
|
+
);
|
|
234
|
+
const unsupportedUnobservable = set.observations.filter((obs) => obs.intensity === "unobservable" && usableVendorIds.has(obs.vendorId));
|
|
235
|
+
if (unsupportedUnobservable.length > 0) {
|
|
236
|
+
console.error(`Rejected: ${unsupportedUnobservable.length} unobservable reading(s) for vendors with usable captures`);
|
|
237
|
+
for (const obs of unsupportedUnobservable.slice(0, 20)) {
|
|
238
|
+
console.error(` - ${obs.vendorId} × ${obs.claimId}: vendor has captured page text; unsupported claims should be absent, not unobservable`);
|
|
239
|
+
}
|
|
240
|
+
console.error("Use unobservable only when no usable captured page exists for that vendor/run. (--unverified skips this gate when captures genuinely live elsewhere.)");
|
|
241
|
+
process.exitCode = 1;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
166
244
|
const failures = verifyEvidenceSpans(set.observations, textByHash);
|
|
167
245
|
if (failures.length > 0) {
|
|
168
246
|
console.error(`Rejected: ${failures.length} evidence span(s) failed verification against the stored captures`);
|
|
@@ -195,6 +273,107 @@ recomputed deterministically on every invocation — never stored.`);
|
|
|
195
273
|
return;
|
|
196
274
|
}
|
|
197
275
|
|
|
276
|
+
if (subcommand === "hints") {
|
|
277
|
+
const vendorFilter = option(rest, "--vendor");
|
|
278
|
+
const vendorIds = vendorFilter ? [vendorFilter] : config.vendors.map((vendor) => vendor.id);
|
|
279
|
+
const worksheets = vendorIds.map((vendorId) => buildWorksheet(config, vendorId, { captureRun: option(rest, "--capture-run") ?? undefined }));
|
|
280
|
+
if (rest.includes("--json")) {
|
|
281
|
+
console.log(JSON.stringify({
|
|
282
|
+
category: config.category,
|
|
283
|
+
captureRun: worksheets[0]?.captureRun ?? null,
|
|
284
|
+
vendors: worksheets.map((worksheet) => ({ vendor: worksheet.vendor, claimHints: worksheet.claimHints })),
|
|
285
|
+
}, null, 2));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
for (const worksheet of worksheets) {
|
|
289
|
+
console.log(`\n## ${worksheet.vendor.id} — ${worksheet.vendor.name}`);
|
|
290
|
+
if (worksheet.pages.length === 0) {
|
|
291
|
+
console.log("No usable captures: classify every claim as unobservable.");
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
for (const hint of worksheet.claimHints) {
|
|
295
|
+
console.log(`\n${hint.claimId}`);
|
|
296
|
+
if (hint.matches.length === 0) {
|
|
297
|
+
console.log(" no lexical matches in captured text (usually absent; still inspect pages for synonyms)");
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
for (const match of hint.matches) console.log(` - ${match.term} @ ${match.url}: ${match.quote}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (subcommand === "observe-template") {
|
|
307
|
+
const runLabel = option(rest, "--run") ?? "run-1";
|
|
308
|
+
const now = new Date().toISOString();
|
|
309
|
+
const observations = config.vendors.flatMap((vendor) => {
|
|
310
|
+
const worksheet = buildWorksheet(config, vendor.id, { captureRun: runLabel });
|
|
311
|
+
const noPages = worksheet.pages.length === 0;
|
|
312
|
+
return config.claims.map((claim) => ({
|
|
313
|
+
id: observationId(config.category, runLabel, vendor.id, claim.id),
|
|
314
|
+
vendorId: vendor.id,
|
|
315
|
+
claimId: claim.id,
|
|
316
|
+
observedAt: now.slice(0, 10),
|
|
317
|
+
intensity: noPages ? "unobservable" : "absent",
|
|
318
|
+
confidence: "low",
|
|
319
|
+
reason: noPages ? `No usable captures for ${vendor.name}; cannot judge.` : "TODO: inspect worksheet/hints and classify this cell.",
|
|
320
|
+
evidence: [],
|
|
321
|
+
_claimHints: worksheet.claimHints.find((hint) => hint.claimId === claim.id)?.matches ?? [],
|
|
322
|
+
}));
|
|
323
|
+
});
|
|
324
|
+
const set = { id: `set_${runLabel}`, category: config.category, runLabel, runAt: now, extractor: "manual-template", observations };
|
|
325
|
+
const payload = `${JSON.stringify(set, null, 2)}\n`;
|
|
326
|
+
const outPath = option(rest, "--out");
|
|
327
|
+
if (outPath) {
|
|
328
|
+
writeFileSync(resolve(process.cwd(), outPath), payload);
|
|
329
|
+
console.log(`Wrote ${outPath}: ${observations.length} template observations. Fill TODO cells, then run market review --from ${outPath}`);
|
|
330
|
+
} else {
|
|
331
|
+
console.log(payload);
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (subcommand === "review") {
|
|
337
|
+
const fromPath = option(rest, "--from");
|
|
338
|
+
if (!fromPath) throw new Error("market review requires --from <observations.json>");
|
|
339
|
+
const set = JSON.parse(readFileSync(resolve(process.cwd(), fromPath), "utf8")) as ObservationSet;
|
|
340
|
+
const errors = validateObservationSet(config, set).map((detail) => ({ code: "invalid_observation_set", detail }));
|
|
341
|
+
const warnings: Array<{ code: string; detail: string }> = [];
|
|
342
|
+
const { entries, textByHash } = loadCaptureTexts(config.category);
|
|
343
|
+
const usableVendorIds = new Set(
|
|
344
|
+
entries
|
|
345
|
+
.filter((entry) => entry.runLabel === set.runLabel && entry.captureHash && textByHash.has(entry.captureHash))
|
|
346
|
+
.map((entry) => entry.vendorId),
|
|
347
|
+
);
|
|
348
|
+
for (const obs of set.observations) {
|
|
349
|
+
if (obs.intensity === "unobservable" && usableVendorIds.has(obs.vendorId)) {
|
|
350
|
+
errors.push({ code: "unobservable_with_captures", detail: `${obs.vendorId} × ${obs.claimId}: vendor has captured page text; use absent when unsupported.` });
|
|
351
|
+
}
|
|
352
|
+
if ((obs.intensity === "absent" || obs.intensity === "unobservable") && usableVendorIds.has(obs.vendorId)) {
|
|
353
|
+
const hints = buildWorksheet(config, obs.vendorId, { captureRun: set.runLabel }).claimHints.find((hint) => hint.claimId === obs.claimId)?.matches ?? [];
|
|
354
|
+
if (hints.length > 0) {
|
|
355
|
+
warnings.push({
|
|
356
|
+
code: "support_candidate_marked_absent",
|
|
357
|
+
detail: `${obs.vendorId} × ${obs.claimId}: ${hints.length} candidate snippet(s) matched terms but cell is ${obs.intensity}; verify absent vs quiet. Example: ${hints[0].quote}`,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
for (const failure of verifyEvidenceSpans(set.observations, textByHash)) {
|
|
363
|
+
errors.push({ code: "bad_evidence", detail: `${failure.vendorId} × ${failure.claimId}: ${failure.problem}` });
|
|
364
|
+
}
|
|
365
|
+
const summary = { ok: errors.length === 0, errors, warnings };
|
|
366
|
+
if (rest.includes("--json")) {
|
|
367
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
368
|
+
} else {
|
|
369
|
+
console.log(`${summary.ok ? "OK" : "FAILED"}: ${errors.length} error(s), ${warnings.length} warning(s)`);
|
|
370
|
+
for (const err of errors.slice(0, 20)) console.log(`ERROR ${err.code}: ${err.detail}`);
|
|
371
|
+
for (const warn of warnings.slice(0, 20)) console.log(`WARN ${warn.code}: ${warn.detail}`);
|
|
372
|
+
}
|
|
373
|
+
if (errors.length > 0) process.exitCode = 1;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
198
377
|
if (subcommand === "classify") {
|
|
199
378
|
const credential = await requireLlmCredential("market classify");
|
|
200
379
|
const vendorFilter = option(rest, "--vendor");
|
package/src/cli/plans.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { auditSnapshot, defaultPolicy } from "../audit.ts";
|
|
6
|
-
import { loadConfig, mergePolicy, resolveConfiguredRules } from "../config.ts";
|
|
6
|
+
import { loadConfig, mergePolicy, resolveConfiguredRules, rulePackageTrustFromCli } from "../config.ts";
|
|
7
7
|
import { applyPatchPlan } from "../connector.ts";
|
|
8
8
|
import { diffFindings, diffSnapshots, diffToMarkdown } from "../diff.ts";
|
|
9
9
|
import { createChannelConnector } from "../connectors/outboxChannel.ts";
|
|
@@ -234,6 +234,28 @@ export async function apply(args: string[]) {
|
|
|
234
234
|
// A channel (e.g. outbox) renders approved ops to a local artifact and
|
|
235
235
|
// transmits nothing; a CRM provider writes records. Same governed apply path.
|
|
236
236
|
const connector = channel ? createChannelConnector(channel) : await connectorFor(provider!, args);
|
|
237
|
+
let applyClaimId: string | undefined;
|
|
238
|
+
if (planId && store) {
|
|
239
|
+
const claimed = await store.claimApply(planId, { provider: provider ?? `channel:${channel}`, source: "cli" });
|
|
240
|
+
applyClaimId = claimed.claimId;
|
|
241
|
+
const claimedVerification = verifyApprovalDigests(
|
|
242
|
+
claimed.stored.plan.operations,
|
|
243
|
+
claimed.stored.approvedOperationIds,
|
|
244
|
+
claimed.stored.valueOverrides,
|
|
245
|
+
claimed.stored.approvalDigests,
|
|
246
|
+
);
|
|
247
|
+
if (!claimedVerification.ok) {
|
|
248
|
+
await store.abortApplyPreflight(
|
|
249
|
+
planId,
|
|
250
|
+
claimed.claimId,
|
|
251
|
+
"Approval integrity verification failed after the claim and before provider I/O.",
|
|
252
|
+
);
|
|
253
|
+
throw new Error(`Refusing to apply plan ${planId}: approval changed while acquiring the apply claim.`);
|
|
254
|
+
}
|
|
255
|
+
plan = claimed.stored.plan;
|
|
256
|
+
approvedOperationIds = claimed.stored.approvedOperationIds;
|
|
257
|
+
valueOverrides = claimed.stored.valueOverrides;
|
|
258
|
+
}
|
|
237
259
|
// Interactive terminals get a live apply board on stderr while the run
|
|
238
260
|
// executes (preflight → operations → results, with a per-op safety ticker);
|
|
239
261
|
// piped runs render nothing. Either way the emitter streams heartbeats to
|
|
@@ -249,11 +271,16 @@ export async function apply(args: string[]) {
|
|
|
249
271
|
valueOverrides,
|
|
250
272
|
progress,
|
|
251
273
|
});
|
|
274
|
+
} catch (error) {
|
|
275
|
+
// Once applyPatchPlan starts, an exception cannot prove that the provider
|
|
276
|
+
// performed no write. Keep the claim fail-closed for operator reconciliation.
|
|
277
|
+
if (planId && store && applyClaimId) await store.markApplyUncertain(planId, applyClaimId);
|
|
278
|
+
throw error;
|
|
252
279
|
} finally {
|
|
253
280
|
renderer.done();
|
|
254
281
|
}
|
|
255
282
|
if (planId && store) {
|
|
256
|
-
await store.recordRun(planId, run);
|
|
283
|
+
await store.recordRun(planId, run, applyClaimId!);
|
|
257
284
|
}
|
|
258
285
|
|
|
259
286
|
// Charge the acquire meter for the creates that actually landed.
|
|
@@ -307,8 +334,9 @@ export async function diffCommand(args: string[]) {
|
|
|
307
334
|
readFileSync(resolve(process.cwd(), afterPath), "utf8"),
|
|
308
335
|
) as CanonicalGtmSnapshot;
|
|
309
336
|
|
|
310
|
-
const
|
|
311
|
-
const
|
|
337
|
+
const explicitConfig = option(args, "--config") ?? undefined;
|
|
338
|
+
const loaded = loadConfig(explicitConfig);
|
|
339
|
+
const rules = selectedRules(args, await resolveConfiguredRules(loaded, undefined, rulePackageTrustFromCli(args, explicitConfig)));
|
|
312
340
|
const policy = mergePolicy(defaultPolicy(), loaded?.config);
|
|
313
341
|
const today = option(args, "--today");
|
|
314
342
|
if (today) policy.today = today;
|
|
@@ -455,6 +483,13 @@ export async function plansCommand(args: string[]) {
|
|
|
455
483
|
console.log(`Status: ${planStatusWord(stored.status, showPaint)}`);
|
|
456
484
|
console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
|
|
457
485
|
console.log(`Runs: ${stored.runs.length}`);
|
|
486
|
+
if (stored.applyAttempts?.length) {
|
|
487
|
+
console.log("Apply attempts:");
|
|
488
|
+
for (const attempt of stored.applyAttempts) {
|
|
489
|
+
console.log(` ${attempt.id} ${attempt.status} ${attempt.provider} via ${attempt.source} ${attempt.claimedAt}`);
|
|
490
|
+
if (attempt.note) console.log(` ${attempt.note}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
458
493
|
console.log("");
|
|
459
494
|
console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
|
|
460
495
|
return;
|
|
@@ -520,5 +555,31 @@ export async function plansCommand(args: string[]) {
|
|
|
520
555
|
return;
|
|
521
556
|
}
|
|
522
557
|
|
|
523
|
-
|
|
558
|
+
if (subcommand === "recover") {
|
|
559
|
+
const planId = rest.find((arg) => !arg.startsWith("--"));
|
|
560
|
+
if (!planId) {
|
|
561
|
+
throw new Error("Usage: fullstackgtm plans recover <planId> --acknowledge-uncertain-writes");
|
|
562
|
+
}
|
|
563
|
+
const stored = await store.get(planId);
|
|
564
|
+
if (!stored) throw new Error(`No stored plan with id ${planId}.`);
|
|
565
|
+
if (stored.status !== "applying" || !stored.applyClaim) {
|
|
566
|
+
throw new Error(`Plan ${planId} has no unresolved apply attempt.`);
|
|
567
|
+
}
|
|
568
|
+
const attempt = stored.applyAttempts?.find((entry) => entry.id === stored.applyClaim?.id);
|
|
569
|
+
if (!rest.includes("--acknowledge-uncertain-writes")) {
|
|
570
|
+
throw new Error(
|
|
571
|
+
`Plan ${planId} has an unresolved ${attempt?.provider ?? "provider"} apply attempt (${stored.applyClaim.id}). ` +
|
|
572
|
+
"Inspect the affected records in the provider; do not replay automatically. If you accept that some writes may already have landed, " +
|
|
573
|
+
`run \`fullstackgtm plans recover ${planId} --acknowledge-uncertain-writes\`. This clears every approval and requires a fresh review and approval before retrying.`,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
await store.recoverApply(planId);
|
|
577
|
+
console.log(
|
|
578
|
+
`Released ${planId} to needs_approval after acknowledging uncertain provider state. ` +
|
|
579
|
+
`No writes were replayed. Re-audit provider state, review the plan, then approve operations again before any apply.`,
|
|
580
|
+
);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
throw unknownSubcommandError("plans", subcommand, ["list", "show", "approve", "reject", "recover"]);
|
|
524
585
|
}
|
package/src/cli/ui.ts
CHANGED
|
@@ -449,6 +449,7 @@ export function severityWord(word: string, p: Paint): string {
|
|
|
449
449
|
export function planStatusWord(status: string, p: Paint): string {
|
|
450
450
|
if (status === "needs_approval") return p.yellow(status);
|
|
451
451
|
if (status === "approved" || status === "applied") return p.green(status);
|
|
452
|
+
if (status === "applying") return p.yellow(status);
|
|
452
453
|
if (status === "rejected") return p.red(status);
|
|
453
454
|
return status;
|
|
454
455
|
}
|
package/src/cli.ts
CHANGED
|
@@ -91,7 +91,7 @@ export async function runCli(argv: string[]) {
|
|
|
91
91
|
}
|
|
92
92
|
// Commands without bespoke help get focused per-command help on --help
|
|
93
93
|
// instead of executing (audit used to silently run the sample audit) or
|
|
94
|
-
// dumping the whole surface. call/market/enrich/
|
|
94
|
+
// dumping the whole surface. call/market/enrich/schedule print
|
|
95
95
|
// their own richer help. `--full` always escapes to the complete reference.
|
|
96
96
|
if (!BESPOKE_HELP.includes(command) && (args.includes("--help") || args.includes("-h"))) {
|
|
97
97
|
console.log(args.includes("--full") ? usage() : commandHelp(command));
|
package/src/config.ts
CHANGED
|
@@ -35,6 +35,35 @@ export type LoadedConfig = {
|
|
|
35
35
|
path: string;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
export type RulePackageTrust = {
|
|
39
|
+
/** Execute rule-package modules. Only set after an explicit trust decision. */
|
|
40
|
+
allowRulePackages?: boolean;
|
|
41
|
+
/** Deliberately ignore rule packages while still applying declarative config. */
|
|
42
|
+
disableRulePackages?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Translate the CLI's explicit trust flags into the library trust contract.
|
|
47
|
+
* Plugin execution requires both an explicit config path and --allow-plugins;
|
|
48
|
+
* discovering a config in the current directory is never a trust decision.
|
|
49
|
+
*/
|
|
50
|
+
export function rulePackageTrustFromCli(
|
|
51
|
+
args: string[],
|
|
52
|
+
explicitConfigPath?: string,
|
|
53
|
+
): RulePackageTrust {
|
|
54
|
+
const allow = args.includes("--allow-plugins");
|
|
55
|
+
const disable = args.includes("--no-plugins");
|
|
56
|
+
if (allow && disable) {
|
|
57
|
+
throw new Error("--allow-plugins and --no-plugins cannot be used together.");
|
|
58
|
+
}
|
|
59
|
+
if (allow && !explicitConfigPath) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
"--allow-plugins requires --config <path>. Plugin code is never trusted from an implicitly discovered config.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return { allowRulePackages: allow, disableRulePackages: disable };
|
|
65
|
+
}
|
|
66
|
+
|
|
38
67
|
export function loadConfig(
|
|
39
68
|
explicitPath?: string,
|
|
40
69
|
cwd = process.cwd(),
|
|
@@ -73,10 +102,19 @@ export function mergePolicy(base: GtmPolicy, config?: FullstackgtmConfig): GtmPo
|
|
|
73
102
|
export async function resolveConfiguredRules(
|
|
74
103
|
loaded?: LoadedConfig | null,
|
|
75
104
|
baseRules: GtmAuditRule[] = builtinAuditRules,
|
|
105
|
+
trust: RulePackageTrust = {},
|
|
76
106
|
): Promise<GtmAuditRule[]> {
|
|
77
107
|
let rules = [...baseRules];
|
|
78
108
|
|
|
79
|
-
|
|
109
|
+
const packages = loaded?.config.rulePackages ?? [];
|
|
110
|
+
if (packages.length && !trust.allowRulePackages && !trust.disableRulePackages) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Config ${loaded!.path} declares executable rulePackages. Refusing to load plugin code without explicit trust. ` +
|
|
113
|
+
"For the CLI, pass --config <path> --allow-plugins after reviewing the modules, or pass --no-plugins to use only declarative policy and built-in rules.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const specifier of trust.disableRulePackages ? [] : packages) {
|
|
80
118
|
const resolvedSpecifier =
|
|
81
119
|
specifier.startsWith(".") || isAbsolute(specifier)
|
|
82
120
|
? pathToFileURL(resolve(dirname(loaded!.path), specifier)).href
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* env/credential store, never argv.
|
|
15
15
|
*/
|
|
16
16
|
import type { CanonicalGtmSnapshot } from "../types.ts";
|
|
17
|
+
import { ProviderHttpError } from "../providerError.ts";
|
|
17
18
|
|
|
18
19
|
export type Prospect = {
|
|
19
20
|
firstName?: string;
|
|
@@ -79,7 +80,7 @@ export async function fetchExploriumProspects(opts: {
|
|
|
79
80
|
body: JSON.stringify({ mode: "full", size, page_size: size, page: 1, filters: opts.filters }),
|
|
80
81
|
});
|
|
81
82
|
if (!response.ok) {
|
|
82
|
-
throw new
|
|
83
|
+
throw new ProviderHttpError("Explorium", "prospect search", response.status);
|
|
83
84
|
}
|
|
84
85
|
const data = (await response.json()) as { data?: ExploriumRow[] };
|
|
85
86
|
return (data.data ?? []).map((row) => ({
|
|
@@ -132,7 +133,7 @@ export async function fetchPipe0CrustdataProspects(opts: {
|
|
|
132
133
|
}),
|
|
133
134
|
});
|
|
134
135
|
if (!response.ok) {
|
|
135
|
-
throw new
|
|
136
|
+
throw new ProviderHttpError("pipe0", "prospect search", response.status);
|
|
136
137
|
}
|
|
137
138
|
const body = (await response.json()) as {
|
|
138
139
|
results?: Array<Record<string, { value?: unknown }>>;
|
|
@@ -214,7 +215,7 @@ export async function probeExploriumBusinessCount(opts: {
|
|
|
214
215
|
body: JSON.stringify({ mode: "full", page_size: 1, page: 1, filters: opts.filters }),
|
|
215
216
|
});
|
|
216
217
|
if (!response.ok) {
|
|
217
|
-
throw new
|
|
218
|
+
throw new ProviderHttpError("Explorium", "business count", response.status);
|
|
218
219
|
}
|
|
219
220
|
const body = (await response.json()) as { total_results?: unknown };
|
|
220
221
|
const total = body.total_results;
|
|
@@ -427,14 +428,6 @@ function fieldValue(field: Pipe0Field | undefined): string | undefined {
|
|
|
427
428
|
return typeof v === "string" && v.trim() ? v : undefined;
|
|
428
429
|
}
|
|
429
430
|
|
|
430
|
-
async function safeText(response: Response): Promise<string> {
|
|
431
|
-
try {
|
|
432
|
-
return (await response.text()).slice(0, 300);
|
|
433
|
-
} catch {
|
|
434
|
-
return "";
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
431
|
// ---------------------------------------------------------------------------
|
|
439
432
|
// Pre-email dedup: identity keys shared between prospects and CRM contacts, so
|
|
440
433
|
// we can drop already-in-CRM / already-seen people BEFORE paying for emails.
|