fullstackgtm 0.25.2 → 0.26.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 +53 -0
- package/dist/bulkUpdate.js +6 -1
- package/dist/cli.js +50 -0
- package/dist/connector.js +90 -1
- package/dist/dedupe.d.ts +6 -0
- package/dist/dedupe.js +24 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/integrity.d.ts +30 -0
- package/dist/integrity.js +128 -0
- package/dist/marketTaxonomy.d.ts +41 -0
- package/dist/marketTaxonomy.js +193 -0
- package/dist/planStore.d.ts +6 -0
- package/dist/planStore.js +10 -2
- package/dist/schedule.js +4 -0
- package/dist/types.d.ts +16 -0
- package/package.json +1 -1
- package/src/bulkUpdate.ts +6 -1
- package/src/cli.ts +62 -0
- package/src/connector.ts +96 -1
- package/src/dedupe.ts +23 -1
- package/src/index.ts +8 -0
- package/src/integrity.ts +146 -0
- package/src/marketTaxonomy.ts +288 -0
- package/src/planStore.ts +23 -4
- package/src/schedule.ts +6 -0
- package/src/types.ts +16 -0
package/src/integrity.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.ts";
|
|
5
|
+
import type { PatchOperation } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Approval integrity.
|
|
9
|
+
*
|
|
10
|
+
* The plan store records WHICH operation ids a human approved, but the apply
|
|
11
|
+
* path re-reads the operation BODIES fresh from the (user-editable) plan file.
|
|
12
|
+
* Nothing bound the approval to the content: an approved op's afterValue or
|
|
13
|
+
* objectId could be changed on disk between `plans approve` and `apply` — by a
|
|
14
|
+
* compromised dependency, a co-tenant, or a plan file synced/edited on another
|
|
15
|
+
* machine — and the changed value would be written under the prior approval.
|
|
16
|
+
*
|
|
17
|
+
* Fix: at approval time, HMAC-sign each approved operation's security-relevant
|
|
18
|
+
* content (including the approved value override) with a per-install secret key
|
|
19
|
+
* stored 0600 alongside the credentials. At apply time, recompute and verify.
|
|
20
|
+
* Any post-approval edit to the operations or the approved overrides changes the
|
|
21
|
+
* signature; a tamper must now also forge an HMAC it cannot compute without the
|
|
22
|
+
* key. The key never leaves the machine, so a plan approved here and applied
|
|
23
|
+
* elsewhere fails closed ("re-approve on this machine") rather than open.
|
|
24
|
+
*
|
|
25
|
+
* This raises the bar from "trust the plan JSON" to "trust the plan JSON only
|
|
26
|
+
* insofar as it still matches what was signed with the local key." It is not a
|
|
27
|
+
* defense against an attacker who already holds the signing key (same-dir, same
|
|
28
|
+
* permissions as the credential store) — that is the documented boundary.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const SIGNING_KEY_FILE = ".plan-signing-key";
|
|
32
|
+
|
|
33
|
+
function signingKeyPath(): string {
|
|
34
|
+
return join(credentialsDir(), SIGNING_KEY_FILE);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read the signing key, or null if it has not been created yet. */
|
|
38
|
+
export function loadSigningKey(): Buffer | null {
|
|
39
|
+
const path = signingKeyPath();
|
|
40
|
+
if (!existsSync(path)) return null;
|
|
41
|
+
try {
|
|
42
|
+
return Buffer.from(readFileSync(path, "utf8").trim(), "hex");
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Read the signing key, creating a fresh 32-byte one (0600) on first use. */
|
|
49
|
+
export function loadOrCreateSigningKey(): Buffer {
|
|
50
|
+
const existing = loadSigningKey();
|
|
51
|
+
if (existing && existing.length >= 32) return existing;
|
|
52
|
+
ensureSecureHomeDir();
|
|
53
|
+
const key = randomBytes(32);
|
|
54
|
+
writeSecureFile(signingKeyPath(), `${key.toString("hex")}\n`);
|
|
55
|
+
return key;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Canonical, stable string of the operation content an approval binds to. Only
|
|
60
|
+
* the fields that determine WHAT gets written: changing any of them must
|
|
61
|
+
* invalidate the approval. `override` is the approved value override for this op
|
|
62
|
+
* (the value actually written when set), so tampering with stored overrides is
|
|
63
|
+
* caught too.
|
|
64
|
+
*/
|
|
65
|
+
function canonicalApprovalContent(operation: PatchOperation, override: unknown): string {
|
|
66
|
+
return JSON.stringify([
|
|
67
|
+
operation.id,
|
|
68
|
+
operation.operation,
|
|
69
|
+
operation.objectType,
|
|
70
|
+
operation.objectId,
|
|
71
|
+
operation.field ?? null,
|
|
72
|
+
operation.beforeValue ?? null,
|
|
73
|
+
operation.afterValue ?? null,
|
|
74
|
+
operation.groupId ?? null,
|
|
75
|
+
// Safety-relevant fields too: editing a precondition could relax a drift
|
|
76
|
+
// guard, and forging forceArchiveDuplicate could suppress the archive-of-
|
|
77
|
+
// duplicate refusal — the signed approval must pin apply BEHAVIOR, not just
|
|
78
|
+
// the written value. `reason` is human-reviewed AND written verbatim into
|
|
79
|
+
// create_task bodies (afterValue ?? reason fallback in the connectors), so a
|
|
80
|
+
// create_task with a null afterValue would otherwise let a disk edit to
|
|
81
|
+
// reason write unapproved text under a still-valid digest.
|
|
82
|
+
operation.preconditions ?? null,
|
|
83
|
+
operation.forceArchiveDuplicate ?? false,
|
|
84
|
+
operation.reason ?? null,
|
|
85
|
+
override === undefined ? null : ["__override__", override],
|
|
86
|
+
]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** HMAC-SHA256 signature of one operation's approved content. */
|
|
90
|
+
export function signApproval(operation: PatchOperation, override: unknown, key: Buffer): string {
|
|
91
|
+
return createHmac("sha256", key).update(canonicalApprovalContent(operation, override)).digest("hex");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Compute the approval signature map for a set of approved operation ids,
|
|
96
|
+
* resolving each op from the plan and its (approved) value override.
|
|
97
|
+
*/
|
|
98
|
+
export function computeApprovalDigests(
|
|
99
|
+
operations: PatchOperation[],
|
|
100
|
+
approvedOperationIds: string[],
|
|
101
|
+
valueOverrides: Record<string, unknown>,
|
|
102
|
+
key: Buffer,
|
|
103
|
+
): Record<string, string> {
|
|
104
|
+
const byId = new Map(operations.map((operation) => [operation.id, operation]));
|
|
105
|
+
const digests: Record<string, string> = {};
|
|
106
|
+
for (const id of approvedOperationIds) {
|
|
107
|
+
const operation = byId.get(id);
|
|
108
|
+
if (!operation) continue;
|
|
109
|
+
digests[id] = signApproval(operation, valueOverrides[id], key);
|
|
110
|
+
}
|
|
111
|
+
return digests;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type ApprovalVerification =
|
|
115
|
+
| { ok: true }
|
|
116
|
+
| { ok: false; reason: "no_key"; tampered: string[] }
|
|
117
|
+
| { ok: false; reason: "mismatch"; tampered: string[] };
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Verify that every approved operation still matches what was signed. Returns
|
|
121
|
+
* ok:true when there are no stored digests (a pre-integrity plan — nothing to
|
|
122
|
+
* verify), when all match, or fails with the list of operation ids whose
|
|
123
|
+
* content changed since approval.
|
|
124
|
+
*/
|
|
125
|
+
export function verifyApprovalDigests(
|
|
126
|
+
operations: PatchOperation[],
|
|
127
|
+
approvedOperationIds: string[],
|
|
128
|
+
valueOverrides: Record<string, unknown>,
|
|
129
|
+
storedDigests: Record<string, string> | undefined,
|
|
130
|
+
): ApprovalVerification {
|
|
131
|
+
if (!storedDigests || Object.keys(storedDigests).length === 0) return { ok: true };
|
|
132
|
+
const key = loadSigningKey();
|
|
133
|
+
if (!key) return { ok: false, reason: "no_key", tampered: approvedOperationIds };
|
|
134
|
+
const byId = new Map(operations.map((operation) => [operation.id, operation]));
|
|
135
|
+
const tampered: string[] = [];
|
|
136
|
+
for (const id of approvedOperationIds) {
|
|
137
|
+
const operation = byId.get(id);
|
|
138
|
+
const expected = storedDigests[id];
|
|
139
|
+
if (!operation || !expected) {
|
|
140
|
+
tampered.push(id);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (signApproval(operation, valueOverrides[id], key) !== expected) tampered.push(id);
|
|
144
|
+
}
|
|
145
|
+
return tampered.length === 0 ? { ok: true } : { ok: false, reason: "mismatch", tampered };
|
|
146
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_MODELS,
|
|
3
|
+
forcedToolCall,
|
|
4
|
+
type LlmCallOptions,
|
|
5
|
+
} from "./llm.ts";
|
|
6
|
+
import {
|
|
7
|
+
captureMarket,
|
|
8
|
+
type FetchPage,
|
|
9
|
+
loadCaptureTexts,
|
|
10
|
+
type MarketClaim,
|
|
11
|
+
type MarketConfig,
|
|
12
|
+
type MarketVendor,
|
|
13
|
+
} from "./market.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Cold-start taxonomy bootstrap. `market init` writes a stub for a human
|
|
17
|
+
* analyst to fill in; the self-serve hosted map has no analyst in the loop, so
|
|
18
|
+
* this proposes the claim taxonomy automatically from the seed vendors' own
|
|
19
|
+
* pages.
|
|
20
|
+
*
|
|
21
|
+
* Posture matches the rest of the market layer: the LLM is a *proposal* layer
|
|
22
|
+
* grounded in captured evidence (it only sees text we actually fetched), and
|
|
23
|
+
* everything downstream — capture, classify with verbatim-span verification,
|
|
24
|
+
* front states, the report — stays deterministic over the stored observations.
|
|
25
|
+
* The taxonomy it emits is a normal `market.config.json` a human can still edit.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type SeedVendor = {
|
|
29
|
+
url: string;
|
|
30
|
+
/** Display name; derived from the host when omitted. */
|
|
31
|
+
name?: string;
|
|
32
|
+
/** Marks the user's own company as the anchor vendor. */
|
|
33
|
+
anchor?: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type SuggestTaxonomyOptions = {
|
|
37
|
+
category: string;
|
|
38
|
+
vendors: SeedVendor[];
|
|
39
|
+
llm: LlmCallOptions;
|
|
40
|
+
/** Upper bound on proposed claims, to keep classification bounded. */
|
|
41
|
+
maxClaims?: number;
|
|
42
|
+
/** Per-vendor captured-text budget fed to the proposer (chars). */
|
|
43
|
+
perVendorChars?: number;
|
|
44
|
+
/** Test injectables. */
|
|
45
|
+
fetchPage?: FetchPage;
|
|
46
|
+
capturesDir?: string;
|
|
47
|
+
now?: () => Date;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type SuggestTaxonomyResult = {
|
|
51
|
+
config: MarketConfig;
|
|
52
|
+
/** Vendors whose homepage capture was empty/failed (excluded from grounding). */
|
|
53
|
+
unreadableVendorIds: string[];
|
|
54
|
+
model: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const DEFAULT_MAX_CLAIMS = 16;
|
|
58
|
+
const DEFAULT_PER_VENDOR_CHARS = 6_000;
|
|
59
|
+
|
|
60
|
+
/** Stable, human-readable id from a string (claim capability or host). */
|
|
61
|
+
function slugify(value: string, maxWords = 6): string {
|
|
62
|
+
const slug = value
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
65
|
+
.replace(/^-+|-+$/g, "")
|
|
66
|
+
.split("-")
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.slice(0, maxWords)
|
|
69
|
+
.join("-");
|
|
70
|
+
return slug || "item";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Second-level domain as a vendor id seed: https://www.stripe.com/ -> stripe. */
|
|
74
|
+
function vendorIdFromUrl(url: string): string {
|
|
75
|
+
let host: string;
|
|
76
|
+
try {
|
|
77
|
+
host = new URL(url).hostname;
|
|
78
|
+
} catch {
|
|
79
|
+
return slugify(url);
|
|
80
|
+
}
|
|
81
|
+
const labels = host.replace(/^www\./, "").split(".");
|
|
82
|
+
const sld = labels.length >= 2 ? labels[labels.length - 2] : labels[0];
|
|
83
|
+
return slugify(sld || host);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Disambiguate repeated ids by suffixing -2, -3, … */
|
|
87
|
+
function uniqueId(base: string, taken: Set<string>): string {
|
|
88
|
+
if (!taken.has(base)) {
|
|
89
|
+
taken.add(base);
|
|
90
|
+
return base;
|
|
91
|
+
}
|
|
92
|
+
for (let n = 2; ; n += 1) {
|
|
93
|
+
const candidate = `${base}-${n}`;
|
|
94
|
+
if (!taken.has(candidate)) {
|
|
95
|
+
taken.add(candidate);
|
|
96
|
+
return candidate;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function provisionalVendors(seeds: SeedVendor[]): MarketVendor[] {
|
|
102
|
+
const taken = new Set<string>();
|
|
103
|
+
return seeds.map((seed) => {
|
|
104
|
+
const id = uniqueId(vendorIdFromUrl(seed.url), taken);
|
|
105
|
+
const host = (() => {
|
|
106
|
+
try {
|
|
107
|
+
return new URL(seed.url).hostname.replace(/^www\./, "");
|
|
108
|
+
} catch {
|
|
109
|
+
return seed.url;
|
|
110
|
+
}
|
|
111
|
+
})();
|
|
112
|
+
return {
|
|
113
|
+
id,
|
|
114
|
+
name: seed.name?.trim() || host,
|
|
115
|
+
urls: { home: seed.url, pricing: null, product: [] },
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type ProposedClaim = {
|
|
121
|
+
capability: string;
|
|
122
|
+
icp: string;
|
|
123
|
+
pricingStructure: string;
|
|
124
|
+
definition: string;
|
|
125
|
+
terms?: string[];
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
type ProposedVendor = { seedUrl: string; name?: string; pricingUrl?: string | null };
|
|
129
|
+
|
|
130
|
+
const TAXONOMY_SCHEMA = {
|
|
131
|
+
type: "object",
|
|
132
|
+
required: ["claims"],
|
|
133
|
+
properties: {
|
|
134
|
+
surfaceRule: {
|
|
135
|
+
type: "string",
|
|
136
|
+
description:
|
|
137
|
+
"One sentence stating how a reader judges LOUD vs QUIET vs ABSENT for this category (e.g. hero/top-nav = LOUD, deeper pages = QUIET, nowhere = ABSENT).",
|
|
138
|
+
},
|
|
139
|
+
claims: {
|
|
140
|
+
type: "array",
|
|
141
|
+
description:
|
|
142
|
+
"The distinct capability positions vendors in this category compete on. 8-16 of them. Only include claims you can actually see evidence for on the supplied pages.",
|
|
143
|
+
items: {
|
|
144
|
+
type: "object",
|
|
145
|
+
required: ["capability", "icp", "pricingStructure", "definition"],
|
|
146
|
+
properties: {
|
|
147
|
+
capability: {
|
|
148
|
+
type: "string",
|
|
149
|
+
description: "What is being claimed, precise enough to judge loud/quiet/absent. Max ~10 words.",
|
|
150
|
+
},
|
|
151
|
+
icp: { type: "string", description: "Which buyer/ICP this claim cell addresses (category vocabulary)." },
|
|
152
|
+
pricingStructure: {
|
|
153
|
+
type: "string",
|
|
154
|
+
description: "Which pricing structure the claim implies (e.g. per-seat, usage-based, flat, free-tier).",
|
|
155
|
+
},
|
|
156
|
+
definition: {
|
|
157
|
+
type: "string",
|
|
158
|
+
description:
|
|
159
|
+
"Operational definition a human (or classifier) uses to score any vendor's page LOUD/QUIET/ABSENT on this claim.",
|
|
160
|
+
},
|
|
161
|
+
terms: {
|
|
162
|
+
type: "array",
|
|
163
|
+
items: { type: "string" },
|
|
164
|
+
description: "Exact buyer phrasings for this claim, for deterministic mention matching. 2-5 terms.",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
vendors: {
|
|
170
|
+
type: "array",
|
|
171
|
+
description: "Optional refinements: a clean display name per seed URL, and a pricing-page URL if one is clearly linked.",
|
|
172
|
+
items: {
|
|
173
|
+
type: "object",
|
|
174
|
+
required: ["seedUrl"],
|
|
175
|
+
properties: {
|
|
176
|
+
seedUrl: { type: "string" },
|
|
177
|
+
name: { type: "string" },
|
|
178
|
+
pricingUrl: { type: ["string", "null"] },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
} as const;
|
|
184
|
+
|
|
185
|
+
function buildDossier(
|
|
186
|
+
vendors: MarketVendor[],
|
|
187
|
+
capture: ReturnType<typeof loadCaptureTexts>,
|
|
188
|
+
perVendorChars: number,
|
|
189
|
+
): { dossier: string; unreadable: string[] } {
|
|
190
|
+
const { entries, textByHash } = capture;
|
|
191
|
+
const unreadable: string[] = [];
|
|
192
|
+
const blocks: string[] = [];
|
|
193
|
+
for (const vendor of vendors) {
|
|
194
|
+
const hash = entries.find((e) => e.vendorId === vendor.id && e.captureHash)?.captureHash ?? null;
|
|
195
|
+
const text = hash ? textByHash.get(hash) ?? "" : "";
|
|
196
|
+
if (!text.trim()) {
|
|
197
|
+
unreadable.push(vendor.id);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
blocks.push(`### ${vendor.name} (${vendor.urls.home})\n${text.slice(0, perVendorChars)}`);
|
|
201
|
+
}
|
|
202
|
+
return { dossier: blocks.join("\n\n"), unreadable };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const INSTRUCTIONS = `You are seeding a competitive "market map" for a category. A market map breaks the category into CLAIMS — the distinct capability positions vendors compete on — so each (vendor x claim) cell can later be scored LOUD / QUIET / ABSENT from that vendor's pages.
|
|
206
|
+
|
|
207
|
+
Propose the claim taxonomy for this category from the competitor homepages below. Rules:
|
|
208
|
+
- Ground every claim in what is actually visible on the supplied pages. Do not invent positions no vendor mentions.
|
|
209
|
+
- Each claim is a cell: a precise capability, the ICP it targets, and the pricing structure it implies.
|
|
210
|
+
- Write each definition so a reader could judge ANY vendor's page LOUD/QUIET/ABSENT against it.
|
|
211
|
+
- Aim for the 8-16 claims that genuinely differentiate vendors. Prefer specific, contested positions over generic table stakes.
|
|
212
|
+
- Provide 2-5 verbatim buyer terms per claim for later mention matching.
|
|
213
|
+
- Optionally return a cleaned display name and a pricing-page URL per seed vendor when evident.`;
|
|
214
|
+
|
|
215
|
+
export async function suggestMarketConfig(options: SuggestTaxonomyOptions): Promise<SuggestTaxonomyResult> {
|
|
216
|
+
const { category } = options;
|
|
217
|
+
if (options.vendors.length === 0) throw new Error("suggestMarketConfig requires at least one seed vendor");
|
|
218
|
+
const maxClaims = options.maxClaims ?? DEFAULT_MAX_CLAIMS;
|
|
219
|
+
const perVendorChars = options.perVendorChars ?? DEFAULT_PER_VENDOR_CHARS;
|
|
220
|
+
const model = options.llm.model ?? DEFAULT_MODELS[options.llm.provider];
|
|
221
|
+
|
|
222
|
+
const vendors = provisionalVendors(options.vendors);
|
|
223
|
+
const anchorSeed = options.vendors.find((seed) => seed.anchor);
|
|
224
|
+
const anchorId = anchorSeed ? vendors[options.vendors.indexOf(anchorSeed)]?.id : undefined;
|
|
225
|
+
|
|
226
|
+
// Capture the seed homepages so the proposer only sees text we actually
|
|
227
|
+
// fetched (the SSRF guard in captureMarket applies to these user-supplied URLs).
|
|
228
|
+
await captureMarket(
|
|
229
|
+
{ category, vendors, claims: [] },
|
|
230
|
+
{ dir: options.capturesDir, runLabel: "bootstrap", fetchPage: options.fetchPage, now: options.now },
|
|
231
|
+
);
|
|
232
|
+
const capture = loadCaptureTexts(category, options.capturesDir);
|
|
233
|
+
const { dossier, unreadable } = buildDossier(vendors, capture, perVendorChars);
|
|
234
|
+
if (!dossier.trim()) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`market init --auto: none of the ${vendors.length} seed pages returned readable text — check the URLs are public homepages.`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const prompt = `${INSTRUCTIONS}\n\nCategory: ${category}\n\nCompetitor homepages:\n${dossier}`;
|
|
241
|
+
const result = (await forcedToolCall(prompt, "propose_market_taxonomy", TAXONOMY_SCHEMA, model, options.llm)) as {
|
|
242
|
+
surfaceRule?: string;
|
|
243
|
+
claims?: ProposedClaim[];
|
|
244
|
+
vendors?: ProposedVendor[];
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const takenClaimIds = new Set<string>();
|
|
248
|
+
const claims: MarketClaim[] = (result.claims ?? [])
|
|
249
|
+
.filter((claim) => claim?.capability && claim?.definition)
|
|
250
|
+
.slice(0, maxClaims)
|
|
251
|
+
.map((claim) => ({
|
|
252
|
+
id: uniqueId(slugify(claim.capability), takenClaimIds),
|
|
253
|
+
capability: claim.capability.trim(),
|
|
254
|
+
icp: (claim.icp ?? "").trim() || "general",
|
|
255
|
+
pricingStructure: (claim.pricingStructure ?? "").trim() || "unspecified",
|
|
256
|
+
definition: claim.definition.trim(),
|
|
257
|
+
...(claim.terms?.length ? { terms: claim.terms.map((t) => t.trim()).filter(Boolean) } : {}),
|
|
258
|
+
}));
|
|
259
|
+
|
|
260
|
+
if (claims.length === 0) {
|
|
261
|
+
throw new Error("market init --auto: the model proposed no usable claims — try again or seed the taxonomy by hand.");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Apply optional vendor refinements (display name + pricing URL), matched by seed URL.
|
|
265
|
+
const refinementByUrl = new Map((result.vendors ?? []).map((v) => [v.seedUrl, v]));
|
|
266
|
+
const refinedVendors: MarketVendor[] = vendors.map((vendor) => {
|
|
267
|
+
const refinement = refinementByUrl.get(vendor.urls.home);
|
|
268
|
+
const pricing =
|
|
269
|
+
refinement?.pricingUrl && /^https?:\/\//i.test(refinement.pricingUrl) ? refinement.pricingUrl : vendor.urls.pricing;
|
|
270
|
+
return {
|
|
271
|
+
...vendor,
|
|
272
|
+
name: refinement?.name?.trim() || vendor.name,
|
|
273
|
+
urls: { ...vendor.urls, pricing },
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const config: MarketConfig = {
|
|
278
|
+
category,
|
|
279
|
+
...(anchorId ? { anchorVendor: anchorId } : {}),
|
|
280
|
+
vendors: refinedVendors,
|
|
281
|
+
claims,
|
|
282
|
+
surfaceRule:
|
|
283
|
+
result.surfaceRule?.trim() ||
|
|
284
|
+
"LOUD = hero copy OR top-level-nav named product with a dedicated page; QUIET = present on any indexed page below that; ABSENT = nowhere observed; UNOBSERVABLE = capture empty/failed — never score ABSENT from a failed capture.",
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
return { config, unreadableVendorIds: unreadable, model };
|
|
288
|
+
}
|
package/src/planStore.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { chmodSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.ts";
|
|
4
|
+
import { computeApprovalDigests, loadOrCreateSigningKey } from "./integrity.ts";
|
|
4
5
|
import type { ApprovalStatus, PatchPlan, PatchPlanRun } from "./types.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -16,6 +17,12 @@ export type StoredPlan = {
|
|
|
16
17
|
status: ApprovalStatus;
|
|
17
18
|
approvedOperationIds: string[];
|
|
18
19
|
valueOverrides: Record<string, unknown>;
|
|
20
|
+
/**
|
|
21
|
+
* HMAC of each approved operation's content at approval time (see
|
|
22
|
+
* integrity.ts). Apply re-verifies these so a post-approval edit to the plan
|
|
23
|
+
* file is caught instead of written. Absent on plans approved before 0.26.0.
|
|
24
|
+
*/
|
|
25
|
+
approvalDigests?: Record<string, string>;
|
|
19
26
|
runs: PatchPlanRun[];
|
|
20
27
|
createdAt: string;
|
|
21
28
|
updatedAt: string;
|
|
@@ -125,13 +132,25 @@ export function createFilePlanStore(directory?: string): PlanStore {
|
|
|
125
132
|
throw new Error(`Plan ${planId} has no operation ${operationId}.`);
|
|
126
133
|
}
|
|
127
134
|
}
|
|
135
|
+
const approvedOperationIds = Array.from(
|
|
136
|
+
new Set([...stored.approvedOperationIds, ...operationIds]),
|
|
137
|
+
);
|
|
138
|
+
const mergedOverrides = { ...stored.valueOverrides, ...valueOverrides };
|
|
139
|
+
// Bind the approval to the operation content so apply can detect a
|
|
140
|
+
// post-approval edit. Recompute over ALL approved ops (a later approve
|
|
141
|
+
// call may add overrides that change an earlier op's resolved value).
|
|
142
|
+
const approvalDigests = computeApprovalDigests(
|
|
143
|
+
stored.plan.operations,
|
|
144
|
+
approvedOperationIds,
|
|
145
|
+
mergedOverrides,
|
|
146
|
+
loadOrCreateSigningKey(),
|
|
147
|
+
);
|
|
128
148
|
return write({
|
|
129
149
|
...stored,
|
|
130
150
|
status: "approved",
|
|
131
|
-
approvedOperationIds
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
valueOverrides: { ...stored.valueOverrides, ...valueOverrides },
|
|
151
|
+
approvedOperationIds,
|
|
152
|
+
valueOverrides: mergedOverrides,
|
|
153
|
+
approvalDigests,
|
|
135
154
|
});
|
|
136
155
|
},
|
|
137
156
|
|
package/src/schedule.ts
CHANGED
|
@@ -124,6 +124,12 @@ export function validateSchedulableArgv(argv: string[]): void {
|
|
|
124
124
|
"plan store's approval state. Use `apply --plan-id <id>` and approve via `plans approve`.",
|
|
125
125
|
);
|
|
126
126
|
}
|
|
127
|
+
if (argv.includes("--value")) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"A scheduled apply cannot take --value — an unattended run must write exactly the values " +
|
|
130
|
+
"signed at approval. Set the value with `plans approve --value <op>=<v>` and re-approve.",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
127
133
|
return;
|
|
128
134
|
}
|
|
129
135
|
if (!Object.hasOwn(SCHEDULABLE, head)) {
|
package/src/types.ts
CHANGED
|
@@ -303,6 +303,22 @@ export type PatchOperation = {
|
|
|
303
303
|
* member of the group.
|
|
304
304
|
*/
|
|
305
305
|
groupId?: string;
|
|
306
|
+
/**
|
|
307
|
+
* Set only when a human explicitly chose to archive a record that shares an
|
|
308
|
+
* identity key with another (`bulk-update --archive --force-archive-duplicates`).
|
|
309
|
+
* Without it, apply refuses to archive_record a record the live snapshot still
|
|
310
|
+
* sees as a duplicate — archiving a duplicate discards data that merging keeps,
|
|
311
|
+
* and an agent on a dedupe task must not silently substitute archive for merge.
|
|
312
|
+
*/
|
|
313
|
+
forceArchiveDuplicate?: boolean;
|
|
314
|
+
/**
|
|
315
|
+
* For irreversible operations (merge_records, archive_record): the field
|
|
316
|
+
* values of the records that will be destroyed, captured at plan-build time.
|
|
317
|
+
* Merges and archives cannot be undone on any provider, so this is the
|
|
318
|
+
* recovery artifact a human uses to recreate a record by hand if a merge or
|
|
319
|
+
* archive was wrong — the plan file IS the backup.
|
|
320
|
+
*/
|
|
321
|
+
recoverySnapshot?: Record<string, unknown>[];
|
|
306
322
|
};
|
|
307
323
|
|
|
308
324
|
/**
|