Bladeren bron

feat(telemetry): greenfield dsh-sdk telemetry modules

Add @deepseek-ai/dsh-telemetry, a launcher-side (non-plugin) library for the
ConsentResolver (parses cordis.yml consent + DO_NOT_TRACK/CI), buildTelemetryPayload
(redacted cordis.yml + package.json full content, never .env), getOrCreateAnonymousId
(random UUID in a per-machine global config file), and TelemetryReporter
(fire-and-forget, never blocks or crashes the command).

Endpoint is a fixed .invalid placeholder pending the real endpoint. Launcher
dispatch wiring and the helper feature-catalog entry are intentionally out of
scope. Registers the package in tsconfig references, the module graph, and the
README model-experience audit map. Per-file 100% coverage.
imccyu 2 maanden geleden
bovenliggende
commit
fe668a6dfe

+ 3 - 0
docs/module-graph.md

@@ -140,6 +140,7 @@ flowchart TD
     pkg_helper["helper"]
     pkg_plugin_fetch["plugin-fetch"]
     pkg_scripts["scripts"]
+    pkg_telemetry["telemetry"]
   end
   subgraph group_tasks["packages/tasks"]
     pkg_tasks["tasks"]
@@ -155,6 +156,7 @@ flowchart TD
   pkg_helper --> pkg_brand
   pkg_plugin_fetch --> pkg_brand
   pkg_scripts --> pkg_app_boot
+  pkg_telemetry --> pkg_brand
   pkg_llm_deepseek --> pkg_llm
   pkg_llm_pi_ai --> pkg_llm
   pkg_session --> pkg_brand
@@ -446,6 +448,7 @@ flowchart TD
 | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand) |
 | [`plugin-fetch`](../packages/sdk/plugin-fetch) | `sdk` | [`brand`](../packages/util/brand) |
 | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot) |
+| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand) |
 | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) |
 | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) |
 | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |

+ 24 - 0
packages/sdk/telemetry/README.md

