fullstackgtm 0.25.2 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,104 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.27.0] — 2026-06-16
9
+
10
+ Trust, compliance & transparency — the artifacts a skeptical buyer's security
11
+ and procurement review asks for, plus an exportable audit trail and two
12
+ content-grounding fixes. Security-relevant additions were re-attacked before
13
+ release (the audit-log signing and the transcript gate each took two rounds).
14
+
15
+ ### Added
16
+
17
+ - **`audit-log export` / `audit-log verify`** — a tamper-evident record of every
18
+ apply run, flattened across all plans into a hash chain, with the head
19
+ HMAC-signed by the per-install key. Exports are always signed; `verify`
20
+ recomputes the chain and refuses an edited, reordered, truncated, or
21
+ signature-stripped log (and reports it as unverifiable on a machine without
22
+ the key). The change-management/SIEM artifact the prior audit flagged as
23
+ missing.
24
+ - **`SECURITY.md`** — disclosure address (security@fullstackgtm.com) and the
25
+ full trust model (credential custody, approval gating, approval-integrity
26
+ signing, scheduling, untrusted-input handling, auditability).
27
+ - **`DATA-FLOWS.md`** — exactly what data leaves the machine, to which endpoint,
28
+ for which command, and under whose account; the "CLI is BYO-key, no
29
+ vendor data path, no sub-processors" statement procurement needs; and how to
30
+ run the whole loop with zero third-party calls.
31
+ - **Company-of-record** — `package.json` author and a `NOTICE` file now name
32
+ Full Stack GTM with a contact; LICENSE unchanged (Apache-2.0).
33
+
34
+ ### Security
35
+
36
+ - **Call-transcript insight grounding.** LLM-extracted call insights are now
37
+ mechanically verified: the evidence quote must be a non-trivial verbatim span
38
+ of the transcript, and for `next_step` (the only insight whose text is written
39
+ to the CRM) the written action itself must be grounded in that quote — every
40
+ number/amount must appear in the quote, and the action's distinctive terms
41
+ must overlap it. This closes the prompt-injection path where a transcript
42
+ fabricates a malicious next step accompanied by an innocuous real quote. (This
43
+ is defense-in-depth on a human-approved path; a determined paraphrase-style
44
+ injection still surfaces to the approver as the proposed value.)
45
+
46
+ ### Changed
47
+
48
+ - README now states the design as **deterministic apply, governed suggest** and
49
+ cites the current 1,020-run / five-model benchmark (was a stale 612-run line);
50
+ a CI guard fails if the documented synthetic-scenario count drifts from the
51
+ code.
52
+
53
+ ## [0.26.0] — 2026-06-15
54
+
55
+ Write-path integrity — the "no write without approval" guarantee now binds to
56
+ operation *content*, and the two irreversible operations finally get a guard.
57
+ Each fix verified by a refute-by-default re-attack; the integrity binding took
58
+ three rounds (the re-attack kept finding unsigned fields that reach a write).
59
+
60
+ ### Added
61
+
62
+ - **Plan-approval integrity signatures.** `plans approve` now HMAC-signs each
63
+ approved operation's full apply-relevant content (operation, object, field,
64
+ before/after value, group, preconditions, force flags, the approved value
65
+ override, and reason) with a per-install key (`$FSGTM_HOME/.plan-signing-key`,
66
+ 0600). `apply --plan-id` re-verifies and refuses the whole apply if any
67
+ approved operation changed since approval, if the plan was approved without
68
+ signatures (downgrade guard), or if the signing key is absent (a plan
69
+ approved on another machine fails closed). The invariant: **what gets written
70
+ equals what the human signed** — a plan file edited between approval and apply
71
+ (by a synced/backed-up copy, a co-tenant, or a compromised dependency) is
72
+ caught instead of executed. apply-time `--value` is folded into the check, and
73
+ a scheduled `apply` may not take `--value` (it must write exactly the signed
74
+ values).
75
+ - **Recovery snapshots on irreversible operations.** `dedupe` merge ops and
76
+ `bulk-update --archive` ops now carry `recoverySnapshot` — the field values of
77
+ every record that will be destroyed — so the rollback instruction ("recreate
78
+ it by hand") is backed by actual data in the plan, which is the backup.
79
+
80
+ ### Security
81
+
82
+ - **Apply-time guard against destroying duplicates (the benchmark self-own).**
83
+ `apply` now refuses any `archive_record` whose target still shares an identity
84
+ key (account domain / contact email) with another live record — unless the
85
+ human explicitly forced it (`--force-archive-duplicates`, which is recorded on
86
+ the operation and signed). This catches every path (agent-driven, hand-edited,
87
+ audit), not just `bulk-update`, so an agent on a dedupe task can no longer
88
+ silently archive a record where it should merge — the rail is safe regardless
89
+ of model strength.
90
+ - **Drift guard for irreversible operations.** `merge_records` and
91
+ `archive_record` got no compare-and-set (there is no single field to compare).
92
+ Apply now checks a fresh snapshot: a merge whose survivor is gone, or whose
93
+ duplicates are already merged, and an archive of a record that no longer
94
+ exists, all conflict out instead of firing an irreversible, replay-unsafe write.
95
+
96
+ ### Notes
97
+
98
+ - The CAS empty/null equivalence in field compare-and-set is intentional (CRMs
99
+ normalize `""`↔`null` server-side; distinguishing them would cause false
100
+ conflicts). Known residuals for a follow-up: the archive-duplicate guard keys
101
+ on domain/email only, so `dedupe --key name` (and deal dedupe, which has no
102
+ identity key) are not guarded against destructive archive — use `dedupe`
103
+ (merge) for those; and `marketMapToMarkdown` is not HTML-escaped (the HTML
104
+ report is).
105
+
8
106
  ## [0.25.2] — 2026-06-15
9
107
 
10
108
  Security hardening I — confirmed fixes from an adversarial audit (each verified
package/DATA-FLOWS.md ADDED
@@ -0,0 +1,52 @@
1
+ # Data flows & trust boundary
2
+
3
+ A procurement / security review needs to know exactly what data leaves the
4
+ machine, to which endpoint, and under whose account. This is that enumeration
5
+ for the open-source `fullstackgtm` CLI. The short version: **the CLI is
6
+ bring-your-own-key and talks directly to services you already control — there
7
+ is no fullstackgtm-operated server in the data path for the open package.**
8
+
9
+ ## What stays local
10
+
11
+ - CRM snapshots, patch plans, approvals, apply-run records, market captures and
12
+ observations, enrich run state, and the signing/credential stores all live
13
+ under `$FSGTM_HOME` (default `~/.fullstackgtm`), `0600`/`0700`. Nothing is
14
+ uploaded to Full Stack GTM.
15
+ - No telemetry, analytics, or phone-home. The core package has zero runtime
16
+ dependencies; the only network calls are the ones listed below, all to
17
+ endpoints you configure.
18
+
19
+ ## What leaves the machine, by command
20
+
21
+ | Command(s) | Destination | Data sent | Auth |
22
+ |---|---|---|---|
23
+ | `snapshot`, `audit`, `apply`, `resolve`, `bulk-update`, `dedupe`, `reassign`, `fix`, `enrich` (writeback) | **Your CRM** (HubSpot / Salesforce / Stripe API) | Reads: your CRM records. Writes: only approved patch operations. | Your CRM token (env / stored / broker) |
24
+ | `call parse`, `call score`, `market classify`, `market refresh` | **Your LLM provider** (api.anthropic.com or api.openai.com) | The call transcript / captured competitor page text you point at, plus the extraction prompt | Your `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` (BYO) |
25
+ | `enrich append --source apollo`, `enrich refresh` | **Apollo** (api.apollo.io) | The company domain / contact email being enriched | Your `APOLLO_API_KEY` (BYO) |
26
+ | `market capture`, `market refresh` | **Public vendor websites** you list in `market.config.json` | An HTTP GET (no data sent beyond the request); SSRF-guarded to public hosts only | none |
27
+ | `login --via <url>` (optional) | **Your hosted deployment's broker** | A pairing handshake; the broker mints short-lived CRM tokens | broker pairing token |
28
+
29
+ Commands not listed (`plans`, `rules`, `doctor`, `schedule`, `audit-log`,
30
+ `diff`, `merge`, report rendering) make **no network calls**.
31
+
32
+ ## Avoiding third-party data egress
33
+
34
+ - **LLM verbs are optional.** `call parse --deterministic` uses a free,
35
+ offline keyword baseline (no LLM call). `market worksheet` lets an agent or
36
+ human classify without the CLI making an LLM call. A regulated deployment can
37
+ run the full audit → plan → apply loop with **zero third-party calls** —
38
+ CRM-only.
39
+ - **No data is sent for training.** Anthropic, OpenAI, and Apollo are reached
40
+ with your own API keys under your own agreements; their data-handling terms
41
+ (and any DPA you have with them) govern that traffic. Full Stack GTM is not
42
+ in that path and is not a sub-processor for the open-source CLI.
43
+
44
+ ## Sub-processors
45
+
46
+ For the **open-source CLI**: none (BYO-key, direct-to-service). The data
47
+ controllers are you and the providers whose keys you supply.
48
+
49
+ For the **hosted application** (a separate, proprietary product — not this
50
+ package): a sub-processor list and DPA are provided through that product's
51
+ agreement. If you are evaluating the hosted product, request them from
52
+ security@fullstackgtm.com.
package/NOTICE ADDED
@@ -0,0 +1,5 @@
1
+ fullstackgtm
2
+ Copyright 2026 Full Stack GTM
3
+
4
+ This product is developed and maintained by Full Stack GTM (https://fullstackgtm.com).
5
+ Licensed under the Apache License, Version 2.0 (see LICENSE).
package/README.md CHANGED
@@ -219,7 +219,9 @@ fullstackgtm diff --before old.json --after new.json --fail-on-new-findings
219
219
  - `--demo` (with `--seed`) generates a realistic mid-market CRM with injected real-world failure modes — departed owners, unlinked deals, orphan accounts, stale pipeline — so agents and CI can exercise the full snapshot → audit → apply pipeline with zero credentials.
220
220
  - Exit codes: `0` success, `1` error, `2` findings at/above `--fail-on`.
221
221
 
222
- "Built for agents" is measured, not asserted: a 612-run benchmark (17 scenarios × 3 tool-surface arms × 4 trials, deterministic graders over final CRM state, τ-bench-style pass^k) shows the gated CLI surface beating raw CRM-API access on completion-under-policy for every model tested. Full matrix and methodology: [the leaderboard](./evals/crm/leaderboard/RESULTS.md).
222
+ "Built for agents" is measured, not asserted: a 1,020-run benchmark (17 scenarios = 14 synthetic + 3 seeded from an anonymized real portal, × 3 tool-surface arms × 4 trials, across five models from three vendors, deterministic graders over final CRM state, τ-bench-style pass^k) shows the gated CLI surface beating raw CRM-API access on completion-under-policy for every model tested — and the tool-surface effect is monotonic and vendor-independent. Full matrix and methodology: [the leaderboard](./evals/crm/leaderboard/RESULTS.md).
223
+
224
+ The design is **deterministic apply, governed suggest**: the parts that touch your CRM — the audit rules, the plan/apply contract, compare-and-set, the survivor/merge logic — are deterministic and replayable; the parts that read free text (`call parse`/`score`, `market classify`) are LLM-powered but bounded, with every quoted span mechanically verified against the source before it can drive a writeback. Nondeterministic suggestion, deterministic governance.
223
225
 
224
226
  ## Authentication: CLI-first, browser only at the consent moment
225
227
 
package/SECURITY.md ADDED
@@ -0,0 +1,69 @@
1
+ # Security Policy
2
+
3
+ fullstackgtm reads and writes live CRM data under the operator's own
4
+ credentials. We take its security posture seriously and design the write path
5
+ to fail closed. This document is the disclosure process and the trust model a
6
+ security reviewer needs.
7
+
8
+ ## Reporting a vulnerability
9
+
10
+ Email **security@fullstackgtm.com** with a description and, ideally, a
11
+ reproduction. Please do not open a public issue for a security report. We aim
12
+ to acknowledge within 3 business days and to ship a fix or mitigation before
13
+ any public disclosure. There is no bounty program yet; credit is given in the
14
+ changelog unless you prefer otherwise.
15
+
16
+ Supported version: the latest published `0.x` release on npm. Fixes land on the
17
+ newest version, not backported (the project is pre-1.0).
18
+
19
+ ## Trust model
20
+
21
+ **Credentials.** API tokens are never accepted as command-line arguments
22
+ (they would leak into the process table and shell history); they come from an
23
+ environment variable or stdin only, and are stored `0600` under a `0700` home
24
+ (`$FSGTM_HOME`, default `~/.fullstackgtm`), re-tightened on read. This is the
25
+ same custody model as the `gcloud`/`aws` CLIs. The hosted broker
26
+ (`login --via`) exists so a team can connect a CRM once, server-side, and hand
27
+ laptops only a revocable pairing token instead of a long-lived super-admin key.
28
+
29
+ **Writes are approval-gated.** Reads are safe by default. Every change is a
30
+ typed patch operation in a dry-run plan that a human must approve before
31
+ `apply`. `apply` writes only operations whose ids were explicitly approved,
32
+ refuses operations carrying unresolved placeholder values, and uses
33
+ compare-and-set against the live CRM so a value that drifted since the plan was
34
+ built becomes a conflict, not a clobber. Irreversible operations (merge,
35
+ archive) get a fresh-snapshot drift guard, and archiving a record that still
36
+ shares an identity key with another is refused (it's a duplicate — merge it).
37
+
38
+ **Approval integrity.** At approval time each operation's apply-relevant content
39
+ is HMAC-signed with a per-install key (`$FSGTM_HOME/.plan-signing-key`, `0600`).
40
+ `apply --plan-id` re-verifies; a plan edited after approval — by a synced copy,
41
+ another process, or a compromised dependency — is refused rather than executed.
42
+ The invariant: **what gets written equals what the human signed.** A plan
43
+ approved on one machine cannot be applied on another (the key does not travel).
44
+ Documented boundary: this defends the plan file, not an attacker who already
45
+ holds the signing key (same directory and permissions as the credential store).
46
+
47
+ **Scheduling never auto-approves.** Scheduled (cron) runs are restricted to a
48
+ read/plan-side allowlist plus `apply --plan-id` whose approved status and
49
+ signatures are re-checked at every firing. Arbitrary shell is not schedulable.
50
+
51
+ **Untrusted input.** Competitor pages fetched by `market capture` are guarded
52
+ against SSRF (scheme allowlist; private/loopback/link-local/metadata addresses
53
+ refused; redirects re-validated). LLM-extracted call insights and market
54
+ classifications are mechanically verified verbatim against the source text
55
+ before they can drive a writeback, so a prompt-injected transcript or page
56
+ cannot fabricate a grounded-looking change. CSV/formula-injection in ingested
57
+ data is neutralized before it reaches a write.
58
+
59
+ **Auditability.** `audit-log export` produces a hash-chained, install-signed
60
+ record of every apply run for change-management/SIEM ingestion; `audit-log
61
+ verify` detects any edit or reorder.
62
+
63
+ ## Data flows
64
+
65
+ What leaves the machine, to whom, and for which command is enumerated in
66
+ [DATA-FLOWS.md](./DATA-FLOWS.md). In brief: the core CLI is BYO-key and talks
67
+ directly to your CRM and (only for LLM/enrichment verbs you invoke) to your
68
+ chosen Anthropic/OpenAI/Apollo accounts — there is no fullstackgtm-operated
69
+ data path for the open-source package.
@@ -0,0 +1,58 @@
1
+ import type { PatchPlanRun } from "./types.ts";
2
+ import type { StoredPlan } from "./planStore.ts";
3
+ /**
4
+ * Exportable, tamper-evident audit log.
5
+ *
6
+ * Every apply run is already recorded per-plan in the store, but a compliance /
7
+ * change-management process needs ONE portable artifact it can archive and
8
+ * later prove was not edited. `audit-log export` flattens every run across all
9
+ * plans into a hash-chained sequence: each entry carries the hash of the
10
+ * previous entry, so removing, reordering, or editing any entry breaks the
11
+ * chain at that point and `audit-log verify` reports exactly where. When a
12
+ * per-install signing key exists, the chain head is also HMAC-signed, so the
13
+ * export can be attributed to this installation, not just shown internally
14
+ * consistent.
15
+ *
16
+ * This is a point-in-time attestation of the stored run history; it is not a
17
+ * real-time append-only journal (that is future work). It answers "give me an
18
+ * auditable record of every change this tool applied, that my auditor can
19
+ * verify hasn't been doctored."
20
+ */
21
+ export type AuditLogEntry = {
22
+ seq: number;
23
+ planId: string;
24
+ planTitle: string;
25
+ provider: string;
26
+ startedAt: string;
27
+ finishedAt: string;
28
+ status: PatchPlanRun["status"];
29
+ trigger: string;
30
+ /** operationId → status, the per-operation outcome of this run */
31
+ operations: Array<{
32
+ operationId: string;
33
+ status: string;
34
+ detail?: string;
35
+ }>;
36
+ prevHash: string;
37
+ hash: string;
38
+ };
39
+ export type AuditLogExport = {
40
+ version: 1;
41
+ generatedAt: string;
42
+ entryCount: number;
43
+ chainHead: string;
44
+ /** HMAC of chainHead with the per-install key, or null when no key exists. */
45
+ signature: string | null;
46
+ entries: AuditLogEntry[];
47
+ };
48
+ /** Flatten all runs from the stored plans, oldest first, into chained entries. */
49
+ export declare function buildAuditLog(plans: StoredPlan[], generatedAt: string): AuditLogExport;
50
+ export type AuditLogVerification = {
51
+ ok: boolean;
52
+ /** seq of the first entry whose hash does not verify, or null if the chain holds */
53
+ brokenAt: number | null;
54
+ signatureOk: boolean | null;
55
+ detail: string;
56
+ };
57
+ /** Recompute the chain (and the signature if a key is available). */
58
+ export declare function verifyAuditLog(log: AuditLogExport): AuditLogVerification;
@@ -0,0 +1,112 @@
1
+ import { createHash, createHmac } from "node:crypto";
2
+ import { loadOrCreateSigningKey, loadSigningKey } from "./integrity.js";
3
+ const GENESIS = "0".repeat(64);
4
+ /** The content that the chain hash covers — everything but prevHash/hash. */
5
+ function entryContent(entry) {
6
+ return JSON.stringify([
7
+ entry.seq,
8
+ entry.planId,
9
+ entry.planTitle,
10
+ entry.provider,
11
+ entry.startedAt,
12
+ entry.finishedAt,
13
+ entry.status,
14
+ entry.trigger,
15
+ entry.operations,
16
+ ]);
17
+ }
18
+ function chainHash(prevHash, content) {
19
+ return createHash("sha256").update(prevHash).update("\n").update(content).digest("hex");
20
+ }
21
+ /** Flatten all runs from the stored plans, oldest first, into chained entries. */
22
+ export function buildAuditLog(plans, generatedAt) {
23
+ const runs = [];
24
+ for (const stored of plans) {
25
+ for (const run of stored.runs ?? [])
26
+ runs.push({ stored, run });
27
+ }
28
+ runs.sort((a, b) => a.run.finishedAt.localeCompare(b.run.finishedAt));
29
+ const entries = [];
30
+ let prevHash = GENESIS;
31
+ runs.forEach(({ stored, run }, index) => {
32
+ const base = {
33
+ seq: index,
34
+ planId: run.planId,
35
+ planTitle: stored.plan.title,
36
+ provider: run.provider,
37
+ startedAt: run.startedAt,
38
+ finishedAt: run.finishedAt,
39
+ status: run.status,
40
+ trigger: run.trigger ?? "manual",
41
+ operations: run.results.map((result) => ({
42
+ operationId: result.operationId,
43
+ status: result.status,
44
+ ...(result.detail ? { detail: result.detail } : {}),
45
+ })),
46
+ };
47
+ const hash = chainHash(prevHash, entryContent(base));
48
+ entries.push({ ...base, prevHash, hash });
49
+ prevHash = hash;
50
+ });
51
+ // Always sign — an unsigned export's keyless sha256 chain is self-recomputable
52
+ // (an attacker can edit entries and rebuild the chain from the public genesis),
53
+ // so the per-install HMAC is the only real tamper barrier. Bind the header
54
+ // fields into the signed material so metadata can't be altered either.
55
+ const key = loadOrCreateSigningKey();
56
+ const entryCount = entries.length;
57
+ return {
58
+ version: 1,
59
+ generatedAt,
60
+ entryCount,
61
+ chainHead: prevHash,
62
+ signature: signHead(key, 1, generatedAt, entryCount, prevHash),
63
+ entries,
64
+ };
65
+ }
66
+ function signHead(key, version, generatedAt, entryCount, chainHead) {
67
+ return createHmac("sha256", key).update(JSON.stringify([version, generatedAt, entryCount, chainHead])).digest("hex");
68
+ }
69
+ /** Recompute the chain (and the signature if a key is available). */
70
+ export function verifyAuditLog(log) {
71
+ let prevHash = GENESIS;
72
+ for (const entry of log.entries) {
73
+ if (entry.prevHash !== prevHash) {
74
+ return { ok: false, brokenAt: entry.seq, signatureOk: null, detail: `Chain breaks at entry ${entry.seq}: prevHash does not match the previous entry's hash (an entry was removed, reordered, or edited).` };
75
+ }
76
+ const expected = chainHash(prevHash, entryContent(entry));
77
+ if (expected !== entry.hash) {
78
+ return { ok: false, brokenAt: entry.seq, signatureOk: null, detail: `Chain breaks at entry ${entry.seq}: its content was edited after export (hash mismatch).` };
79
+ }
80
+ prevHash = entry.hash;
81
+ }
82
+ if (prevHash !== log.chainHead) {
83
+ return { ok: false, brokenAt: log.entries.length, signatureOk: null, detail: "The recorded chainHead does not match the recomputed chain." };
84
+ }
85
+ // The keyless chain alone is self-recomputable, so a missing/stripped signature
86
+ // means the export is forgeable — refuse it. (Current exports are always
87
+ // signed; a null signature is an old/unsigned or a downgraded export.)
88
+ if (!log.signature) {
89
+ return {
90
+ ok: false,
91
+ brokenAt: null,
92
+ signatureOk: false,
93
+ detail: "Unsigned export: the hash chain alone is self-recomputable, so this log cannot be trusted (the signature is absent or was stripped). Re-export on the issuing install.",
94
+ };
95
+ }
96
+ const key = loadSigningKey();
97
+ if (!key) {
98
+ // A third party without the issuing install's key cannot verify attribution.
99
+ // The chain is internally consistent, but that is not proof of authenticity.
100
+ return {
101
+ ok: false,
102
+ brokenAt: null,
103
+ signatureOk: null,
104
+ detail: "Chain is internally consistent, but this machine has no signing key to verify the signature — authenticity is unattributed. Verify on the issuing install.",
105
+ };
106
+ }
107
+ const signatureOk = signHead(key, log.version, log.generatedAt, log.entryCount, prevHash) === log.signature;
108
+ if (!signatureOk) {
109
+ return { ok: false, brokenAt: null, signatureOk: false, detail: "Signature does not match this installation's key — the log was exported elsewhere, or its entries/metadata were altered after signing." };
110
+ }
111
+ return { ok: true, brokenAt: null, signatureOk: true, detail: `Verified ${log.entries.length} entries; chain intact and signature valid.` };
112
+ }
@@ -27,6 +27,7 @@
27
27
  * `account.ownerId`, `account.contactCount`; accounts get `contactCount`
28
28
  * and `openDealCount`.
29
29
  */
30
+ import { recoverableFields } from "./dedupe.js";
30
31
  import { normalizeDomain } from "./merge.js";
31
32
  import { stableHash } from "./rules.js";
32
33
  const FIELD_PATTERN = "[a-zA-Z][a-zA-Z0-9_]*(?:\\.[a-zA-Z][a-zA-Z0-9_]*)?";
@@ -319,7 +320,11 @@ export function buildBulkUpdatePlan(snapshot, options) {
319
320
  beforeValue: null,
320
321
  afterValue: null,
321
322
  riskLevel: "high",
322
- rollback: "Archived records can be restored from the provider's recycle bin within its retention window.",
323
+ // Carry the human's explicit force decision to the apply-time guard, and
324
+ // snapshot the record so it can be recreated if the archive was wrong.
325
+ ...(options.forceArchiveDuplicates ? { forceArchiveDuplicate: true } : {}),
326
+ recoverySnapshot: [recoverableFields(record)],
327
+ rollback: "Archived records can be restored from the provider's recycle bin within its retention window; recoverySnapshot also retains the field values.",
323
328
  });
324
329
  continue;
325
330
  }
package/dist/cli.d.ts CHANGED
@@ -33,7 +33,7 @@ export declare function doctorReport(env?: Record<string, string | undefined>):
33
33
  llm: {
34
34
  configured: boolean;
35
35
  provider: LlmProvider;
36
- source: "env" | "stored";
36
+ source: "stored" | "env";
37
37
  detail?: undefined;
38
38
  } | {
39
39
  configured: boolean;
package/dist/cli.js CHANGED
@@ -13,6 +13,8 @@ import { activeProfile, credentialsPath, DEFAULT_PROFILE, deleteCredential, getC
13
13
  import { generateDemoSnapshot } from "./demo.js";
14
14
  import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
15
15
  import { mergeSnapshots } from "./merge.js";
16
+ import { verifyApprovalDigests } from "./integrity.js";
17
+ import { buildAuditLog, verifyAuditLog } from "./auditLog.js";
16
18
  import { createFilePlanStore } from "./planStore.js";
17
19
  import { auditReportToHtml, auditReportToMarkdown } from "./report.js";
18
20
  import { builtinAuditRules } from "./rules.js";
@@ -22,6 +24,7 @@ import { captureMarket, computeFrontStates, createFileObservationStore, diffFron
22
24
  import { assessAxes, axesReportToText } from "./marketAxes.js";
23
25
  import { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, } from "./marketOverlay.js";
24
26
  import { computeScaleIndex, scaleReportToText } from "./marketScale.js";
27
+ import { suggestMarketConfig } from "./marketTaxonomy.js";
25
28
  import { buildWorksheet, classifyMarket } from "./marketClassify.js";
26
29
  import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
27
30
  import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
@@ -153,6 +156,7 @@ Usage:
153
156
  fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
154
157
  fullstackgtm apply --plan-id <id> --provider <name>
155
158
  fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
159
+ fullstackgtm audit-log export [--out <path>] | verify --in <path> tamper-evident apply-run record
156
160
  fullstackgtm rules [--json]
157
161
  fullstackgtm profiles [--json] list credential profiles
158
162
  fullstackgtm doctor [--json] check install, credentials, and next step
@@ -827,6 +831,8 @@ async function marketCommand(args) {
827
831
  if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
828
832
  console.log(`Usage:
829
833
  market init --category <name> [--out <path>] write a starter market.config.json
834
+ market init --category <name> --auto --vendor <url> [--vendor <url>...] [--anchor <url>] [--max-claims n]
835
+ LLM-propose vendors + claim taxonomy from seed pages (needs an API key)
830
836
  market capture [--config <path>] [--run <label>]
831
837
  market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model m] [--out <path>]
832
838
  market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
@@ -877,6 +883,27 @@ recomputed deterministically on every invocation — never stored.`);
877
883
  const outPath = resolve(process.cwd(), option(rest, "--out") ?? "market.config.json");
878
884
  if (existsSync(outPath))
879
885
  throw new Error(`${outPath} already exists — refusing to overwrite`);
886
+ if (rest.includes("--auto")) {
887
+ const vendorUrls = repeatedOption(rest, "--vendor");
888
+ if (vendorUrls.length === 0) {
889
+ throw new Error("market init --auto requires at least one --vendor <url> (the competitor homepages to seed from)");
890
+ }
891
+ const anchorUrl = option(rest, "--anchor");
892
+ const credential = await requireLlmCredential("market classify");
893
+ console.error(`Capturing ${vendorUrls.length} seed page(s) and proposing a claim taxonomy with ${credential.provider}…`);
894
+ const { config, unreadableVendorIds, model } = await suggestMarketConfig({
895
+ category,
896
+ vendors: vendorUrls.map((url) => ({ url, anchor: anchorUrl ? url === anchorUrl : false })),
897
+ llm: { ...credential, model: option(rest, "--model") ?? undefined },
898
+ maxClaims: numericOption(rest, "--max-claims"),
899
+ });
900
+ writeFileSync(outPath, `${JSON.stringify(config, null, 2)}\n`);
901
+ if (unreadableVendorIds.length > 0) {
902
+ console.error(`Note: no readable text for ${unreadableVendorIds.join(", ")} — excluded from taxonomy grounding.`);
903
+ }
904
+ console.log(`Wrote ${outPath}: ${config.vendors.length} vendors, ${config.claims.length} proposed claims (${model}). Review it, then: fullstackgtm market refresh`);
905
+ return;
906
+ }
880
907
  writeFileSync(outPath, `${JSON.stringify(starterMarketConfig(category), null, 2)}\n`);
881
908
  console.log(`Wrote ${outPath}. Fill in vendors and claims, then: fullstackgtm market capture`);
882
909
  return;
@@ -2256,6 +2283,52 @@ function readSuggestionValues(path, minConfidence, includeCreates) {
2256
2283
  }
2257
2284
  return { overrides, skipped };
2258
2285
  }
2286
+ async function auditLogCommand(args) {
2287
+ const [sub, ...rest] = args;
2288
+ if (!sub || sub === "--help" || sub === "-h" || (sub !== "export" && sub !== "verify")) {
2289
+ console.log(`Usage:
2290
+ audit-log export [--out <path>] [--json] hash-chained, signed record of every apply run
2291
+ audit-log verify [--in <path>] re-check an exported log's chain and signature
2292
+
2293
+ export flattens every apply run across all stored plans (this profile) into a
2294
+ tamper-evident chain — each entry carries the prior entry's hash, and the chain
2295
+ head is HMAC-signed with this install's key — so a change-management process can
2296
+ archive one file and later prove it was not edited. verify recomputes the chain
2297
+ and (if the signing key is present) the signature.`);
2298
+ return;
2299
+ }
2300
+ if (sub === "export") {
2301
+ const plans = await createFilePlanStore().list();
2302
+ const log = buildAuditLog(plans, new Date().toISOString());
2303
+ const payload = `${JSON.stringify(log, null, 2)}\n`;
2304
+ const outPath = option(rest, "--out");
2305
+ if (outPath) {
2306
+ writeFileSync(resolve(process.cwd(), outPath), payload);
2307
+ console.log(`Wrote ${outPath}: ${log.entryCount} run(s), chain head ${log.chainHead.slice(0, 12)}${log.signature ? " (signed)" : " (unsigned — no signing key on this install)"}.`);
2308
+ }
2309
+ else if (rest.includes("--json")) {
2310
+ console.log(payload);
2311
+ }
2312
+ else {
2313
+ console.log(`${log.entryCount} apply run(s); chain head ${log.chainHead.slice(0, 12)}${log.signature ? ", signed" : ", unsigned"}. Pass --out <path> to archive, or --json to print.`);
2314
+ }
2315
+ return;
2316
+ }
2317
+ // verify
2318
+ const inPath = option(rest, "--in");
2319
+ if (!inPath)
2320
+ throw new Error("audit-log verify requires --in <exported-log.json>");
2321
+ const log = JSON.parse(readFileSync(resolve(process.cwd(), inPath), "utf8"));
2322
+ const result = verifyAuditLog(log);
2323
+ if (rest.includes("--json")) {
2324
+ console.log(JSON.stringify(result, null, 2));
2325
+ }
2326
+ else {
2327
+ console.log(result.ok ? `OK — ${result.detail}` : `TAMPERED — ${result.detail}`);
2328
+ }
2329
+ if (!result.ok)
2330
+ process.exitCode = 2;
2331
+ }
2259
2332
  async function apply(args) {
2260
2333
  const provider = option(args, "--provider");
2261
2334
  if (!provider)
@@ -2277,7 +2350,32 @@ async function apply(args) {
2277
2350
  }
2278
2351
  plan = stored.plan;
2279
2352
  approvedOperationIds = stored.approvedOperationIds;
2353
+ // Downgrade guard: an approved plan with no signatures is either pre-0.26
2354
+ // (re-approve to gain them) or had its approvalDigests stripped to skip the
2355
+ // integrity check. Either way, refuse rather than fall back to trusting the
2356
+ // file. (A plan with zero approved operations has nothing to apply anyway.)
2357
+ if (stored.approvedOperationIds.length > 0 && !stored.approvalDigests) {
2358
+ throw new Error(`Refusing to apply plan ${planId}: it was approved without integrity signatures ` +
2359
+ "(approved before 0.26.0, or its signatures were removed). Re-approve it with " +
2360
+ `\`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`);
2361
+ }
2362
+ // Integrity gate: the plan file is re-read from disk, so verify each approved
2363
+ // operation still matches what was signed at approval. Verify against the
2364
+ // EFFECTIVE overrides (stored ∪ apply-time --value): the invariant is "what
2365
+ // gets written must equal what was signed", so an apply-time --value that
2366
+ // changes a value the human did not approve is treated as tamper, not a live
2367
+ // override. A mismatch means the plan/overrides were edited after approval —
2368
+ // refuse the whole apply rather than write an unapproved value.
2280
2369
  valueOverrides = { ...stored.valueOverrides, ...parseValueOverrides(args) };
2370
+ const verification = verifyApprovalDigests(stored.plan.operations, stored.approvedOperationIds, valueOverrides, stored.approvalDigests);
2371
+ if (!verification.ok) {
2372
+ const detail = verification.reason === "no_key"
2373
+ ? "the plan-signing key is missing (was this plan approved on another machine?). Re-approve it here with `fullstackgtm plans approve`."
2374
+ : `these operations differ from what was approved: ${verification.tampered.join(", ")}. ` +
2375
+ "If you changed a value at apply time, set it at approval instead (`plans approve --value <op>=<v>`) and re-approve; " +
2376
+ "otherwise the plan was edited after approval — review and re-approve.";
2377
+ throw new Error(`Refusing to apply plan ${planId}: ${detail}`);
2378
+ }
2281
2379
  }
2282
2380
  else {
2283
2381
  const approve = option(args, "--approve");
@@ -3003,6 +3101,10 @@ export async function runCli(argv) {
3003
3101
  await plansCommand(args);
3004
3102
  return;
3005
3103
  }
3104
+ if (command === "audit-log") {
3105
+ await auditLogCommand(args);
3106
+ return;
3107
+ }
3006
3108
  if (command === "apply") {
3007
3109
  await apply(args);
3008
3110
  return;