/** * CLI drift detection (issue #435). * * The loop shells out to whatever `renaiss-shipflow` PATH resolves for every * decision it makes — inbox states, `pr ready`, `pr automerge`. Nothing ever * compared that binary against anything, and drift is **directional**: an older * CLI knows about fewer blockers, so it reports *fewer* reasons to park and * biases the loop toward merging what it should have held. One session ran * twelve versions behind and classified a `CONFLICTING` PR as `approved_ready`. * * Two rules this module exists to enforce: * * 1. **The reference is the npm registry `latest` dist-tag — never the working * tree.** A worktree's `package.json` is whatever branch is checked out; it * was measured at 0.27.10 while the installed binary was 0.28.2, so a * tree-based comparison reports *healthy* during real drift. The registry is * the only reference that is both fresh and names something installable. * 2. **Never trust an exit code from npm.** `npm i -g pkg@latest` was * reproduced printing `changed 12 packages in 2s` on exit 0 with the version * unchanged (npm 10.9.2 serves dist-tags from a 300s cache for `install`, * though not for `view`). Only a changed `--version` string proves an * upgrade — see {@link classifyUpgradeOutcome}. * 3. **A remediation must reach the binary the launcher actually runs.** THREE * install channels exist, not two — see {@link CliChannel}. Naming the wrong * one produces a command that exits 0 and changes nothing, which is this * module's own thesis recurring inside its own fix. */ /** The npm package the loop's PATH binary comes from. */ export declare const CLI_PACKAGE = "@renaiss-shipflow/cli"; /** Default npm registry; override with `SHIPFLOW_NPM_REGISTRY` (mirrors, tests). */ export declare const DEFAULT_REGISTRY = "https://registry.npmjs.org"; /** Time-box for the registry probe — mirrors the 8s server probe in `version`. */ export declare const REGISTRY_TIMEOUT_MS = 8000; /** * Exit code for `version --check` when the installed CLI is behind the registry. * Distinct from every other deliberate code (3 taken · 4 none · 5 not ready · * 6 conflict · 7 threads · 8 unresolved paths · 10 unexpected). */ export declare const DRIFT_STALE_EXIT_CODE = 9; /** Installed CLI vs the registry's `latest` dist-tag. */ export type Drift = "current" | "stale" | "ahead" | "unknown"; /** * Which install channel the running binary came from — the remediation differs, * and a remediation that runs cleanly against the wrong channel fixes nothing * (this issue's failure mode recurring inside its own fix). * * - `npm-global` — a real `npm i -g` prefix (`.../lib/node_modules/…`). * - `plugin-launcher` — the CLI snapshot bundled in the Claude plugin cache. * - `launcher-cache` — the npm copies `bin/shipflow-cli-update` fetches into * `/cli//node_modules/@renaiss-shipflow/cli/` (issue #307). * It has the npm-global *shape* but sits on a prefix `npm i -g` never writes * to, so an `npm i -g` remediation here upgrades a binary the launcher does * not scan and leaves the running one untouched. * - `unknown` — a dev checkout or an unresolvable path; a human decides. */ export type CliChannel = "npm-global" | "plugin-launcher" | "launcher-cache" | "unknown"; export interface RegistryProbe { /** Package queried. */ package: string; /** The `latest` dist-tag, or null when the probe failed. */ latest: string | null; /** Probe error message (offline, HTTP error, timeout), else null. */ error: string | null; } export interface CliProvenance { version: string; channel: CliChannel; } export interface Remediation { /** Whether an upgrade is called for (drift === "stale"). */ needed: boolean; channel: CliChannel; /** * The exact command to run — **never** a bare `@latest`: an exact-version * spec is not a tag lookup, so it bypasses npm's cached dist-tag entirely. * null when the channel is unknown (a human decides). */ command: string | null; /** How long to keep polling for the registry to catch up, in seconds. */ pollWindowSeconds: number; note: string; } interface ParsedSemver { core: [number, number, number]; prerelease: string | null; } /** Parse `major.minor.patch[-prerelease][+build]`; null when unparseable. */ export declare function parseSemver(v: string | null | undefined): ParsedSemver | null; /** * Compare two semver strings: -1 / 0 / 1. Throws never — an unparseable input * is the caller's problem (see {@link classifyDrift}, which maps it to * `"unknown"` rather than guessing a direction). */ export declare function compareSemver(a: string, b: string): number; /** * Classify the installed CLI against the registry's `latest`. Fails SOFT to * `"unknown"` — offline, a registry blip, or a version string neither side can * parse must never be reported as `"current"`, because "current" is what the * loop treats as permission to trust its own merge verdicts. */ export declare function classifyDrift(installed: string | null | undefined, registryLatest: string | null | undefined): Drift; /** * Path segment of the launcher's own npm cache, under the ShipFlow state dir * (`SHIPFLOW_STATE_DIR`, default `~/.shipflow`) — see `bin/shipflow-cli-update`. */ export declare const LAUNCHER_CACHE_SEGMENT = "/.shipflow/cli/"; /** * Resolve the install channel from the **realpath** of the running entry point. * Pure — pass `stateDir` explicitly (no env reads) so the path table is * testable without installing anything. * * THREE channels compete on this machine and nobody owns the one the loop runs: * the plugin cache ships its own launcher, `bin/shipflow-cli-update` fetches * npm copies into the state dir for that launcher to pick up, and an npm-global * install shadows both on PATH. Detection keys off the location, not off which * one *should* win. * * **Order is load-bearing.** The launcher's fetched copies live at * `/cli//node_modules/@renaiss-shipflow/cli/` — the npm-global * substring matches them too, so that test must come LAST or a launcher-managed * binary is remediated with `npm i -g`, writing to a prefix the launcher never * scans while the running binary stays exactly as stale as it was. */ export declare function detectChannel(realBinPath: string | null | undefined, opts?: { stateDir?: string | null; }): CliChannel; /** The running process's channel (memoized — one `realpath` per process). */ export declare function resolveCliChannel(): CliChannel; /** Claude's plugin cache root — where the launcher and its updater are installed. */ export declare const PLUGIN_CACHE_BASE: string; /** * Absolute path to the newest plugin-cached `bin/shipflow-cli-update`, or null * when no plugin cache exists. That script — not `npm i -g` — is the only thing * that refreshes a `launcher-cache` install, so the remediation for that channel * is built from this path. */ export declare function resolveLauncherUpdater(cacheBase?: string): string | null; /** Test seam: forget the memoized channel. */ export declare function resetChannelCache(): void; /** This CLI's own version, read from its package.json (same source as `--version`). */ export declare function cliVersion(): string; /** `{version, channel}` stamped into machine-readable envelopes so a verdict * carries the identity of the binary that produced it. Local reads only — no * network — so this is safe in a per-tick hot path. */ export declare function cliProvenance(): CliProvenance; /** * Stamp `cli: {version, channel}` onto a command's `--json` envelope. Applied * to `inbox`, `pr packet` and `pr reviews` — the three payloads a loop verdict * is built from — so the wrong-merge class is retroactively self-diagnosing * from the transcript alone. */ export declare function withProvenance(payload: T): T & { cli: CliProvenance; }; export declare function safeVersionSpec(v: string | null | undefined): string | null; /** * The channel-correct upgrade command, or null when no command can be trusted * to fix *this* binary. `updaterPath` comes from {@link resolveLauncherUpdater} * and is only consulted for the `launcher-cache` channel. */ export declare function remediationCommand(channel: CliChannel, target: string | null, updaterPath?: string | null): string | null; export declare function buildRemediation(drift: Drift, channel: CliChannel, target: string | null, pollWindowSeconds: number, updaterPath?: string | null): Remediation; /** * The outcome of an attempted upgrade, judged on the **version string**, never * on npm's exit code. `no-op` is the measured `@latest` failure: exit 0, * "changed 12 packages", version unchanged. */ export type UpgradeOutcome = "upgraded" | "no-op" | "unexpected"; export declare function classifyUpgradeOutcome(before: string, after: string, target: string): UpgradeOutcome; /** `version --check` exit code: non-zero only on a proven-stale binary. */ export declare function driftExitCode(drift: Drift): number; export interface DriftGateSupport { /** Whether the probed binary implements the drift gate at all. */ supported: boolean; /** Why it does not — null when supported. */ reason: string | null; } /** * **The bootstrap gap.** The drift gate ships *in* the binary it is meant to * police, so on the day it lands the binary on PATH is the previous release — * which has no `drift` key in `version --json` and rejects `version --check` as * an unknown option. Measured on the live 0.28.2: `version --json` → * `{cli, plugin, server}` (no `drift`); `version --check` → exit **1**, * `error: unknown option '--check'`. * * Read naively, both shapes look like "not stale → continue", so the exact * stale binary this module exists to repair green-lights itself and is never * upgraded. Neither shape is evidence of health — they are evidence the gate * **could not run**, which is `legacy-stale`: bootstrap it (read `--version` * directly, read the registry directly, remediate by channel) per * `loop-mode.md` §0 step 1. * * Fail-soft in the safe direction: an absent probe result is `supported: false` * (bootstrap and re-check), never a silent pass. */ export declare function supportsDriftGate(input: { /** Parsed `version --json` payload, if one was obtained. */ versionJson?: unknown; /** Exit code of `version --check`, if it was run. */ checkExitCode?: number | null; /** Combined stderr of `version --check`, if captured. */ checkStderr?: string | null; }): DriftGateSupport; /** * Pull the version out of a raw `--version` stdout — the one reading a legacy * binary is guaranteed to answer. Commander prints the bare string; a wrapper * may prefix the binary name, so take the first semver token. */ export declare function parseVersionFlagOutput(raw: string | null | undefined): string | null; /** * The repo path whose changes publish a new CLI to npm on merge. Only a merge * touching it can make the running binary stale. */ export declare const CLI_PUBLISH_PATH_PREFIX = "apps/renaissshipflow-cli/"; /** * Could this merge have published a new CLI? Gates the post-merge poll window: * that poll waits out the ~62s publish lag, and running it after a merge that * publishes nothing spends the whole window (default 180s) to learn nothing — * on every server-only ShipFlow change, and on every merge in every other repo * the loop runs against. */ export declare function mergePublishesCli(changedPaths: readonly string[] | null | undefined, prefix?: string): boolean; /** * Build the cache-busted dist-tags URL. npm's own client serves dist-tags from * a cache for up to 300s on the `install` path, which is how a losing install * pinned `latest=0.27.10` for every later `@latest` — this probe must never * inherit that class of staleness, from npm's cache or an HTTP one. */ export declare function registryDistTagsUrl(pkg: string, registry: string, nonce: string): string; /** * Probe the npm registry for the `latest` dist-tag. Time-boxed and * **fail-soft**: any failure yields `latest: null`, which classifies as * `drift: "unknown"` so an offline loop degrades instead of halting. * * Deliberately NOT response-cached. A TTL cache here would rebuild the exact * defect this issue is about (a stale dist-tag read surviving a publish); the * probe stays out of every hot path instead — `inbox`/`pr packet`/`pr reviews` * stamp provenance from local reads only and never call this. */ export declare function fetchRegistryLatest(opts?: { pkg?: string; registry?: string; timeoutMs?: number; fetchImpl?: typeof fetch; nonce?: string; }): Promise; /** * Every version invariant that must be visible — in **both** the text and * `--json` branches (issue #435: the plugin≠cli warning lived only in the text * branch, so `--json` callers, i.e. the loop, never saw it). * * Note what the old invariant could not see: plugin === cli says nothing when * BOTH are behind. That was the original failure — the cache held 0.26.2 and * the pair agreed with each other all the way down. Hence the registry-relative * checks below, which compare each component against something installable. */ export declare function driftWarnings(input: { cli: string; plugin: string | null; registryLatest: string | null; registryError: string | null; drift: Drift; channel: CliChannel; /** From {@link resolveLauncherUpdater} — needed to name a `launcher-cache` fix. */ updaterPath?: string | null; }): string[]; export {}; //# sourceMappingURL=cli-drift.d.ts.map