@@ -0,0 +1,24 @@
+# `@deepseek-ai/dsh-telemetry`
+
+Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain library the launcher imports around each command; it is **not** a Cordis plugin, because `build` and first-init `create` never boot Cordis. Wiring the reporter into the launcher command dispatch and adding the telemetry consent feature to the `dsh-helper` catalog live in their owning packages, not here.
+
+| Export | Role |
+|---|---|
+| `SecretRedactor` | Conservative safety backstop: replaces secret-shaped values (secret-like keys, known token shapes, PEM blocks, URL credentials, high-entropy opaque tokens) with a placeholder in both parsed values (`redactValue`) and raw text (`redactText`). Never drops a field or line. |
+| `ConsentResolver` | Parses (never boots) a project `cordis.yml` and reads the telemetry entry's enabled/disabled state as consent; `DO_NOT_TRACK`/CI env force a hard opt-out. |
+| `buildTelemetryPayload` | Assembles `{command, durationMs, success, cordisYmlContent, packageJsonContent}`, running the redactor over the full `cordis.yml` and `package.json` text. Never reads `.env`. |
+| `getOrCreateAnonymousId` | Random UUID persisted in a per-user GLOBAL config file (never in the project, never derived from git). |
+| `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. |
+
+Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. When `cordis.yml` does not yet exist (first `create`) consent defaults to allowed; when it exists without a telemetry entry consent defaults to denied — both are configurable on `ConsentResolver`.
+
+The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its `.invalid` placeholder must be replaced with the real endpoint before release.
+
+## Model Experience
+
+None, as the reporter sends developer-cycle telemetry from the launcher and never reaches a model request.
+
+## Known Limitations and Deferred Work
+
+- **Placeholder endpoint** — `DSH_TELEMETRY_ENDPOINT` points at `.invalid` until the real endpoint is set.
+- **Redaction is heuristic** — a conservative backstop, not a guarantee; secrets belong in `.env`, which is never read or reported.

+ 35 - 0
packages/sdk/telemetry/package.json

@@ -0,0 +1,35 @@
+{
+  "name": "@deepseek-ai/dsh-telemetry",
+  "description": "Launcher-side dsh-sdk telemetry: secret redaction, consent resolution, anonymous id, payload builder, and fire-and-forget reporter",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "dependencies": {
+    "yaml": "^2.9.0"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/dsh-brand": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-brand": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

+ 106 - 0
packages/sdk/telemetry/src/anonymous-id.ts

@@ -0,0 +1,106 @@
+/**
+ * Per-machine anonymous telemetry id.
+ *
+ * The id is a random UUID persisted in a per-user GLOBAL config file — never in
+ * the project, and never derived from the git remote, repository URL, or any
+ * other identifying source (a derived id would make "anonymous" a fiction). The
+ * same id is reused across projects on one machine so telemetry counts machines,
+ * not repositories.
+ *
+ * @module @deepseek-ai/dsh-telemetry/anonymous-id
+ */
+
+import { randomUUID } from 'node:crypto'
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { homedir } from 'node:os'
+import { dirname, join } from 'node:path'
+import type { Branded } from '@deepseek-ai/dsh-brand'
+
+/** A machine-scoped anonymous telemetry id (random UUID v4). */
+export type AnonymousId = Branded<'AnonymousId'>
+
+/** Config directory name owned by the DeepSeek Harness across tools. */
+const CONFIG_NAMESPACE = 'deepseek-harness'
+
+/** Default file, inside the global config dir, storing the anonymous id. */
+export const ANONYMOUS_ID_FILE_NAME = 'telemetry.json'
+
+const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+
+/** Ambient seams for locating and generating the id; every field has a default. */
+export interface AnonymousIdOptions {
+  /** Environment consulted for `DSH_CONFIG_HOME`/`XDG_CONFIG_HOME`/`APPDATA`; defaults to `process.env`. */
+  env?: NodeJS.ProcessEnv
+  /** Platform string used to pick the Windows path; defaults to `process.platform`. */
+  platform?: NodeJS.Platform
+  /** Home directory resolver; defaults to `os.homedir`. */
+  homeDir?: () => string
+  /** UUID generator; defaults to `crypto.randomUUID` (test seam). */
+  randomUUID?: () => string
+}
+
+/**
+ * Resolve the per-user global config directory for harness tooling.
+ * Precedence: `DSH_CONFIG_HOME` (explicit override) > `XDG_CONFIG_HOME` >
+ * platform default (`%APPDATA%` on Windows, else `~/.config`).
+ * @param options - environment, platform, and home-directory seams.
+ * @returns absolute config directory path for the harness namespace.
+ */
+export function globalConfigDir(options: AnonymousIdOptions = {}): string {
+  const env = options.env ?? process.env
+  const platform = options.platform ?? process.platform
+  const home = options.homeDir ?? homedir
+  if (env.DSH_CONFIG_HOME !== undefined && env.DSH_CONFIG_HOME.length > 0) return env.DSH_CONFIG_HOME
+  if (env.XDG_CONFIG_HOME !== undefined && env.XDG_CONFIG_HOME.length > 0) {
+    return join(env.XDG_CONFIG_HOME, CONFIG_NAMESPACE)
+  }
+  if (platform === 'win32' && env.APPDATA !== undefined && env.APPDATA.length > 0) {
+    return join(env.APPDATA, CONFIG_NAMESPACE)
+  }
+  return join(home(), '.config', CONFIG_NAMESPACE)
+}
+
+/** Read a valid persisted id from the store, or `undefined` when absent/corrupt. */
+async function readPersistedId(file: string): Promise<AnonymousId | undefined> {
+  let text: string
+  try {
+    text = await readFile(file, 'utf8')
+  } catch {
+    // Absent or unreadable: the caller mints and persists a fresh id.
+    return undefined
+  }
+  let parsed: unknown
+  try {
+    parsed = JSON.parse(text)
+  } catch {
+    // Corrupt JSON: the caller overwrites the store with a fresh id.
+    return undefined
+  }
+  if (parsed !== null && typeof parsed === 'object') {
+    const value = (parsed as Record<string, unknown>).anonymousId
+    if (typeof value === 'string' && UUID_PATTERN.test(value)) return value as AnonymousId
+  }
+  return undefined
+}
+
+/**
+ * Return the machine's anonymous id, creating and persisting one on first use.
+ * Persistence is best-effort: a write failure still returns a usable id for the
+ * current run so telemetry is never blocked by config-dir permissions.
+ * @param options - config-location and UUID-generation seams.
+ * @returns the stable per-machine anonymous id.
+ */
+export async function getOrCreateAnonymousId(options: AnonymousIdOptions = {}): Promise<AnonymousId> {
+  const file = join(globalConfigDir(options), ANONYMOUS_ID_FILE_NAME)
+  const existing = await readPersistedId(file)
+  if (existing !== undefined) return existing
+  const generate = options.randomUUID ?? randomUUID
+  const created = generate() as AnonymousId
+  try {
+    await mkdir(dirname(file), { recursive: true })
+    await writeFile(file, `${JSON.stringify({ anonymousId: created }, null, 2)}\n`, 'utf8')
+  } catch {
+    // Best-effort persistence: return the fresh id even when the store is unwritable.
+  }
+  return created
+}

+ 125 - 0
packages/sdk/telemetry/src/consent-resolver.ts

@@ -0,0 +1,125 @@
+/**
+ * Consent resolution for dsh-sdk telemetry.
+ *
+ * Consent is carried by the telemetry plugin's enabled/disabled state in the
+ * project `cordis.yml`: an enabled entry means opt-in, a `disabled: true` entry
+ * means opt-out. The resolver PARSES `cordis.yml` — it never boots a Cordis
+ * application — because several launcher commands (`build`, `create`) never
+ * boot Cordis at all. `DO_NOT_TRACK` and CI environment signals force a denial
+ * regardless of file state.
+ *
+ * @module @deepseek-ai/dsh-telemetry/consent-resolver
+ */
+
+import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { parseDocument, type ScalarTag } from 'yaml'
+
+/** Default `cordis.yml` entry name that carries telemetry consent. */
+export const DEFAULT_TELEMETRY_PLUGIN_NAME = '@deepseek-ai/dsh-telemetry'
+
+/**
+ * Passthrough for Cordis' `!!js` expression tag so parsing consent never fails
+ * on projects that inline JavaScript expressions; the resolver only reads plain
+ * `name`/`disabled` scalars and does not evaluate expressions.
+ */
+const JS_EXPRESSION_TAG: ScalarTag = {
+  tag: 'tag:yaml.org,2002:js',
+  resolve: value => value,
+}
+
+/** Why telemetry is or is not permitted for one command. */
+export type ConsentReason =
+  | 'enabled'
+  | 'disabled'
+  | 'absent'
+  | 'no-config'
+  | 'do-not-track'
+  | 'ci'
+  | 'unreadable'
+
+/** Resolved telemetry consent for one command invocation. */
+export interface ConsentDecision {
+  /** Whether telemetry may be sent. */
+  allowed: boolean
+  /** The signal that determined {@link allowed}. */
+  reason: ConsentReason
+}
+
+/** Tuning for {@link ConsentResolver}; every field defaults to a documented value. */
+export interface ConsentResolverOptions {
+  /** `cordis.yml` entry name whose enabled state carries consent. */
+  telemetryPluginName?: string
+  /** Environment used for `DO_NOT_TRACK`/CI checks; defaults to `process.env`. */
+  env?: NodeJS.ProcessEnv
+  /** Honor `DO_NOT_TRACK`/CI env signals as a hard opt-out. Defaults to `true`. */
+  honorEnvOptOut?: boolean
+  /** Consent when `cordis.yml` does not exist yet (first `create`). Defaults to `true` (telemetry is default-on). */
+  allowWhenNoConfig?: boolean
+  /** Consent when `cordis.yml` exists but has no telemetry entry. Defaults to `false`. */
+  allowWhenEntryAbsent?: boolean
+}
+
+/** Whether an environment variable is set to a non-empty, non-"0"/"false" value. */
+function envEnabled(value: string | undefined): boolean {
+  if (value === undefined) return false
+  const normalized = value.trim().toLowerCase()
+  return normalized.length > 0 && normalized !== '0' && normalized !== 'false'
+}
+
+/** Read a `cordis.yml` entry's `name`/`disabled` scalars, tolerating `!!js` tags. */
+function readTelemetryEntry(text: string, pluginName: string): { present: boolean; disabled: boolean } {
+  const document = parseDocument(text, { customTags: [JS_EXPRESSION_TAG] })
+  const contents: unknown = document.toJS({ maxAliasCount: -1 })
+  if (!Array.isArray(contents)) return { present: false, disabled: false }
+  for (const entry of contents) {
+    if (entry === null || typeof entry !== 'object') continue
+    const record = entry as Record<string, unknown>
+    if (record.name === pluginName) return { present: true, disabled: record.disabled === true }
+  }
+  return { present: false, disabled: false }
+}
+
+/** Resolve telemetry consent by parsing a project's `cordis.yml` and the environment. */
+export class ConsentResolver {
+  readonly #pluginName: string
+  readonly #env: NodeJS.ProcessEnv
+  readonly #honorEnvOptOut: boolean
+  readonly #allowWhenNoConfig: boolean
+  readonly #allowWhenEntryAbsent: boolean
+
+  /** @param options - plugin name, environment, and default-decision knobs. */
+  constructor(options: ConsentResolverOptions = {}) {
+    this.#pluginName = options.telemetryPluginName ?? DEFAULT_TELEMETRY_PLUGIN_NAME
+    this.#env = options.env ?? process.env
+    this.#honorEnvOptOut = options.honorEnvOptOut ?? true
+    this.#allowWhenNoConfig = options.allowWhenNoConfig ?? true
+    this.#allowWhenEntryAbsent = options.allowWhenEntryAbsent ?? false
+  }
+
+  /**
+   * Resolve consent for a command run in the given project directory.
+   * @param projectDir - absolute or relative project root containing `cordis.yml`.
+   * @returns the consent decision and the signal that produced it.
+   */
+  async resolve(projectDir: string): Promise<ConsentDecision> {
+    if (this.#honorEnvOptOut) {
+      if (envEnabled(this.#env.DO_NOT_TRACK)) return { allowed: false, reason: 'do-not-track' }
+      if (envEnabled(this.#env.CI)) return { allowed: false, reason: 'ci' }
+    }
+    let text: string
+    try {
+      text = await readFile(join(projectDir, 'cordis.yml'), 'utf8')
+    } catch (error) {
+      // Missing cordis.yml is the first-init (`create`) path; any other read
+      // fault is treated conservatively as its own reason.
+      if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+        return { allowed: this.#allowWhenNoConfig, reason: 'no-config' }
+      }
+      return { allowed: false, reason: 'unreadable' }
+    }
+    const entry = readTelemetryEntry(text, this.#pluginName)
+    if (!entry.present) return { allowed: this.#allowWhenEntryAbsent, reason: 'absent' }
+    return entry.disabled ? { allowed: false, reason: 'disabled' } : { allowed: true, reason: 'enabled' }
+  }
+}

+ 45 - 0
packages/sdk/telemetry/src/index.ts

@@ -0,0 +1,45 @@
+/**
+ * Launcher-side telemetry for the dsh-sdk toolchain: secret redaction, consent
+ * resolution, anonymous id, payload assembly, and a fire-and-forget reporter.
+ *
+ * This package is a plain library the launcher imports around each command — it
+ * is NOT a Cordis plugin (several commands never boot Cordis). Wiring it into
+ * the launcher command dispatch and the helper feature catalog lives outside
+ * this package.
+ *
+ * @module @deepseek-ai/dsh-telemetry
+ */
+
+export {
+  DEFAULT_ENTROPY_THRESHOLD,
+  DEFAULT_MIN_TOKEN_LENGTH,
+  DEFAULT_REDACTION_PLACEHOLDER,
+  SecretRedactor,
+  keyLooksSecret,
+} from './secret-redactor.ts'
+export type { SecretRedactorOptions } from './secret-redactor.ts'
+export {
+  ConsentResolver,
+  DEFAULT_TELEMETRY_PLUGIN_NAME,
+} from './consent-resolver.ts'
+export type {
+  ConsentDecision,
+  ConsentReason,
+  ConsentResolverOptions,
+} from './consent-resolver.ts'
+export {
+  ANONYMOUS_ID_FILE_NAME,
+  getOrCreateAnonymousId,
+  globalConfigDir,
+} from './anonymous-id.ts'
+export type { AnonymousId, AnonymousIdOptions } from './anonymous-id.ts'
+export { buildTelemetryPayload } from './payload.ts'
+export type { BuildTelemetryPayloadInput, TelemetryPayload } from './payload.ts'
+export {
+  DEFAULT_FLUSH_TIMEOUT_MS,
+  DEFAULT_SEND_TIMEOUT_MS,
+  DSH_TELEMETRY_ENDPOINT,
+  TELEMETRY_SCHEMA_VERSION,
+  TelemetryReporter,
+} from './reporter.ts'
+export type { DeliveryOutcome, TelemetryReporterOptions } from './reporter.ts'

+ 75 - 0
packages/sdk/telemetry/src/payload.ts

@@ -0,0 +1,75 @@
+/**
+ * Telemetry payload assembly.
+ *
+ * The payload carries the command lifecycle plus the FULL redacted content of
+ * the project `cordis.yml` and `package.json`. It NEVER reads or includes `.env`
+ * — secrets live only in `.env`, and the redactor is the backstop for any that
+ * leak into the two reported files. A file that does not exist (the first
+ * `create` run) simply omits its field.
+ *
+ * @module @deepseek-ai/dsh-telemetry/payload
+ */
+
+import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { SecretRedactor } from './secret-redactor.ts'
+
+/** Project files whose full (redacted) content ships with the payload. */
+const REPORTED_FILES = ['cordis.yml', 'package.json'] as const
+
+/** One command's telemetry payload. */
+export interface TelemetryPayload {
+  /** The dsh-sdk command that ran (`start`/`dev`/`build`/`config`/`create`). */
+  command: string
+  /** Wall-clock duration of the command in milliseconds. */
+  durationMs: number
+  /** Whether the command completed without error. */
+  success: boolean
+  /** Redacted full text of the project `cordis.yml`, absent when the file does not exist. */
+  cordisYmlContent?: string
+  /** Redacted full text of the project `package.json`, absent when the file does not exist. */
+  packageJsonContent?: string
+}
+
+/** Inputs for {@link buildTelemetryPayload}. */
+export interface BuildTelemetryPayloadInput {
+  /** The dsh-sdk command that ran. */
+  command: string
+  /** Wall-clock duration of the command in milliseconds. */
+  durationMs: number
+  /** Whether the command completed without error. */
+  success: boolean
+  /** Project root whose `cordis.yml` and `package.json` are read. */
+  projectDir: string
+  /** Redactor applied to reported file content; defaults to a fresh {@link SecretRedactor}. */
+  redactor?: SecretRedactor
+}
+
+/** Read a project file's text, returning `undefined` when it cannot be read. */
+async function readReportedFile(projectDir: string, name: string): Promise<string | undefined> {
+  try {
+    return await readFile(join(projectDir, name), 'utf8')
+  } catch {
+    // Missing/unreadable reported file: telemetry omits the field rather than fail.
+    return undefined
+  }
+}
+
+/**
+ * Assemble a redacted telemetry payload for one command invocation.
+ * @param input - command lifecycle facts, project directory, and optional redactor.
+ * @returns the payload with redacted `cordis.yml`/`package.json` content.
+ */
+export async function buildTelemetryPayload(input: BuildTelemetryPayloadInput): Promise<TelemetryPayload> {
+  const redactor = input.redactor ?? new SecretRedactor()
+  const [cordisYml, packageJson] = await Promise.all(
+    REPORTED_FILES.map(name => readReportedFile(input.projectDir, name)),
+  )
+  return {
+    command: input.command,
+    durationMs: input.durationMs,
+    success: input.success,
+    ...cordisYml !== undefined ? { cordisYmlContent: redactor.redactText(cordisYml) } : {},
+    ...packageJson !== undefined ? { packageJsonContent: redactor.redactText(packageJson) } : {},
+  }
+}

+ 149 - 0
packages/sdk/telemetry/src/reporter.ts

@@ -0,0 +1,149 @@
+/**
+ * Fire-and-forget telemetry reporter for the dsh-sdk launcher.
+ *
+ * The reporter must NEVER block or crash a command: {@link TelemetryReporter.report}
+ * schedules a detached send and returns immediately, and the underlying delivery
+ * resolves on every path (consent skip, network failure, non-OK status) instead
+ * of rejecting. {@link TelemetryReporter.flush} lets the launcher optionally
+ * drain in-flight sends within a cap before exit.
+ *
+ * @module @deepseek-ai/dsh-telemetry/reporter
+ */
+
+import type { ConsentDecision } from './consent-resolver.ts'
+import type { TelemetryPayload } from './payload.ts'
+import { getOrCreateAnonymousId, type AnonymousId } from './anonymous-id.ts'
+import { SecretRedactor } from './secret-redactor.ts'
+
+/**
+ * Placeholder collection endpoint. This is a fixed protocol constant, not a
+ * deployment tunable.
+ *
+ * FIXME(ccyu): replace with the real telemetry endpoint before release. The
+ * `.invalid` TLD guarantees delivery fails harmlessly until then.
+ */
+export const DSH_TELEMETRY_ENDPOINT = 'https://telemetry.example.invalid/v1/dsh-sdk'
+
+/** Wire-envelope schema version; bump on any incompatible body change. */
+export const TELEMETRY_SCHEMA_VERSION = 1
+
+/** Default per-request send timeout in milliseconds. */
+export const DEFAULT_SEND_TIMEOUT_MS = 3000
+
+/** Default cap for {@link TelemetryReporter.flush} in milliseconds. */
+export const DEFAULT_FLUSH_TIMEOUT_MS = 2000
+
+/** Outcome of one delivery attempt; delivery never rejects. */
+export type DeliveryOutcome =
+  | { status: 'skipped'; reason: string }
+  | { status: 'sent' }
+  | { status: 'failed'; error: string }
+
+/** The JSON body posted to the telemetry endpoint. */
+interface TelemetryEnvelope extends TelemetryPayload {
+  schemaVersion: number
+  anonymousId: AnonymousId
+  sentAt: string
+}
+
+/** Injectable seams for {@link TelemetryReporter}; every field has a default. */
+export interface TelemetryReporterOptions {
+  /** Collection endpoint; defaults to {@link DSH_TELEMETRY_ENDPOINT}. */
+  endpoint?: string
+  /** `fetch` implementation; defaults to the global `fetch`. */
+  fetch?: typeof globalThis.fetch
+  /** Anonymous-id provider; defaults to {@link getOrCreateAnonymousId}. */
+  anonymousId?: () => Promise<AnonymousId>
+  /** Redactor applied to the assembled envelope as a final backstop; defaults to a fresh {@link SecretRedactor}. */
+  redactor?: SecretRedactor
+  /** Per-request send timeout in milliseconds. */
+  timeoutMs?: number
+  /** Clock for the envelope timestamp; defaults to `Date.now`. */
+  now?: () => number
+}
+
+/** Sends telemetry payloads fire-and-forget, swallowing every failure. */
+export class TelemetryReporter {
+  readonly #endpoint: string
+  readonly #fetch: typeof globalThis.fetch
+  readonly #anonymousId: () => Promise<AnonymousId>
+  readonly #redactor: SecretRedactor
+  readonly #timeoutMs: number
+  readonly #now: () => number
+  readonly #inflight = new Set<Promise<DeliveryOutcome>>()
+
+  /** @param options - endpoint, transport, id provider, and timing seams. */
+  constructor(options: TelemetryReporterOptions = {}) {
+    this.#endpoint = options.endpoint ?? DSH_TELEMETRY_ENDPOINT
+    this.#fetch = options.fetch ?? globalThis.fetch
+    this.#anonymousId = options.anonymousId ?? getOrCreateAnonymousId
+    this.#redactor = options.redactor ?? new SecretRedactor()
+    this.#timeoutMs = options.timeoutMs ?? DEFAULT_SEND_TIMEOUT_MS
+    this.#now = options.now ?? Date.now
+  }
+
+  /**
+   * Schedule a detached, non-blocking send. Returns immediately and never
+   * throws; the send's outcome is observable only through {@link flush}.
+   * @param payload - the command payload to report.
+   * @param consent - resolved consent; a denial short-circuits to a skip.
+   */
+  report(payload: TelemetryPayload, consent: ConsentDecision): void {
+    const pending = this.#deliver(payload, consent)
+    this.#inflight.add(pending)
+    void pending.finally(() => this.#inflight.delete(pending))
+  }
+
+  /**
+   * Await in-flight sends up to a timeout so a caller can drain before exit.
+   * Resolves on the cap regardless of send progress; never rejects.
+   * @param timeoutMs - maximum time to wait; defaults to {@link DEFAULT_FLUSH_TIMEOUT_MS}.
+   */
+  async flush(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise<void> {
+    if (this.#inflight.size === 0) return
+    const drained = Promise.allSettled([...this.#inflight]).then(() => undefined)
+    let timer!: ReturnType<typeof setTimeout>
+    const capped = new Promise<void>((resolve) => {
+      timer = setTimeout(resolve, timeoutMs)
+    })
+    try {
+      await Promise.race([drained, capped])
+    } finally {
+      clearTimeout(timer)
+    }
+  }
+
+  /** Deliver one payload, resolving to an outcome on every path (never rejects). */
+  async #deliver(payload: TelemetryPayload, consent: ConsentDecision): Promise<DeliveryOutcome> {
+    if (!consent.allowed) return { status: 'skipped', reason: consent.reason }
+    try {
+      const envelope: TelemetryEnvelope = {
+        schemaVersion: TELEMETRY_SCHEMA_VERSION,
+        anonymousId: await this.#anonymousId(),
+        sentAt: new Date(this.#now()).toISOString(),
+        ...payload,
+        // Idempotent backstop over the only free-form fields, in case a caller
+        // built the payload without buildTelemetryPayload. Applied to content
+        // text only so the anonymous id and metadata are never disturbed.
+        ...payload.cordisYmlContent !== undefined
+          ? { cordisYmlContent: this.#redactor.redactText(payload.cordisYmlContent) }
+          : {},
+        ...payload.packageJsonContent !== undefined
+          ? { packageJsonContent: this.#redactor.redactText(payload.packageJsonContent) }
+          : {},
+      }
+      const response = await this.#fetch(this.#endpoint, {
+        method: 'POST',
+        headers: { 'content-type': 'application/json' },
+        body: JSON.stringify(envelope),
+        signal: AbortSignal.timeout(this.#timeoutMs),
+      })
+      if (!response.ok) return { status: 'failed', error: `HTTP ${response.status}` }
+      return { status: 'sent' }
+    } catch (error) {
+      // Telemetry is best-effort: network faults, aborts, and id/redaction
+      // errors are swallowed so the command is never affected.
+      return { status: 'failed', error: error instanceof Error ? error.message : String(error) }
+    }
+  }
+}

+ 203 - 0
packages/sdk/telemetry/src/secret-redactor.ts

@@ -0,0 +1,203 @@
+/**
+ * Conservative secret redactor: the safety backstop that scrubs credential-like
+ * values from telemetry content before it leaves the machine.
+ *
+ * The redactor never drops a field or line — it only replaces the secret-shaped
+ * VALUE with a fixed placeholder, so the surrounding structure (keys, package
+ * names, base URLs, dependency pins) stays intact for the maintainer. It leans
+ * toward redaction on strong signals (secret-like key names, known token
+ * shapes, PEM blocks, URL credentials, high-entropy opaque tokens) while
+ * deliberately leaving low-signal values (package names, versions, git SHAs,
+ * plain URLs, kebab identifiers) untouched, because those are exactly the
+ * signal telemetry exists to capture.
+ *
+ * @module @deepseek-ai/dsh-telemetry/secret-redactor
+ */
+
+/** Default text substituted for a detected secret. */
+export const DEFAULT_REDACTION_PLACEHOLDER = '[REDACTED]'
+
+/** Default minimum length for the high-entropy opaque-token heuristic. */
+export const DEFAULT_MIN_TOKEN_LENGTH = 24
+
+/** Default Shannon-entropy threshold (bits/char) that marks an opaque token secret. */
+export const DEFAULT_ENTROPY_THRESHOLD = 4
+
+/** Tuning for {@link SecretRedactor}; every field defaults to a documented constant. */
+export interface SecretRedactorOptions {
+  /** Replacement text for a detected secret. */
+  placeholder?: string
+  /** Minimum length before the high-entropy heuristic considers an opaque token. */
+  minTokenLength?: number
+  /** Shannon entropy (bits/char) at or above which an opaque token is treated as secret. */
+  entropyThreshold?: number
+}
+
+/**
+ * Regexes for well-known credential shapes. A match anywhere in a candidate
+ * token marks it secret regardless of length, so short-but-recognizable tokens
+ * are caught even when the entropy heuristic would not fire.
+ */
+const KNOWN_SECRET_PATTERNS: readonly RegExp[] = [
+  /sk-(?:ant-)?[A-Za-z0-9_-]{10,}/, // OpenAI / DeepSeek / Anthropic style
+  /gh[pousr]_[A-Za-z0-9]{16,}/, // GitHub personal/oauth/server/refresh tokens
+  /github_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PAT
+  /xox[baprs]-[A-Za-z0-9-]{10,}/, // Slack tokens
+  /AKIA[0-9A-Z]{16}/, // AWS access key id
+  /AIza[0-9A-Za-z_-]{35}/, // Google API key
+  /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/, // JWT
+]
+
+/**
+ * Key names (normalized to lowercase, separators stripped) whose value is a
+ * secret. Split by match strategy so short/ambiguous words do not over-match:
+ * `author` must not trip the `auth` rule.
+ */
+const KEY_SUBSTRING_INDICATORS: readonly string[] = [
+  'password', 'passwd', 'passphrase', 'secret', 'apikey', 'apisecret',
+  'clientsecret', 'privatekey', 'secretkey', 'accesskey', 'credential',
+  'connectionstring', 'sastoken', 'xapikey', 'authtoken', 'accesstoken',
+  'refreshtoken', 'idtoken', 'sessiontoken', 'bearertoken',
+]
+const KEY_SUFFIX_INDICATORS: readonly string[] = ['token']
+const KEY_EXACT_INDICATORS: readonly string[] = [
+  'auth', 'authorization', 'cookie', 'bearer', 'dsn', 'signature',
+]
+
+/**
+ * Whether a key name marks its value as a secret.
+ * @param key - raw object key or assignment name.
+ * @returns whether the value under this key must be redacted.
+ */
+export function keyLooksSecret(key: string): boolean {
+  const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '')
+  if (normalized.length === 0) return false
+  if (KEY_SUBSTRING_INDICATORS.some(indicator => normalized.includes(indicator))) return true
+  if (KEY_SUFFIX_INDICATORS.some(indicator => normalized.endsWith(indicator))) return true
+  return KEY_EXACT_INDICATORS.includes(normalized)
+}
+
+/** Shannon entropy in bits per character. */
+function shannonEntropy(value: string): number {
+  const counts = new Map<string, number>()
+  for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1)
+  let entropy = 0
+  for (const count of counts.values()) {
+    const probability = count / value.length
+    entropy -= probability * Math.log2(probability)
+  }
+  return entropy
+}
+
+/** Opaque-token character set (base64/base64url plus common token punctuation). */
+const OPAQUE_TOKEN = /^[A-Za-z0-9+/=_.-]+$/
+/** Version-like leader kept visible (dependency pins, semver). */
+const VERSION_LIKE = /^v?\d+(?:\.\d+)+/
+
+/**
+ * Conservative secret detector and redactor for telemetry content.
+ * Detection is a pure function of the input; construction only fixes tunables.
+ */
+export class SecretRedactor {
+  readonly #placeholder: string
+  readonly #minTokenLength: number
+  readonly #entropyThreshold: number
+
+  /** @param options - placeholder text and heuristic thresholds. */
+  constructor(options: SecretRedactorOptions = {}) {
+    this.#placeholder = options.placeholder ?? DEFAULT_REDACTION_PLACEHOLDER
+    this.#minTokenLength = options.minTokenLength ?? DEFAULT_MIN_TOKEN_LENGTH
+    this.#entropyThreshold = options.entropyThreshold ?? DEFAULT_ENTROPY_THRESHOLD
+  }
+
+  /**
+   * Whether a standalone token value looks like a secret.
+   * @param value - candidate token, already trimmed of surrounding quotes.
+   * @returns whether the value should be redacted on its own merits.
+   */
+  isSecretValue(value: string): boolean {
+    if (KNOWN_SECRET_PATTERNS.some(pattern => pattern.test(value))) return true
+    if (value.length < this.#minTokenLength) return false
+    if (!OPAQUE_TOKEN.test(value)) return false
+    // Git SHAs and integrity digests are hex and public — never a secret we hide.
+    if (/^[0-9a-fA-F]+$/.test(value)) return false
+    if (VERSION_LIKE.test(value)) return false
+    const classes = (/[a-z]/.test(value) ? 1 : 0) + (/[A-Z]/.test(value) ? 1 : 0) + (/[0-9]/.test(value) ? 1 : 0)
+    return classes >= 3 || shannonEntropy(value) >= this.#entropyThreshold
+  }
+
+  /**
+   * Deep-redact a parsed value in place-safe fashion, returning a new structure.
+   * A secret-named key redacts its string value outright; every other string is
+   * judged on its own shape. Non-string leaves pass through untouched.
+   * @param value - parsed JSON-like value (object, array, or primitive).
+   * @returns a structurally identical value with secret strings replaced.
+   */
+  redactValue<T>(value: T): T {
+    return this.#redactNode(value, false) as T
+  }
+
+  #redactNode(value: unknown, keyIsSecret: boolean): unknown {
+    if (typeof value === 'string') {
+      return keyIsSecret || this.isSecretValue(value) ? this.#placeholder : value
+    }
+    if (Array.isArray(value)) return value.map(item => this.#redactNode(item, false))
+    if (value !== null && typeof value === 'object') {
+      return Object.fromEntries(
+        Object.entries(value).map(([key, child]) => [key, this.#redactNode(child, keyLooksSecret(key))]),
+      )
+    }
+    return value
+  }
+
+  /**
+   * Redact secrets embedded in raw text (YAML, JSON, or `.env`-style content),
+   * preserving every line and key while replacing only secret-shaped values.
+   * @param text - raw file or message text.
+   * @returns text with detected secrets replaced by the placeholder.
+   */
+  redactText(text: string): string {
+    let output = this.#redactPemBlocks(text)
+    output = this.#redactAssignments(output)
+    output = this.#redactUrlCredentials(output)
+    output = this.#redactBearerTokens(output)
+    return this.#redactStandaloneTokens(output)
+  }
+
+  #redactPemBlocks(text: string): string {
+    return text.replace(
+      /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY-----/g,
+      this.#placeholder,
+    )
+  }
+
+  #redactAssignments(text: string): string {
+    // `key: value`, `key = value`, or `"key": "value"` across YAML/JSON/.env.
+    return text.replace(
+      /("?)([A-Za-z0-9_.-]+)\1(\s*[:=]\s*)(["']?)([^\n\r"']+)\4/g,
+      (match, keyQuote: string, key: string, separator: string, valueQuote: string, value: string) =>
+        keyLooksSecret(key) && value.trim().length > 0
+          ? `${keyQuote}${key}${keyQuote}${separator}${valueQuote}${this.#placeholder}${valueQuote}`
+          : match,
+    )
+  }
+
+  #redactUrlCredentials(text: string): string {
+    // Redact only the password in `scheme://user:password@host`, keeping host visible.
+    return text.replace(
+      /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi,
+      (_match, prefix: string, _password: string, at: string) => `${prefix}${this.#placeholder}${at}`,
+    )
+  }
+
+  #redactBearerTokens(text: string): string {
+    return text.replace(/(bearer\s+)([a-z0-9._-]{8,})/gi, (_match, prefix: string) => `${prefix}${this.#placeholder}`)
+  }
+
+  #redactStandaloneTokens(text: string): string {
+    // `/` is excluded so package names, file paths, and URLs are never split or
+    // redacted; a secret containing `/` is still scrubbed piecewise.
+    return text.replace(/[A-Za-z0-9][A-Za-z0-9+=_.-]{7,}/g, token =>
+      this.isSecretValue(token) ? this.#placeholder : token)
+  }
+}

+ 100 - 0
packages/sdk/telemetry/tests/anonymous-id.spec.ts

@@ -0,0 +1,100 @@
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+  ANONYMOUS_ID_FILE_NAME,
+  getOrCreateAnonymousId,
+  globalConfigDir,
+} from '@deepseek-ai/dsh-telemetry'
+
+const dirs: string[] = []
+
+async function tempDir(): Promise<string> {
+  const dir = await mkdtemp(join(tmpdir(), 'dsh-anon-'))
+  dirs.push(dir)
+  return dir
+}
+
+afterEach(async () => {
+  await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
+})
+
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+
+describe('globalConfigDir', () => {
+  it('prefers an explicit DSH_CONFIG_HOME override', () => {
+    expect(globalConfigDir({ env: { DSH_CONFIG_HOME: '/custom/dsh' } })).toBe('/custom/dsh')
+  })
+
+  it('falls back to XDG_CONFIG_HOME under the harness namespace', () => {
+    expect(globalConfigDir({ env: { XDG_CONFIG_HOME: '/xdg' } })).toBe(join('/xdg', 'deepseek-harness'))
+  })
+
+  it('uses %APPDATA% on Windows', () => {
+    expect(globalConfigDir({ env: { APPDATA: 'C:/Users/x/AppData/Roaming' }, platform: 'win32' }))
+      .toBe(join('C:/Users/x/AppData/Roaming', 'deepseek-harness'))
+  })
+
+  it('falls back to ~/.config on Windows without APPDATA and on posix', () => {
+    const home = () => '/home/dev'
+    expect(globalConfigDir({ env: {}, platform: 'win32', homeDir: home }))
+      .toBe(join('/home/dev', '.config', 'deepseek-harness'))
+    expect(globalConfigDir({ env: {}, platform: 'linux', homeDir: home }))
+      .toBe(join('/home/dev', '.config', 'deepseek-harness'))
+  })
+
+  it('reads process.env by default', () => {
+    // No override supplied: the call must not throw and must return an absolute path.
+    expect(globalConfigDir()).toContain('deepseek-harness')
+  })
+})
+
+describe('getOrCreateAnonymousId', () => {
+  it('creates, persists, and returns a UUID on first use', async () => {
+    const dir = await tempDir()
+    const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })
+    expect(id).toMatch(UUID)
+    const stored: unknown = JSON.parse(await readFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'utf8'))
+    expect(stored).toEqual({ anonymousId: id })
+  })
+
+  it('returns the same persisted id on subsequent calls', async () => {
+    const dir = await tempDir()
+    const first = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })
+    const second = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })
+    expect(second).toBe(first)
+  })
+
+  it('uses the injected UUID generator', async () => {
+    const dir = await tempDir()
+    const id = await getOrCreateAnonymousId({
+      env: { DSH_CONFIG_HOME: dir },
+      randomUUID: () => '00000000-0000-4000-8000-000000000000',
+    })
+    expect(id).toBe('00000000-0000-4000-8000-000000000000')
+  })
+
+  it('regenerates when the stored file is corrupt JSON', async () => {
+    const dir = await tempDir()
+    await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'not json', 'utf8')
+    const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })
+    expect(id).toMatch(UUID)
+  })
+
+  it('regenerates when the stored value is not a valid UUID or object', async () => {
+    const dir = await tempDir()
+    await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), JSON.stringify({ anonymousId: 'nope' }), 'utf8')
+    expect(await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })).toMatch(UUID)
+    await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), '123', 'utf8')
+    expect(await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })).toMatch(UUID)
+  })
+
+  it('returns a usable id even when persistence fails', async () => {
+    const dir = await tempDir()
+    // A regular file where a directory is expected makes mkdir/writeFile fail.
+    await writeFile(join(dir, 'blocker'), 'x', 'utf8')
+    const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: join(dir, 'blocker') } })
+    expect(id).toMatch(UUID)
+  })
+})

+ 131 - 0
packages/sdk/telemetry/tests/consent-resolver.spec.ts

@@ -0,0 +1,131 @@
+import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { ConsentResolver, DEFAULT_TELEMETRY_PLUGIN_NAME, type ConsentDecision } from '@deepseek-ai/dsh-telemetry'
+
+const dirs: string[] = []
+
+async function projectDir(cordisYml?: string): Promise<string> {
+  const dir = await mkdtemp(join(tmpdir(), 'dsh-consent-'))
+  dirs.push(dir)
+  if (cordisYml !== undefined) await writeFile(join(dir, 'cordis.yml'), cordisYml, 'utf8')
+  return dir
+}
+
+afterEach(async () => {
+  await Promise.all(dirs.splice(0).map(dir => import('node:fs/promises').then(fs => fs.rm(dir, { recursive: true, force: true }))))
+})
+
+const enabledYml = `- id: telemetry\n  name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n`
+
+describe('ConsentResolver environment opt-out', () => {
+  it('denies when DO_NOT_TRACK is set', async () => {
+    const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' } }).resolve(await projectDir(enabledYml))
+    expect(decision).toEqual<ConsentDecision>({ allowed: false, reason: 'do-not-track' })
+  })
+
+  it('denies when CI is set', async () => {
+    const decision = await new ConsentResolver({ env: { CI: 'true' } }).resolve(await projectDir(enabledYml))
+    expect(decision).toEqual<ConsentDecision>({ allowed: false, reason: 'ci' })
+  })
+
+  it('ignores falsy env values and continues to the file', async () => {
+    const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '0', CI: 'false' } })
+      .resolve(await projectDir(enabledYml))
+    expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
+  })
+
+  it('can be told to ignore env opt-out signals', async () => {
+    const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' }, honorEnvOptOut: false })
+      .resolve(await projectDir(enabledYml))
+    expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
+  })
+
+  it('reads process.env by default', async () => {
+    const saved = { CI: process.env.CI, DO_NOT_TRACK: process.env.DO_NOT_TRACK }
+    delete process.env.CI
+    delete process.env.DO_NOT_TRACK
+    try {
+      const decision = await new ConsentResolver().resolve(await projectDir(enabledYml))
+      expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
+    } finally {
+      if (saved.CI !== undefined) process.env.CI = saved.CI
+      if (saved.DO_NOT_TRACK !== undefined) process.env.DO_NOT_TRACK = saved.DO_NOT_TRACK
+    }
+  })
+})
+
+describe('ConsentResolver cordis.yml state', () => {
+  const resolver = new ConsentResolver({ env: {} })
+
+  it('allows when the telemetry entry is enabled', async () => {
+    expect(await resolver.resolve(await projectDir(enabledYml)))
+      .toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
+  })
+
+  it('denies when the telemetry entry is disabled', async () => {
+    const yml = `- id: telemetry\n  name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n  disabled: true\n`
+    expect(await resolver.resolve(await projectDir(yml)))
+      .toEqual<ConsentDecision>({ allowed: false, reason: 'disabled' })
+  })
+
+  it('tolerates !!js expression tags while reading plain scalars', async () => {
+    const yml = [
+      '- id: telemetry',
+      `  name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'`,
+      '- id: llm',
+      '  name: \'@deepseek-ai/dsh-llm-deepseek\'',
+      '  config:',
+      '    apiKey: !!js process.env.DEEPSEEK_API_KEY',
+      '',
+    ].join('\n')
+    expect(await resolver.resolve(await projectDir(yml)))
+      .toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
+  })
+
+  it('reports absent when cordis.yml has no telemetry entry, defaulting to deny', async () => {
+    const yml = '- id: llm\n  name: \'@deepseek-ai/dsh-llm-deepseek\'\n'
+    expect(await resolver.resolve(await projectDir(yml)))
+      .toEqual<ConsentDecision>({ allowed: false, reason: 'absent' })
+  })
+
+  it('can allow when the entry is absent', async () => {
+    const yml = '- id: llm\n  name: \'@deepseek-ai/dsh-llm-deepseek\'\n'
+    const decision = await new ConsentResolver({ env: {}, allowWhenEntryAbsent: true }).resolve(await projectDir(yml))
+    expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'absent' })
+  })
+
+  it('skips non-object sequence items and a non-sequence root', async () => {
+    expect(await resolver.resolve(await projectDir('- just-a-string\n- id: x\n  name: y\n')))
+      .toEqual<ConsentDecision>({ allowed: false, reason: 'absent' })
+    expect(await resolver.resolve(await projectDir('root: not-a-sequence\n')))
+      .toEqual<ConsentDecision>({ allowed: false, reason: 'absent' })
+  })
+
+  it('honors a custom telemetry plugin name', async () => {
+    const yml = '- id: t\n  name: \'my-consent-marker\'\n'
+    const decision = await new ConsentResolver({ env: {}, telemetryPluginName: 'my-consent-marker' })
+      .resolve(await projectDir(yml))
+    expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
+  })
+})
+
+describe('ConsentResolver missing or unreadable cordis.yml', () => {
+  it('reports no-config and allows by default on first init', async () => {
+    expect(await new ConsentResolver({ env: {} }).resolve(await projectDir()))
+      .toEqual<ConsentDecision>({ allowed: true, reason: 'no-config' })
+  })
+
+  it('can deny on first init', async () => {
+    const decision = await new ConsentResolver({ env: {}, allowWhenNoConfig: false }).resolve(await projectDir())
+    expect(decision).toEqual<ConsentDecision>({ allowed: false, reason: 'no-config' })
+  })
+
+  it('denies with an unreadable reason when cordis.yml is not a regular file', async () => {
+    const dir = await projectDir()
+    await mkdir(join(dir, 'cordis.yml')) // a directory where the resolver expects a file
+    expect(await new ConsentResolver({ env: {} }).resolve(dir))
+      .toEqual<ConsentDecision>({ allowed: false, reason: 'unreadable' })
+  })
+})

+ 59 - 0
packages/sdk/telemetry/tests/payload.spec.ts

@@ -0,0 +1,59 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { SecretRedactor, buildTelemetryPayload } from '@deepseek-ai/dsh-telemetry'
+
+const dirs: string[] = []
+
+async function projectDir(files: Record<string, string>): Promise<string> {
+  const dir = await mkdtemp(join(tmpdir(), 'dsh-payload-'))
+  dirs.push(dir)
+  await Promise.all(Object.entries(files).map(([name, content]) => writeFile(join(dir, name), content, 'utf8')))
+  return dir
+}
+
+afterEach(async () => {
+  await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
+})
+
+describe('buildTelemetryPayload', () => {
+  it('carries lifecycle facts and redacted file content', async () => {
+    const dir = await projectDir({
+      'cordis.yml': '- id: llm\n  name: \'@deepseek-ai/dsh-llm-deepseek\'\n  config:\n    apiKey: sk-abcdefghij1234567890\n',
+      'package.json': '{ "name": "my-app", "config": { "token": "sk-abcdefghij1234567890" } }',
+    })
+    const payload = await buildTelemetryPayload({ command: 'build', durationMs: 42, success: true, projectDir: dir })
+    expect(payload.command).toBe('build')
+    expect(payload.durationMs).toBe(42)
+    expect(payload.success).toBe(true)
+    expect(payload.cordisYmlContent).toContain('@deepseek-ai/dsh-llm-deepseek') // package name preserved
+    expect(payload.cordisYmlContent).not.toContain('sk-abcdefghij1234567890') // secret scrubbed
+    expect(payload.packageJsonContent).toContain('my-app')
+    expect(payload.packageJsonContent).not.toContain('sk-abcdefghij1234567890')
+  })
+
+  it('omits fields whose files do not exist', async () => {
+    const dir = await projectDir({ 'cordis.yml': '- id: llm\n  name: \'@deepseek-ai/dsh-llm-deepseek\'\n' })
+    const payload = await buildTelemetryPayload({ command: 'create', durationMs: 1, success: false, projectDir: dir })
+    expect(payload.cordisYmlContent).toBeDefined()
+    expect('packageJsonContent' in payload).toBe(false)
+  })
+
+  it('omits both fields when neither file exists', async () => {
+    const dir = await projectDir({})
+    const payload = await buildTelemetryPayload({ command: 'create', durationMs: 0, success: true, projectDir: dir })
+    expect('cordisYmlContent' in payload).toBe(false)
+    expect('packageJsonContent' in payload).toBe(false)
+  })
+
+  it('uses a supplied redactor', async () => {
+    const dir = await projectDir({ 'package.json': '{ "password": "hunter2" }' })
+    const redactor = new SecretRedactor({ placeholder: '<<hidden>>' })
+    const payload = await buildTelemetryPayload({
+      command: 'config', durationMs: 5, success: true, projectDir: dir, redactor,
+    })
+    expect(payload.packageJsonContent).toContain('<<hidden>>')
+    expect(payload.packageJsonContent).not.toContain('hunter2')
+  })
+})

+ 134 - 0
packages/sdk/telemetry/tests/reporter.spec.ts

@@ -0,0 +1,134 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+  DSH_TELEMETRY_ENDPOINT,
+  SecretRedactor,
+  TELEMETRY_SCHEMA_VERSION,
+  TelemetryReporter,
+  type AnonymousId,
+  type ConsentDecision,
+  type TelemetryPayload,
+} from '@deepseek-ai/dsh-telemetry'
+
+const ALLOW: ConsentDecision = { allowed: true, reason: 'enabled' }
+const DENY: ConsentDecision = { allowed: false, reason: 'disabled' }
+const anon = (value = 'anon-123'): (() => Promise<AnonymousId>) => async () => value as AnonymousId
+
+function okResponse(): Response {
+  return { ok: true } as Response
+}
+
+describe('TelemetryReporter.report', () => {
+  it('skips delivery when consent is denied', async () => {
+    const fetchMock = vi.fn(async () => okResponse())
+    const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon() })
+    reporter.report({ command: 'build', durationMs: 1, success: true }, DENY)
+    await reporter.flush(50)
+    expect(fetchMock).not.toHaveBeenCalled()
+  })
+
+  it('posts a redacted envelope when consent is granted', async () => {
+    const fetchMock = vi.fn<typeof globalThis.fetch>(() => Promise.resolve(okResponse()))
+    const reporter = new TelemetryReporter({
+      endpoint: 'https://collector.test/telemetry',
+      fetch: fetchMock,
+      anonymousId: anon('anon-xyz'),
+      redactor: new SecretRedactor(),
+      now: () => 0,
+      timeoutMs: 100,
+    })
+    const payload: TelemetryPayload = {
+      command: 'config',
+      durationMs: 7,
+      success: true,
+      cordisYmlContent: 'apiKey: sk-abcdefghij1234567890\nname: \'@deepseek-ai/dsh-llm-deepseek\'\n',
+      packageJsonContent: '{ "name": "app" }',
+    }
+    reporter.report(payload, ALLOW)
+    await reporter.flush(50)
+
+    expect(fetchMock).toHaveBeenCalledTimes(1)
+    const call = fetchMock.mock.calls[0]!
+    expect(call[0]).toBe('https://collector.test/telemetry')
+    const init = call[1]!
+    expect(init.method).toBe('POST')
+    const body = JSON.parse(init.body as string) as Record<string, unknown>
+    expect(body.schemaVersion).toBe(TELEMETRY_SCHEMA_VERSION)
+    expect(body.anonymousId).toBe('anon-xyz')
+    expect(body.sentAt).toBe('1970-01-01T00:00:00.000Z')
+    expect(body.command).toBe('config')
+    expect(body.cordisYmlContent).not.toContain('sk-abcdefghij1234567890')
+    expect(body.cordisYmlContent).toContain('@deepseek-ai/dsh-llm-deepseek')
+    expect(body.packageJsonContent).toContain('app')
+  })
+
+  it('posts an envelope without content fields when they are absent', async () => {
+    const fetchMock = vi.fn<typeof globalThis.fetch>(() => Promise.resolve(okResponse()))
+    const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), now: () => 0, timeoutMs: 100 })
+    reporter.report({ command: 'start', durationMs: 2, success: true }, ALLOW)
+    await reporter.flush(50)
+    const body = JSON.parse(fetchMock.mock.calls[0]![1]!.body as string) as Record<string, unknown>
+    expect('cordisYmlContent' in body).toBe(false)
+    expect('packageJsonContent' in body).toBe(false)
+  })
+
+  it('swallows a non-OK HTTP status', async () => {
+    const fetchMock = vi.fn(async () => ({ ok: false, status: 503 } as Response))
+    const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 })
+    reporter.report({ command: 'dev', durationMs: 3, success: true }, ALLOW)
+    await expect(reporter.flush(50)).resolves.toBeUndefined()
+    expect(fetchMock).toHaveBeenCalledTimes(1)
+  })
+
+  it('swallows a transport failure', async () => {
+    const fetchMock = vi.fn(async () => { throw new Error('network down') })
+    const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 })
+    reporter.report({ command: 'dev', durationMs: 3, success: false }, ALLOW)
+    await expect(reporter.flush(50)).resolves.toBeUndefined()
+  })
+
+  it('swallows a non-Error transport rejection', async () => {
+    const fetchMock = vi.fn(async () => { throw 'boom' })
+    const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 })
+    reporter.report({ command: 'dev', durationMs: 3, success: false }, ALLOW)
+    await expect(reporter.flush(50)).resolves.toBeUndefined()
+  })
+
+  it('swallows a failure while resolving the anonymous id, never sending', async () => {
+    const fetchMock = vi.fn(async () => okResponse())
+    const reporter = new TelemetryReporter({
+      fetch: fetchMock,
+      anonymousId: async () => { throw new Error('config unwritable') },
+      timeoutMs: 100,
+    })
+    reporter.report({ command: 'build', durationMs: 1, success: true }, ALLOW)
+    await reporter.flush(50)
+    expect(fetchMock).not.toHaveBeenCalled()
+  })
+})
+
+describe('TelemetryReporter.flush', () => {
+  it('returns immediately when nothing is in flight', async () => {
+    const reporter = new TelemetryReporter({ fetch: vi.fn(async () => okResponse()), anonymousId: anon() })
+    await expect(reporter.flush()).resolves.toBeUndefined()
+  })
+
+  it('resolves on the timeout cap when a send never settles', async () => {
+    const reporter = new TelemetryReporter({
+      fetch: () => new Promise<Response>(() => {}),
+      anonymousId: anon(),
+      timeoutMs: 10,
+    })
+    reporter.report({ command: 'start', durationMs: 1, success: true }, ALLOW)
+    const started = Date.now()
+    await reporter.flush(15)
+    expect(Date.now() - started).toBeLessThan(1000)
+  })
+})
+
+describe('TelemetryReporter defaults', () => {
+  it('defaults the endpoint and transport seams without options', () => {
+    const reporter = new TelemetryReporter()
+    expect(reporter).toBeInstanceOf(TelemetryReporter)
+    expect(DSH_TELEMETRY_ENDPOINT).toContain('.invalid')
+  })
+})

+ 169 - 0
packages/sdk/telemetry/tests/secret-redactor.spec.ts

@@ -0,0 +1,169 @@
+import { describe, expect, it } from 'vitest'
+import {
+  DEFAULT_ENTROPY_THRESHOLD,
+  DEFAULT_MIN_TOKEN_LENGTH,
+  DEFAULT_REDACTION_PLACEHOLDER,
+  SecretRedactor,
+  keyLooksSecret,
+} from '@deepseek-ai/dsh-telemetry'
+
+const REDACTED = DEFAULT_REDACTION_PLACEHOLDER
+
+describe('exported defaults', () => {
+  it('expose the documented tunable defaults', () => {
+    expect(DEFAULT_REDACTION_PLACEHOLDER).toBe('[REDACTED]')
+    expect(DEFAULT_MIN_TOKEN_LENGTH).toBe(24)
+    expect(DEFAULT_ENTROPY_THRESHOLD).toBe(4)
+  })
+})
+
+describe('keyLooksSecret', () => {
+  it('matches secret substrings across casings and separators', () => {
+    for (const key of ['password', 'API_KEY', 'apiKey', 'clientSecret', 'x-api-key', 'privateKey', 'CREDENTIALS']) {
+      expect(keyLooksSecret(key)).toBe(true)
+    }
+  })
+
+  it('matches *token as a suffix but not tokenizer', () => {
+    expect(keyLooksSecret('accessToken')).toBe(true)
+    expect(keyLooksSecret('token')).toBe(true)
+    expect(keyLooksSecret('tokenizer')).toBe(false)
+  })
+
+  it('matches short ambiguous words only as whole keys', () => {
+    expect(keyLooksSecret('auth')).toBe(true)
+    expect(keyLooksSecret('authorization')).toBe(true)
+    expect(keyLooksSecret('cookie')).toBe(true)
+    expect(keyLooksSecret('author')).toBe(false)
+  })
+
+  it('does not match ordinary config keys', () => {
+    for (const key of ['name', 'version', 'model', 'baseURL', 'timeout', 'path', 'pass']) {
+      expect(keyLooksSecret(key)).toBe(false)
+    }
+  })
+
+  it('returns false for a key with no alphanumerics', () => {
+    expect(keyLooksSecret('---')).toBe(false)
+  })
+})
+
+describe('SecretRedactor.isSecretValue', () => {
+  const redactor = new SecretRedactor()
+
+  it('detects known token shapes regardless of length', () => {
+    expect(redactor.isSecretValue('sk-abcdefghij1234567890')).toBe(true)
+    expect(redactor.isSecretValue('sk-ant-abcdefghij1234567890')).toBe(true)
+    expect(redactor.isSecretValue('ghp_abcdefghijklmnop1234')).toBe(true)
+    expect(redactor.isSecretValue('github_pat_abcdefghijklmnopqrst')).toBe(true)
+    expect(redactor.isSecretValue('xoxb-abcdefghij-klmno')).toBe(true)
+    expect(redactor.isSecretValue('AKIA1234567890ABCDEF')).toBe(true)
+    expect(redactor.isSecretValue(`AIza${'a'.repeat(35)}`)).toBe(true)
+    expect(redactor.isSecretValue('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop')).toBe(true)
+  })
+
+  it('detects high-entropy opaque tokens with three character classes', () => {
+    // Non-hex letters keep it off the hex-digest exemption; three classes trip the rule.
+    expect(redactor.isSecretValue('zX9zX9zX9zX9zX9zX9zX9zX9')).toBe(true)
+  })
+
+  it('detects high-entropy opaque tokens by entropy even within two classes', () => {
+    // 30 distinct lowercase+digit chars: entropy ~4.9, only two classes.
+    const token = 'abcdefghijklmnopqrstuvwxyz0123'
+    expect(token.length).toBeGreaterThanOrEqual(DEFAULT_MIN_TOKEN_LENGTH)
+    expect(redactor.isSecretValue(token)).toBe(true)
+  })
+
+  it('leaves short values, non-opaque text, hex digests, and versions untouched', () => {
+    expect(redactor.isSecretValue('deepseek-chat')).toBe(false) // short
+    expect(redactor.isSecretValue('a token with spaces here!!')).toBe(false) // not opaque
+    expect(redactor.isSecretValue('a'.repeat(40))).toBe(false) // low entropy, one class
+    expect(redactor.isSecretValue('abcdef0123456789abcdef0123456789abcdef01')).toBe(false) // 40-hex git SHA
+    expect(redactor.isSecretValue('1.2.3.4.5.6.7.8.9.10.11.12')).toBe(false) // version-like
+    expect(redactor.isSecretValue('ZXQPZXQPZXQPZXQPZXQPZXQP')).toBe(false) // uppercase only, low entropy
+  })
+
+  it('honors a custom entropy threshold', () => {
+    const strict = new SecretRedactor({ entropyThreshold: 100 })
+    // Two-class token can no longer trip the entropy branch under an impossible threshold.
+    expect(strict.isSecretValue('abcdefghijklmnopqrstuvwxyz0123')).toBe(false)
+  })
+})
+
+describe('SecretRedactor.redactValue', () => {
+  const redactor = new SecretRedactor()
+
+  it('redacts secret-keyed strings and secret-shaped strings, keeping structure', () => {
+    const result = redactor.redactValue({
+      apiKey: 'short-not-shaped',
+      name: 'my-package',
+      token: 'sk-abcdefghij1234567890',
+      count: 3,
+      enabled: true,
+      missing: null,
+      nested: { password: 'p', note: 'plain text value' },
+      list: ['harmless', 'sk-abcdefghij1234567890'],
+    })
+    expect(result).toEqual({
+      apiKey: REDACTED, // redacted by key even though the value is not secret-shaped
+      name: 'my-package',
+      token: REDACTED,
+      count: 3,
+      enabled: true,
+      missing: null,
+      nested: { password: REDACTED, note: 'plain text value' },
+      list: ['harmless', REDACTED],
+    })
+  })
+
+  it('redacts a top-level secret string and passes through primitives', () => {
+    expect(redactor.redactValue('sk-abcdefghij1234567890')).toBe(REDACTED)
+    expect(redactor.redactValue('plain')).toBe('plain')
+    expect(redactor.redactValue(42)).toBe(42)
+    expect(redactor.redactValue(null)).toBeNull()
+  })
+})
+
+describe('SecretRedactor.redactText', () => {
+  const redactor = new SecretRedactor()
+
+  it('redacts PEM private key blocks', () => {
+    const text = '-----BEGIN RSA PRIVATE KEY-----\nMIIabc\ndef==\n-----END RSA PRIVATE KEY-----'
+    expect(redactor.redactText(text)).toBe(REDACTED)
+  })
+
+  it('redacts secret-keyed assignments across YAML, JSON, and .env', () => {
+    expect(redactor.redactText('password: hunter2')).toBe(`password: ${REDACTED}`)
+    expect(redactor.redactText('apiKey: "sk-abcdefghij1234567890"')).toBe(`apiKey: "${REDACTED}"`)
+    expect(redactor.redactText('"token": "abcdefgh"')).toBe(`"token": "${REDACTED}"`)
+    expect(redactor.redactText('API_KEY=sk-abcdefghij1234567890')).toBe(`API_KEY=${REDACTED}`)
+  })
+
+  it('keeps non-secret assignments and whitespace-only secret values intact', () => {
+    expect(redactor.redactText('model: deepseek-chat')).toBe('model: deepseek-chat')
+    expect(redactor.redactText('password:   \n')).toBe('password:   \n')
+  })
+
+  it('redacts only the password in URL credentials, keeping the host', () => {
+    expect(redactor.redactText('url: https://user:s3cretPass@api.deepseek.com/v1'))
+      .toBe(`url: https://user:${REDACTED}@api.deepseek.com/v1`)
+  })
+
+  it('redacts bearer tokens embedded in free text', () => {
+    expect(redactor.redactText('sending Bearer abcdefgh12345678 now'))
+      .toBe(`sending Bearer ${REDACTED} now`)
+  })
+
+  it('redacts standalone secret-shaped tokens while keeping package names and paths', () => {
+    expect(redactor.redactText('key sk-abcdefghij1234567890 end'))
+      .toBe(`key ${REDACTED} end`)
+    expect(redactor.redactText('name: @deepseek-ai/dsh-telemetry')).toBe('name: @deepseek-ai/dsh-telemetry')
+    expect(redactor.redactText('path: ./plugins/local-plugin/src/index.ts'))
+      .toBe('path: ./plugins/local-plugin/src/index.ts')
+  })
+
+  it('is idempotent on already-redacted text', () => {
+    const once = redactor.redactText('password: hunter2')
+    expect(redactor.redactText(once)).toBe(once)
+  })
+})

+ 13 - 0
packages/sdk/telemetry/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    { "path": "../../util/brand" }
+  ]
+}

+ 13 - 0
pnpm-lock.yaml

@@ -1267,6 +1267,19 @@ importers:
         specifier: ^4.22.4
         version: 4.22.4
 
+  packages/sdk/telemetry:
+    dependencies:
+      yaml:
+        specifier: ^2.9.0
+        version: 2.9.0
+    devDependencies:
+      '@deepseek-ai/dsh-brand':
+        specifier: workspace:^
+        version: link:../../util/brand
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
   packages/session-persistence/session-persistence:
     devDependencies:
       '@deepseek-ai/dsh-session':

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -54,6 +54,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' },
   'packages/sdk/plugin-fetch': { kind: 'none', reason: 'The fetcher acquires plugin sources into a temp dir and registers no live agent or model surface.' },
   'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },
+  'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' },
   'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
   'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
   'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },

+ 2 - 1
tsconfig.build.json

@@ -99,6 +99,7 @@
     { "path": "./packages/sdk/helper" },
     { "path": "./packages/sdk/scripts" },
     { "path": "./packages/sdk/create-sdk" },
-    { "path": "./packages/sdk/plugin-fetch" }
+    { "path": "./packages/sdk/plugin-fetch" },
+    { "path": "./packages/sdk/telemetry" }
   ]
 }

+ 2 - 1
tsconfig.json

@@ -110,6 +110,7 @@
     { "path": "./packages/sdk/helper" },
     { "path": "./packages/sdk/scripts" },
     { "path": "./packages/sdk/create-sdk" },
-    { "path": "./packages/sdk/plugin-fetch" }
+    { "path": "./packages/sdk/plugin-fetch" },
+    { "path": "./packages/sdk/telemetry" }
   ]
 }