diff --git a/README.md b/README.md index 09f2dbf..9f4bdac 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ hyp smoke walkthrough_picker_to_first_query hyp smoke client_attach_idempotent hyp smoke gateway_claude_capture hyp smoke gateway_codex_capture +hyp smoke hypignore_capture_drop hyp smoke otel_loopback_capture hyp smoke local_parquet_export hyp smoke status_diagnostics diff --git a/collectivus-plugin-kernel-types.d.ts b/collectivus-plugin-kernel-types.d.ts index ffc347c..2a336e3 100644 --- a/collectivus-plugin-kernel-types.d.ts +++ b/collectivus-plugin-kernel-types.d.ts @@ -17,6 +17,7 @@ import type { AsyncDataSource, ScanOptions, ScanResults } from 'squirreling' import type { CachePartitioningDeclaration } from './src/core/iceberg/types.d.ts' +import type { UsagePolicyDrop } from './src/core/usage-policy/types.d.ts' export type { AsyncDataSource, ScanOptions, ScanResults } @@ -1463,6 +1464,12 @@ export interface AiGatewayClientStatusContext { * no projector succeeds the gateway still emits pass-through * telemetry (the `aigw.exchange` log and `aigw.exchange_bytes` meter) * but writes zero rows into `ai_gateway_messages`. + * + * Returning the `USAGE_POLICY_DROP` sentinel is distinct from declining + * with `undefined`: it is a TERMINAL `.hypignore` usage-policy drop + * (LLP 0050). The dispatcher stops the projector walk on it (no later + * projector is consulted) and logs it as an intentional drop, never as + * a `no_projector_match` miss, while still writing zero rows. */ export interface AiGatewayExchangeProjector { name: string @@ -1471,7 +1478,11 @@ export interface AiGatewayExchangeProjector { project( input: AiGatewayExchangeInput, ctx: AiGatewayExchangeProjectorContext - ): AiGatewayProjectedExchange | Promise | undefined + ): + | AiGatewayProjectedExchange + | UsagePolicyDrop + | Promise + | undefined } export interface AiGatewayExchangeProjectorContext { diff --git a/hypaware-core/plugins-workspace/ai-gateway/src/message_projector.js b/hypaware-core/plugins-workspace/ai-gateway/src/message_projector.js index 8b2bbd8..d3e78a5 100644 --- a/hypaware-core/plugins-workspace/ai-gateway/src/message_projector.js +++ b/hypaware-core/plugins-workspace/ai-gateway/src/message_projector.js @@ -2,11 +2,14 @@ import { createHash } from 'node:crypto' +import { isUsagePolicyDrop } from '../../../../src/core/usage-policy/index.js' + export const SCHEMA_VERSION = 7 /** * @import { AiGatewayExchangeInput, AiGatewayProjectedExchange, AiGatewayProjectedMessage, CachePartitionMeta, ColumnSpec, PluginLogger, QueryStorageService } from '../../../../collectivus-plugin-kernel-types.js' * @import { ExtendedQueryStorageService } from '../../../../src/core/cache/types.js' + * @import { UsagePolicyDrop } from '../../../../src/core/usage-policy/types.js' * @import { RegisteredProjector } from './types.js' */ @@ -165,6 +168,20 @@ export function createAiGatewayMessageProjector(opts) { async projectExchange(exchange) { const input = /** @type {AiGatewayExchangeInput} */ (exchange) const projection = await dispatchProjector(projectors, input, log) + // An intentional `.hypignore` usage-policy drop is a TERMINAL success, not + // a projection miss: the adapter already logged the rich + // `plugin..usage_policy_drop` event at the seam, so the gateway + // records the drop at info level (NOT the `no_projector_match` warn below, + // which would mislabel a privacy drop as a failure) and writes no rows. + // @ref LLP 0050 [implements] + if (isUsagePolicyDrop(projection)) { + log?.info?.('aigw.usage_policy_drop', { + exchange_id: stringValue(input.exchange_id) ?? '', + upstream: stringValue(input.upstream) ?? '', + reason: 'usage_policy_drop', + }) + return [] + } if (!projection) { log?.warn?.('aigw.message_projection_skipped', { exchange_id: stringValue(input.exchange_id) ?? '', @@ -490,7 +507,7 @@ export function aiGatewayRowsFromProjectedExchange(projection, opts = {}) { * @param {RegisteredProjector[]} projectors * @param {AiGatewayExchangeInput} input * @param {{ warn?: (m: string, f?: Record) => void } | undefined} log - * @returns {Promise} + * @returns {Promise} */ async function dispatchProjector(projectors, input, log) { if (projectors.length === 0) return undefined @@ -509,6 +526,12 @@ async function dispatchProjector(projectors, input, log) { }) continue } + // A usage-policy drop is TERMINAL: stop the walk and propagate the sentinel + // so the drop wins outright. Crucially we do NOT `continue` here (which is + // what a bare `undefined` decline does below) so a later overlapping + // projector can never record an exchange the user asked to suppress. + // @ref LLP 0050 [implements] + if (isUsagePolicyDrop(result)) return result if (!isValidProjection(result)) { if (result !== undefined) { log?.warn?.('aigw.projector_invalid_output', { diff --git a/hypaware-core/plugins-workspace/claude/src/backfill.js b/hypaware-core/plugins-workspace/claude/src/backfill.js index 2ff3bb0..664fa1d 100644 --- a/hypaware-core/plugins-workspace/claude/src/backfill.js +++ b/hypaware-core/plugins-workspace/claude/src/backfill.js @@ -9,10 +9,12 @@ import { import { pickLatestMatching, readSessionContext } from './session_context.js' import { deriveRepoFromCwd } from './git_repo.js' import { anthropicMessageAttributes } from './anthropic.js' +import { createUsagePolicyResolver } from '../../../../src/core/usage-policy/index.js' /** * @import { AiGatewayProjectedExchange, AiGatewayProjectedMessage, BackfillContribution, BackfillItem, BackfillProvenance, BackfillRunContext, JsonObject, PluginLogger } from '../../../../collectivus-plugin-kernel-types.js' * @import { SessionContextRecord, TranscriptEntry } from './types.js' + * @import { UsagePolicyResolver } from '../../../../src/core/usage-policy/types.js' */ /** @@ -66,6 +68,7 @@ const DAY_MS = 24 * 60 * 60 * 1000 * clientName?: string, * pluginName?: string, * deriveRepo?: (cwd: string | undefined) => Promise<{ git_remote?: string, repo_root?: string }>, + * resolver?: UsagePolicyResolver, * }} opts * @returns {BackfillContribution} */ @@ -78,6 +81,10 @@ export function createClaudeBackfillProvider(opts) { // recover it by running git in the session's cwd at backfill time. Injectable // so tests stub the git lookup and stay hermetic. const deriveRepo = opts.deriveRepo ?? deriveRepoFromCwd + // One resolver per backfill run (LLP 0050): the per-cwd cache reflects disk at + // run time and is shared across the whole scan. Injectable for hermetic tests. + // @ref LLP 0050 [implements]: skip ignored sessions at the capture seam. + const resolver = opts.resolver ?? createUsagePolicyResolver() return { name: clientName, @@ -85,7 +92,7 @@ export function createClaudeBackfillProvider(opts) { datasets: [AI_GATEWAY_MESSAGES_DATASET], summary: 'Import local Claude Code transcripts into ai_gateway_messages', async *run(ctx) { - yield* runClaudeBackfill({ ctx, projectsDir, stateFile, clientName, deriveRepo }) + yield* runClaudeBackfill({ ctx, projectsDir, stateFile, clientName, deriveRepo, resolver }) }, } } @@ -103,11 +110,12 @@ export function createClaudeBackfillProvider(opts) { * stateFile: string, * clientName: string, * deriveRepo: (cwd: string | undefined) => Promise<{ git_remote?: string, repo_root?: string }>, + * resolver: UsagePolicyResolver, * }} args * @returns {AsyncGenerator} */ async function* runClaudeBackfill(args) { - const { ctx, projectsDir, stateFile, clientName, deriveRepo } = args + const { ctx, projectsDir, stateFile, clientName, deriveRepo, resolver } = args const log = ctx.log const window = resolveWindow(ctx) // Many sessions share a cwd (the same repo, often the same checkout), and @@ -166,11 +174,36 @@ async function* runClaudeBackfill(args) { for (const [sessionId, sessionEntries] of groupBySession(entries)) { const windowed = filterByWindow(sessionEntries, window) + const record = pickLatestMatching(sessionRecords, { sessionId, transcriptPath: filePath }) + + // @ref LLP 0050 [implements]: capture-seam drop for backfill. Skip an + // ignored session BEFORE projecting/writing it, else `hyp backfill` would + // silently re-import the exact sessions ignored live (LLP 0049#requirements + // R1). The cwd precedence mirrors projectedExchangeFromEntries (the + // hook-written record wins, else the first transcript line's cwd), so the + // session is tested on the same cwd the row would have carried. + const sessionCwd = record?.cwd ?? windowed.find((entry) => entry.cwd)?.cwd + const sessionPolicy = sessionCwd ? resolver.resolve(sessionCwd) : null + if (sessionPolicy?.class === 'ignore') { + // A fail-safe clamp (declared token unimplemented) escalates to warn + // so an operator can tell it from an intended ignore (R3 SHOULD). + log[sessionPolicy.warn ? 'warn' : 'info']('claude.backfill.usage_policy_drop', { + component: 'plugin.claude.backfill', + operation: 'usage_policy_drop', + session_id: sessionId, + declared: sessionPolicy.declared, + governed_by: sessionPolicy.governedBy, + status: 'ok', + ...(sessionPolicy.warn ? { warn: sessionPolicy.warn } : {}), + }) + continue + } + const exchange = await projectedExchangeFromEntries({ sessionId, entries: windowed, clientName, - record: pickLatestMatching(sessionRecords, { sessionId, transcriptPath: filePath }), + record, agentMeta, deriveRepo: deriveRepoCached, }) diff --git a/hypaware-core/plugins-workspace/claude/src/projector.js b/hypaware-core/plugins-workspace/claude/src/projector.js index 6ff62ab..d59db3c 100644 --- a/hypaware-core/plugins-workspace/claude/src/projector.js +++ b/hypaware-core/plugins-workspace/claude/src/projector.js @@ -33,10 +33,12 @@ import { pickLatestMatching, readSessionContext, } from './session_context.js' +import { createUsagePolicyResolver, USAGE_POLICY_DROP } from '../../../../src/core/usage-policy/index.js' /** * @import { AiGatewayExchangeInput, AiGatewayExchangeProjector, AiGatewayProjectedExchange, AiGatewayProjectedMessage, AiGatewayUpstreamPreset, JsonObject } from '../../../../collectivus-plugin-kernel-types.js' * @import { TranscriptEntry } from './types.js' + * @import { UsagePolicyResolver } from '../../../../src/core/usage-policy/types.js' */ /** @@ -80,6 +82,7 @@ import { * projectsDir?: string, * clientName?: string, * logger?: { warn(message: string, fields?: Record): void, debug?: (m: string, f?: Record) => void }, + * resolver?: UsagePolicyResolver, * }} opts * @returns {AiGatewayExchangeProjector} */ @@ -89,6 +92,11 @@ export function createClaudeExchangeProjector(opts) { const clientName = opts.clientName ?? 'claude' const logger = opts.logger const sessionContextCache = createSessionContextCache() + // One resolver per projector (per daemon run): the per-cwd cache rides the + // projector's lifetime so the capture hot path adds no unbounded fs work. + // @ref LLP 0050 [implements]: the .hypignore capture-seam drop lives in the + // client adapter, the only place that resolves a cwd; injectable for tests. + const resolver = opts.resolver ?? createUsagePolicyResolver() return { name: 'claude-anthropic-messages', @@ -152,6 +160,33 @@ export function createClaudeExchangeProjector(opts) { sessionId, }) : undefined + + // @ref LLP 0050 [implements]: capture-seam drop. Once the exchange's cwd + // is resolved, an ancestor `.hypignore` that resolves to `ignore` means + // this exchange is never recorded: return BEFORE building any rows, so the + // gateway source's write guard (`if (messageRows.length > 0)`) persists + // nothing. The response has already streamed to the client, so the live + // LLM call is untouched (LLP 0049#requirements R2). The drop returns the + // terminal `USAGE_POLICY_DROP` sentinel (NOT a bare `undefined`): the + // dispatcher stops the projector walk on it so no later projector can + // record the suppressed exchange, and logs it as a drop rather than a + // `no_projector_match` miss. + const cwd = sessionContextRecord?.cwd + const policy = cwd ? resolver.resolve(cwd) : null + if (policy?.class === 'ignore') { + // A fail-safe clamp (declared token unimplemented) escalates to warn + // so an operator can tell it from an intended ignore (R3 SHOULD). + ctx.log[policy.warn ? 'warn' : 'info']('plugin.claude.usage_policy_drop', { + component: 'claude', + operation: 'usage_policy_drop', + exchange_id: input.exchange_id, + declared: policy.declared, + governed_by: policy.governedBy, + ...(policy.warn ? { warn: policy.warn } : {}), + }) + return USAGE_POLICY_DROP + } + const transcriptEntries = sessionId ? await loadTranscriptSafe({ projectsDir, diff --git a/hypaware-core/plugins-workspace/codex/src/backfill.js b/hypaware-core/plugins-workspace/codex/src/backfill.js index 111bca7..24c3f8f 100644 --- a/hypaware-core/plugins-workspace/codex/src/backfill.js +++ b/hypaware-core/plugins-workspace/codex/src/backfill.js @@ -3,11 +3,13 @@ import fs from 'node:fs/promises' import path from 'node:path' +import { createUsagePolicyResolver } from '../../../../src/core/usage-policy/index.js' import { redactRemoteUserinfo } from './git-remote.js' /** * @import { AiGatewayProjectedExchange, AiGatewayProjectedMessage, BackfillContribution, BackfillEvent, BackfillItem, BackfillProvenance, BackfillRunContext, JsonObject, JsonValue, PluginLogger } from '../../../../collectivus-plugin-kernel-types.js' * @import { CodexRolloutItem, CodexRolloutSession } from './types.js' + * @import { UsagePolicyResolver } from '../../../../src/core/usage-policy/types.js' */ /** @@ -74,6 +76,7 @@ const DAY_MS = 24 * 60 * 60 * 1000 * unsupportedLocations?: Array<{ kind: string, path: string }>, * clientName?: string, * pluginName?: string, + * resolver?: UsagePolicyResolver, * }} opts * @returns {BackfillContribution} */ @@ -83,6 +86,9 @@ export function createCodexBackfillProvider(opts) { const codexHome = opts.codexHome ?? defaultCodexHome(opts.homeDir) const sessionsDir = opts.sessionsDir ?? path.join(codexHome, 'sessions') const unsupportedLocations = opts.unsupportedLocations ?? defaultUnsupportedLocations(opts.homeDir) + // One `.hypignore` resolver per backfill run, holding its per-cwd cache for + // the whole scan (LLP 0049 R6). + const resolver = opts.resolver ?? createUsagePolicyResolver() return { name: clientName, @@ -90,7 +96,7 @@ export function createCodexBackfillProvider(opts) { datasets: [AI_GATEWAY_MESSAGES_DATASET], summary: 'Import local Codex session rollouts into ai_gateway_messages', async *run(ctx) { - yield* runCodexBackfill({ ctx, codexHome, sessionsDir, unsupportedLocations, clientName }) + yield* runCodexBackfill({ ctx, codexHome, sessionsDir, unsupportedLocations, clientName, resolver }) }, } } @@ -131,11 +137,12 @@ function defaultUnsupportedLocations(homeDir) { * sessionsDir: string, * unsupportedLocations: Array<{ kind: string, path: string }>, * clientName: string, + * resolver: UsagePolicyResolver, * }} args * @returns {AsyncGenerator} */ async function* runCodexBackfill(args) { - const { ctx, codexHome, sessionsDir, unsupportedLocations, clientName } = args + const { ctx, codexHome, sessionsDir, unsupportedLocations, clientName, resolver } = args const log = ctx.log const window = resolveWindow(ctx) @@ -158,6 +165,7 @@ async function* runCodexBackfill(args) { let filesSeen = 0 let sessionsProjected = 0 + let sessionsIgnored = 0 let messagesProjected = 0 for (const filePath of await listRolloutFiles(sessionsDir)) { @@ -180,6 +188,29 @@ async function* runCodexBackfill(args) { } for (const session of sessions) { + // @ref LLP 0050 [implements]: capture-seam drop for backfill, symmetric + // to the @hypaware/claude backfill skip. A session whose recorded cwd has + // an ancestor `.hypignore` of class `ignore` is skipped before projecting + // or yielding any row, so `hyp backfill` never re-imports the exact + // sessions ignored live (LLP 0049 R1). + const sessionPolicy = session.cwd ? resolver.resolve(session.cwd) : null + if (sessionPolicy?.class === 'ignore') { + sessionsIgnored += 1 + // A fail-safe clamp (declared token unimplemented) escalates to warn + // so an operator can tell it from an intended ignore (R3 SHOULD). + log[sessionPolicy.warn ? 'warn' : 'info']('codex.backfill.usage_policy_drop', { + component: COMPONENT, + operation: 'usage_policy_drop', + conversation_id: session.sessionId, + class: 'ignore', + declared: sessionPolicy.declared, + governed_by: sessionPolicy.governedBy, + status: 'skipped', + ...(sessionPolicy.warn ? { warn: sessionPolicy.warn } : {}), + }) + continue + } + const exchange = projectedExchangeFromSession({ session, items: filterByWindow(session.items, window), @@ -211,6 +242,7 @@ async function* runCodexBackfill(args) { operation: 'backfill.scan', files_seen: filesSeen, sessions_projected: sessionsProjected, + sessions_ignored: sessionsIgnored, messages_projected: messagesProjected, status: 'ok', }) diff --git a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js index 986a6cf..8a2a3d4 100644 --- a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js +++ b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js @@ -2,11 +2,13 @@ import { createHash } from 'node:crypto' +import { createUsagePolicyResolver, USAGE_POLICY_DROP } from '../../../../src/core/usage-policy/index.js' import { redactRemoteUserinfo } from './git-remote.js' /** * @import { AiGatewayExchangeInput, AiGatewayExchangeProjector, AiGatewayProjectedExchange, AiGatewayProjectedMessage, JsonObject } from '../../../../collectivus-plugin-kernel-types.js' * @import { CodexLogReader } from './types.js' + * @import { UsagePolicyResolver } from '../../../../src/core/usage-policy/types.js' */ /** @@ -27,6 +29,7 @@ import { redactRemoteUserinfo } from './git-remote.js' * @param {{ * logReaders?: CodexLogReader[], * env?: Record, + * resolver?: UsagePolicyResolver, * }} [opts] * @returns {AiGatewayExchangeProjector} */ @@ -36,6 +39,10 @@ export function createCodexExchangeProjector(opts = {}) { const logReaders = sqliteReadsEnabled && Array.isArray(opts.logReaders) ? opts.logReaders : [] + // One `.hypignore` resolver per projector instance (one per started + // listener): the per-cwd cache then spans the listener's lifetime so the + // capture hot path adds no unbounded fs work (LLP 0049 R6). + const resolver = opts.resolver ?? createUsagePolicyResolver() return { name: 'codex-exchange', @@ -54,14 +61,48 @@ export function createCodexExchangeProjector(opts = {}) { return false }, - /** @param {AiGatewayExchangeInput} input */ - project(input) { + /** + * @param {AiGatewayExchangeInput} input + * @param {{ log?: { info?: (m: string, f?: Record) => void } }} [ctx] + */ + project(input, ctx) { const reqBody = parseMaybeJson(input.request_body) if (!isPlainObject(reqBody)) return undefined const path = input.path ?? '' const provider = resolveProvider(input, reqBody, path) const codexContext = resolveCodexContext(input, provider, path, reqBody) + + // @ref LLP 0050 [implements]: capture-seam drop, symmetric to the + // @hypaware/claude projector. Once this exchange's cwd is resolved, an + // ancestor `.hypignore` of class `ignore` drops the exchange by returning + // the terminal `USAGE_POLICY_DROP` sentinel (the gateway source's + // `messageRows.length > 0` write guard then persists nothing). The + // sentinel (NOT a bare `undefined`) stops the dispatcher's projector walk + // so no later overlapping projector can record the suppressed exchange, + // and is logged as a drop rather than a `no_projector_match` miss. The + // response has already streamed, so the live call is untouched: only + // persistence is suppressed (LLP 0049 R1/R2). This is the same cwd + // `resolveRecordedContext` would stamp on the row. + const cwd = firstString(codexContext?.cwd, readRecordedCwd(reqBody)) + if (cwd) { + const policy = resolver.resolve(cwd) + if (policy.class === 'ignore') { + // `declared` distinguishes an intended `ignore` from a fail-safe clamp + // of an unimplemented token; on a clamp escalate to warn (R3 SHOULD). + ctx?.log?.[policy.warn ? 'warn' : 'info']?.('plugin.codex.usage_policy_drop', { + component: 'codex', + operation: 'usage_policy_drop', + class: policy.class, + declared: policy.declared, + governed_by: policy.governedBy, + cwd_sha256: sha256Hex(cwd).slice(0, 16), + ...(policy.warn ? { warn: policy.warn } : {}), + }) + return USAGE_POLICY_DROP + } + } + const responseBody = parseMaybeJson(input.response_body) const streamEvents = Array.isArray(input.stream_events) ? input.stream_events : [] const messages = messagesForTransport({ provider, path, reqBody, responseBody, streamEvents }) diff --git a/hypaware-core/smoke/flows/hypignore_capture_drop.js b/hypaware-core/smoke/flows/hypignore_capture_drop.js new file mode 100644 index 0000000..c0239f4 --- /dev/null +++ b/hypaware-core/smoke/flows/hypignore_capture_drop.js @@ -0,0 +1,548 @@ +// @ts-check + +import fs from 'node:fs/promises' +import http from 'node:http' +import path from 'node:path' +import process from 'node:process' +import { Readable } from 'node:stream' + +import { + Attr, + installObservability, + getLogger, + runRoot, +} from '../../../src/core/observability/index.js' +import { defaultConfigPath } from '../../../src/core/config/schema.js' +import { runDaemon } from '../../../src/core/daemon/runtime.js' +import { dispatch } from '../../../src/core/cli/dispatch.js' + +/** + * Hermetic smoke: the `.hypignore` usage policy drops capture at the seam. + * + * @ref LLP 0049#requirements [tests]: R1/R2 end-to-end - an exchange whose + * resolved cwd has an ancestor .hypignore of class ignore is never written to + * the cache, while the live (already streamed) call is untouched. + * @ref LLP 0050 [tests]: enforcement lives in the client adapter (the + * @hypaware/claude projector), proved end-to-end through the daemon. + * @ref LLP 0053#tasks: implements plan task T5 (the hermetic smoke). + * + * Boots `runDaemon` with `@hypaware/ai-gateway` AND `@hypaware/claude` against + * an in-process Anthropic-flavored echo upstream, stages two Claude sessions + * via the session-context hook (one whose `cwd` sits under a `.hypignore`, one + * clean), then drives one exchange from each through the gateway and asserts: + * + * - **Only the clean row lands.** A query over `ai_gateway_messages` returns + * exactly the clean session's rows; no row carries the ignored `cwd`, and + * the ignored session id produced nothing. + * - **A `usage_policy_drop` event is emitted.** The claude projector logs + * `plugin.claude.usage_policy_drop` (`operation = usage_policy_drop`, + * `governed_by` = the ignored `.hypignore`) for the dropped exchange. + * - **The live call was untouched.** The gateway returned 200 for the ignored + * exchange and its `aigw.exchange` log records `rows_written = 0`. + * + * Every phase runs under a `smoke_step`-tagged root span so a failure points at + * the broken step, per the repo's log-driven ethos. + * + * @param {{ harness: any, expect: any }} args + */ +export async function run({ harness, expect }) { + const obs = installObservability() + if (!obs.tracer.provider) { + throw new Error( + 'hypignore_capture_drop: tracer provider not installed - expected HYP_DEV_TELEMETRY=1' + ) + } + const log = getLogger('smoke') + + /** + * Stable `smoke_step` attribute bag for a phase. + * @param {string} name + * @returns {Record} + */ + const stepBag = (name) => ({ + [Attr.COMPONENT]: 'smoke', + [Attr.OPERATION]: 'step', + [Attr.SMOKE_NAME]: harness.smokeName, + [Attr.SMOKE_STEP]: name, + [Attr.DEV_RUN_ID]: harness.devRunId, + status: 'ok', + }) + + /** + * Run one phase under a `smoke_step`-tagged root span so a failure names + * the broken step, per the repo's log-driven ethos. + * @template T + * @param {string} name + * @param {() => Promise} fn + * @returns {Promise} + */ + const step = (name, fn) => + runRoot(`smoke.step.${name}`, stepBag(name), async () => { + log.info(`smoke step ${name}`, stepBag(name)) + return fn() + }) + + // Track the upstream + daemon + the env keys setup mutates in an OUTER scope + // so the finally below always tears them down, even when an assertion throws + // mid-flow. Otherwise a failed run leaks a live daemon and echo server plus an + // unflushed telemetry pipeline into the next smoke (and leaves HYP_HOME / + // HYP_CONFIG / HOME pointing at this run's temp dirs). + const envSnapshot = { + HYP_HOME: process.env.HYP_HOME, + HYP_CONFIG: process.env.HYP_CONFIG, + HOME: process.env.HOME, + } + /** @type {Awaited> | undefined} */ + let echo + /** @type {Awaited> | undefined} */ + let handle + let obsShutDown = false + + try { + // ----- smoke_step: setup ----- + // Stage the echo upstream, the two cwds (ignored vs clean), the v2 config, + // and the two session-context records the projector reads back per exchange. + const setup = await step('setup', async () => { + echo = await startAnthropicEchoUpstream() + + // A Claude HOME with an (empty) projects dir so the plugin never reaches + // for the developer's real `~/.claude`. Neither session has a transcript: + // the clean one takes the gateway fallback identity, which is enough to + // prove a row lands; the ignored one is dropped before identity matters. + const claudeHome = path.join(harness.hypHome, 'home') + const claudeProjectsDir = path.join(claudeHome, '.claude', 'projects', 'some-repo') + await fs.mkdir(claudeProjectsDir, { recursive: true }) + + // The ignored scope: a `.hypignore` (self-documenting, `ignore` token) at + // the root of one repo. The clean scope is a sibling with no governing + // file, so the ancestor walk from it resolves to `full`. + const ignoredCwd = path.join(harness.tmpDir, 'ignored-repo') + const cleanCwd = path.join(harness.tmpDir, 'clean-repo') + await fs.mkdir(ignoredCwd, { recursive: true }) + await fs.mkdir(cleanCwd, { recursive: true }) + const hypignorePath = path.join(ignoredCwd, '.hypignore') + await fs.writeFile( + hypignorePath, + '# HypAware: do not record work done in this directory subtree.\nignore\n', + 'utf8' + ) + + const configPath = defaultConfigPath(harness.hypHome) + await fs.mkdir(path.dirname(configPath), { recursive: true }) + await fs.writeFile(configPath, JSON.stringify({ + version: 2, + plugins: [ + { + name: '@hypaware/ai-gateway', + config: { + listen: '127.0.0.1:0', + upstreams: [ + // Distinct name + high priority so routing prefers this echo + // over the claude preset (which would rewrite base_url to + // api.anthropic.com); the projector still matches on path + + // headers and processes the captured exchange. + { + name: 'echo-anthropic', + base_url: echo.url, + path_prefix: '/v1/messages', + priority: 1000, + }, + ], + }, + }, + { name: '@hypaware/claude' }, + ], + query: { cache: { retention: { default_days: 30 } } }, + }, null, 2)) + + process.env.HYP_HOME = harness.hypHome + process.env.HYP_CONFIG = configPath + process.env.HOME = claudeHome + + // The kernel resolves the claude plugin's state dir to + // `/hypaware/plugins/`; mirror that recipe and drive the + // hook (as Claude Code would on SessionStart) once per session so the + // projector reads each session's `cwd` back from session-context. + const stateFile = path.join( + harness.hypHome, + 'hypaware', 'plugins', '@hypaware/claude', + 'session-context.jsonl' + ) + await fs.mkdir(path.dirname(stateFile), { recursive: true }) + + const ignoredSession = `ignored-${harness.devRunId}` + const cleanSession = `clean-${harness.devRunId}` + for (const { session, cwd } of [ + { session: ignoredSession, cwd: ignoredCwd }, + { session: cleanSession, cwd: cleanCwd }, + ]) { + const hookCode = await dispatch( + ['claude-hook', 'session-context', '--state-file', stateFile], + { + stdout: makeBuf(), + stderr: makeBuf(), + stdin: stdinFor({ + session_id: session, + cwd, + hook_event_name: 'SessionStart', + }), + env: { ...process.env, HYP_HOME: harness.hypHome, HYP_CONFIG: configPath }, + } + ) + expect.that(`hook: session-context for ${session} exited 0`, hookCode, (v) => v === 0) + } + const stateLines = (await fs.readFile(stateFile, 'utf8')) + .split('\n').filter((l) => l.length > 0).length + expect.that('hook: state file got both session-context records', stateLines, (v) => v === 2) + + return { configPath, ignoredCwd, cleanCwd, hypignorePath, ignoredSession, cleanSession } + }) + + const { configPath, ignoredCwd, cleanCwd, hypignorePath, ignoredSession, cleanSession } = setup + + // ----- smoke_step: drive_exchanges ----- + // Boot the daemon, then send one exchange from the ignored cwd and one from + // the clean cwd. Both must return 200 (the gateway is pass-through, R2). + await step('drive_exchanges', async () => { + handle = await runDaemon({ + hypHome: harness.hypHome, + configPath, + env: process.env, + runId: harness.devRunId, + tickIntervalMs: 50, + installSignalHandlers: false, + }) + + const snapshot = handle.snapshot() + const gatewayDetails = /** @type {{ host: string, port: number, projectors: string[] }} */ ( + snapshot.sources.find((s) => s.name === 'ai-gateway')?.details + ) + expect.that( + 'snapshot: claude exchange projector registered against the gateway', + gatewayDetails?.projectors, + (v) => Array.isArray(v) && v.includes('claude-anthropic-messages'), + ) + const gatewayUrl = `http://${gatewayDetails.host}:${gatewayDetails.port}` + + const ignoredResp = await postJson(`${gatewayUrl}/v1/messages`, harness.devRunId, JSON.stringify({ + model: 'claude-3-opus', + metadata: { user_id: JSON.stringify({ session_id: ignoredSession }) }, + messages: [{ role: 'user', content: `ignored ${harness.devRunId}` }], + }), { + id: 'msg_ignored', + role: 'assistant', + content: [{ type: 'text', text: 'ignored reply' }], + stop_reason: 'end_turn', + }) + expect.that('gateway: ignored-cwd exchange still returned 200 (pass-through)', ignoredResp.statusCode, (v) => v === 200) + + const cleanResp = await postJson(`${gatewayUrl}/v1/messages`, harness.devRunId, JSON.stringify({ + model: 'claude-3-opus', + metadata: { user_id: JSON.stringify({ session_id: cleanSession }) }, + messages: [{ role: 'user', content: `clean ${harness.devRunId}` }], + }), { + id: 'msg_clean', + role: 'assistant', + content: [{ type: 'text', text: 'clean reply' }], + stop_reason: 'end_turn', + }) + expect.that('gateway: clean-cwd exchange returned 200', cleanResp.statusCode, (v) => v === 200) + + // Let at least one sink tick fire so the recorder drains before stop. + await sleep(120) + }) + + // ----- Shut down + flush so the JSONL artifacts are complete ----- + // The assert steps below read the on-disk logs/traces, so the daemon must + // stop and obs must flush FIRST. The finally backstops the case where a + // failure prevents reaching here. Null out each handle as it is released so + // the finally only acts on what the normal path left running. + await handle?.stop() + await handle?.done + handle = undefined + await obs.shutdown() + obsShutDown = true + await echo?.close() + echo = undefined + + // ----- smoke_step: assert_cache (only the clean row lands) ----- + await step('assert_cache', async () => { + const sql = ` + select role, content_text, cwd, session_id + from ai_gateway_messages + where JSON_VALUE(attributes, '$.dev_run_id') = '${harness.devRunId}' + order by session_id, message_index + `.trim().replace(/\s+/g, ' ') + const stdoutBuf = makeBuf() + const stderrBuf = makeBuf() + const code = await dispatch( + ['query', 'sql', sql, '--refresh', 'always', '--format', 'json'], + { stdout: stdoutBuf, stderr: stderrBuf, env: { ...process.env, HYP_HOME: harness.hypHome, HYP_CONFIG: configPath } }, + ) + expect.that('dispatch: query sql exited 0', code, (v) => v === 0) + expect.that('stderr: query sql had no errors', stderrBuf.text(), (v) => typeof v === 'string' && v.length === 0) + + /** @type {any[]} */ + let rows + try { + rows = JSON.parse(stdoutBuf.text()) + } catch (err) { + expect.that( + `stdout: query sql --format json was valid JSON (${err instanceof Error ? err.message : String(err)})`, + false, + (v) => v === true, + ) + return + } + + // Exactly the clean session's two rows (user + assistant). The ignored + // exchange was dropped at the capture seam, so it contributes nothing. + expect.that( + 'query: ai_gateway_messages has exactly the clean session rows (2)', + rows, + (v) => Array.isArray(v) && v.length === 2, + ) + expect.that( + 'query: every landed row belongs to the clean session', + rows, + (v) => Array.isArray(v) && v.every((r) => r.session_id === cleanSession), + ) + expect.that( + 'query: no landed row belongs to the ignored session', + rows, + (v) => Array.isArray(v) && v.every((r) => r.session_id !== ignoredSession), + ) + expect.that( + 'query: every landed row carries the clean cwd, never the ignored cwd', + rows, + (v) => Array.isArray(v) && v.every((r) => r.cwd === cleanCwd && r.cwd !== ignoredCwd), + ) + }) + + // ----- smoke_step: assert_drop (usage_policy_drop event emitted) ----- + await step('assert_drop', async () => { + const logs = await expect.logs() + + const drops = logs.filter( + (/** @type {any} */ l) => + l.body === 'plugin.claude.usage_policy_drop' && + l.attributes?.operation === 'usage_policy_drop', + ) + expect.that( + 'logs: exactly one usage_policy_drop event for the ignored exchange', + drops, + (v) => Array.isArray(v) && v.length === 1, + ) + expect.that( + 'logs: usage_policy_drop names the governing .hypignore', + drops[0]?.attributes?.governed_by, + (v) => v === hypignorePath, + ) + + // The live call was pass-through: the gateway recorded the ignored + // exchange with zero rows written, and the clean one with rows. + const exchanges = logs.filter( + (/** @type {any} */ l) => + l.body === 'aigw.exchange' && l.attributes?.[Attr.DEV_RUN_ID] === harness.devRunId, + ) + expect.that( + 'logs: both exchanges logged an aigw.exchange record', + exchanges, + (v) => Array.isArray(v) && v.length === 2, + ) + expect.that( + 'logs: the dropped exchange wrote zero rows (capture suppressed)', + exchanges.filter((/** @type {any} */ l) => l.attributes?.rows_written === 0), + (v) => Array.isArray(v) && v.length === 1, + ) + expect.that( + 'logs: the clean exchange wrote rows', + exchanges.filter((/** @type {any} */ l) => Number(l.attributes?.rows_written) > 0), + (v) => Array.isArray(v) && v.length === 1, + ) + }) + + // ----- smoke_step: assert_telemetry (daemon self-signal) ----- + await step('assert_telemetry', async () => { + const traces = await expect.traces() + const cacheAppends = traces.filter( + (/** @type {any} */ t) => + t.name === 'cache.append' && t.attributes?.hyp_dataset === 'ai_gateway_messages', + ) + // Only the clean exchange ever appends; the ignored one never reaches the + // cache, so there is at least one append and it is for the clean session. + expect.that( + 'traces: at least one cache.append for ai_gateway_messages (clean exchange only)', + cacheAppends, + (v) => Array.isArray(v) && v.length >= 1, + ) + const shutdownSpans = traces.filter((/** @type {any} */ t) => t.name === 'daemon.shutdown') + expect.that( + 'traces: daemon.shutdown span recorded under the daemon boot path', + shutdownSpans, + (v) => Array.isArray(v) && v.length >= 1, + ) + }) + } finally { + // Always release the daemon + upstream + env, even when a step threw before + // the normal teardown ran. A failed assertion must never leave the daemon + // or echo server running, the telemetry pipeline unflushed, or HYP_HOME / + // HYP_CONFIG / HOME leaked into the next smoke. Each release is best-effort + // so the original failure (not teardown noise) is what surfaces. + if (handle) { + try { await handle.stop() } catch { /* already stopping or stopped */ } + try { await handle.done } catch { /* surface the original failure */ } + } + if (!obsShutDown) { + try { await obs.shutdown() } catch { /* best-effort flush */ } + } + if (echo) { + try { await echo.close() } catch { /* best-effort close */ } + } + restoreEnv('HYP_HOME', envSnapshot.HYP_HOME) + restoreEnv('HYP_CONFIG', envSnapshot.HYP_CONFIG) + restoreEnv('HOME', envSnapshot.HOME) + } +} + +/** + * Restore a `process.env` key to a snapshot value, deleting it when the + * snapshot was unset (assigning `undefined` coerces to the string "undefined"). + * + * @param {string} key + * @param {string | undefined} value + */ +function restoreEnv(key, value) { + if (value === undefined) delete process.env[key] + else process.env[key] = value +} + +// --------------------------------------------------------------------- +// Test upstream + helpers (mirrors gateway_claude_capture) +// --------------------------------------------------------------------- + +/** + * Echo upstream that returns the requested assistant body (set via a base64 + * header) so the projector's `responseBody` path is exercised. + */ +async function startAnthropicEchoUpstream() { + const server = http.createServer((req, res) => { + /** @type {Buffer[]} */ + const chunks = [] + req.on('data', (chunk) => chunks.push(Buffer.from(chunk))) + req.on('end', () => { + const assistantHeader = req.headers['x-test-assistant-b64'] + const assistant = typeof assistantHeader === 'string' + ? safeJson(Buffer.from(assistantHeader, 'base64').toString('utf8')) + : { role: 'assistant', content: [{ type: 'text', text: 'ok' }] } + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(assistant)) + }) + req.on('error', () => res.end()) + }) + await listen(server) + const addr = server.address() + if (!addr || typeof addr !== 'object') throw new Error('echo: failed to bind') + return { + url: `http://127.0.0.1:${addr.port}`, + close: () => closeServer(server), + } +} + +/** @param {http.Server} server */ +function listen(server) { + return new Promise((resolve, reject) => { + server.once('error', reject) + server.once('listening', () => resolve(undefined)) + server.listen(0, '127.0.0.1') + }) +} + +/** @param {http.Server} server */ +function closeServer(server) { + return new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve(undefined))) + }) +} + +/** + * Issue one POST through the gateway carrying the contract header and a + * scripted assistant body the upstream echo plays back. + * + * @param {string} url + * @param {string} runId + * @param {string} body + * @param {Record | undefined} assistant + * @returns {Promise<{ statusCode: number, body: string }>} + */ +function postJson(url, runId, body, assistant) { + return new Promise((resolve, reject) => { + const parsed = new URL(url) + /** @type {Record} */ + const headers = { + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(body)), + 'x-hyp-dev-run-id': runId, + 'anthropic-version': '2023-06-01', + 'user-agent': 'claude-cli/1.0', + } + if (assistant) { + headers['x-test-assistant-b64'] = Buffer.from(JSON.stringify(assistant), 'utf8').toString('base64') + } + const req = http.request( + { + method: 'POST', + hostname: parsed.hostname, + port: Number.parseInt(parsed.port, 10), + path: parsed.pathname + parsed.search, + headers, + }, + (res) => { + /** @type {Buffer[]} */ + const chunks = [] + res.on('data', (chunk) => chunks.push(Buffer.from(chunk))) + res.on('end', () => + resolve({ + statusCode: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString('utf8'), + }), + ) + res.on('error', reject) + }, + ) + req.on('error', reject) + req.write(body) + req.end() + }) +} + +/** @param {string | Record} value */ +function stdinFor(value) { + const body = typeof value === 'string' ? value : JSON.stringify(value) + return /** @type {NodeJS.ReadStream} */ (Readable.from([body])) +} + +/** @param {string} raw */ +function safeJson(raw) { + try { return JSON.parse(raw) } catch { return undefined } +} + +/** @param {number} ms */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function makeBuf() { + /** @type {string[]} */ + const chunks = [] + return { + /** @param {unknown} chunk */ + write(chunk) { + chunks.push(typeof chunk === 'string' ? chunk : String(chunk)) + return true + }, + text() { + return chunks.join('') + }, + } +} diff --git a/llp/0052-hypignore-usage-policy.design.md b/llp/0052-hypignore-usage-policy.design.md new file mode 100644 index 0000000..a5dc32e --- /dev/null +++ b/llp/0052-hypignore-usage-policy.design.md @@ -0,0 +1,200 @@ +# LLP 0052: hypignore usage policy — technical design + +**Type:** design +**Status:** Active +**Systems:** Sources, Gateway, CLI, Core +**Generated-by:** neutral +**Author:** Phil / Claude +**Date:** 2026-06-29 +**Related:** LLP 0049, LLP 0050, LLP 0051, LLP 0009, LLP 0012 + +> Buildable design for the `.hypignore` folder-scoped usage policy. +> `@ref LLP 0049 [implements]` — realizes the hypignore-usage-policy spec (scope→class, +> the `ignore` class, the `hyp ignore`/`unignore` CLI). +> `@ref LLP 0050 [constrained-by]` — enforcement lives in the client adapters; the +> shared matcher lives in `src/core/usage-policy/`; the gateway stays `cwd`-blind. +> `@ref LLP 0051 [constrained-by]` — `local-only` and the session opt-out are deferred; +> the file format and matcher stay forward-compatible with them. + +## Overview + +One new core module (`src/core/usage-policy/`) plus four small adapter drop-sites and +two CLI verbs. Nothing touches the cache schema, the export driver, or the gateway — +V1 enforces only the **capture seam** ([LLP 0049](./0049-hypignore-usage-policy.spec.md#enforcement), +[LLP 0050](./0050-ignore-enforced-in-adapters.decision.md)). + +## Core module: `src/core/usage-policy/` {#module} + +A `cwd`-agnostic unit of path logic, imported by the adapters exactly as they import +`src/core/observability` (LLP 0050 §Shared matcher in core). Core gains a reusable +matcher and **no** `cwd` concept — only an adapter knows which row field is the `cwd`. + +``` +src/core/usage-policy/ + index.js // public API (barrel) + format.js // parse a .hypignore body -> UsageClass (+ fail-safe) + matcher.js // resolveUsageClass(cwd) — ancestor walk + per-cwd cache + types.d.ts // UsageClass, UsagePolicyResolver, ResolveResult +``` + +### Types (`types.d.ts`) + +```js +// V1 ships `ignore`; `local-only`/`full` are reserved/implicit (LLP 0049 §classes, LLP 0051). +export type UsageClass = 'ignore' | 'local-only' | 'full' + +export interface ResolveResult { + class: UsageClass // the resolved, implemented class ('full' when nothing governs) + governedBy: string | null // absolute path of the nearest governing .hypignore, or null + declared: string | null // the raw token read (e.g. 'local-only'), before fail-safe +} + +export interface UsagePolicyResolver { + resolve(cwd: string): ResolveResult + isIgnored(cwd: string): boolean // resolve(cwd).class === 'ignore' +} +``` + +### `format.js` — parse a `.hypignore` body {#format} + +Pure, fs-free; unit-tested in isolation. Implements the file format and the privacy +fail-safe ([LLP 0049](./0049-hypignore-usage-policy.spec.md#file-format), [#fail-safe](./0049-hypignore-usage-policy.spec.md#fail-safe)). + +```js +// `@ref LLP 0049#file-format [implements]` +// `@ref LLP 0049#fail-safe [implements]` — unknown/unimplemented class => 'ignore' +const IMPLEMENTED = new Set(['ignore']) // V1; grows additively when local-only ships + +/** @returns {{ class: UsageClass, declared: string|null, warn?: string }} */ +export function parseHypignore(body) { /* ... */ } +``` + +Rules: strip `#` comments and blank lines; the first remaining non-empty line is the +**class token**. Empty/comment-only ⇒ `ignore` (preserves the skill notes' promise). +A token not in `IMPLEMENTED` ⇒ resolve to `ignore` and surface a `warn` string (the +caller logs it). Reserved in-file path patterns are parsed-but-ignored in V1. + +### `matcher.js` — `resolveUsageClass` {#matcher} + +```js +// `@ref LLP 0050 [implements]` — the single shared matcher; no per-adapter copies. +// `@ref LLP 0049#scope [implements]` — gitignore-style ancestor walk, nearest wins. +export function createUsagePolicyResolver({ readFileSync, existsSync } = nodeFs) { /* ... */ } +``` + +- `resolve(cwd)`: from `cwd`, walk parent directories to filesystem root; the **nearest** + ancestor containing a `.hypignore` governs. Found ⇒ `parseHypignore(read)`; none ⇒ + `{ class: 'full', governedBy: null }`. Because V1 has no un-ignore directive, "any + ancestor `.hypignore` ⇒ ignored" (LLP 0049 §scope). +- **Per-`cwd` cache with a short TTL** (LLP 0049 R6): memoize `resolve` by absolute `cwd` + so the capture hot path does at most one ancestor walk per `cwd` per TTL window, not one + per exchange. Entries carry an expiry (`CACHE_TTL_MS`, 5s); a resolver instance is held + per daemon/backfill run, and `--check` constructs a fresh resolver so it always reflects + disk immediately. + - **Why TTL, not process-lifetime.** A process-lifetime cache made `hyp ignore` silently + ineffective on a running daemon: a `cwd` already resolved+cached as `full` by the live + projector kept recording after the user wrote a `.hypignore`, until the daemon + restarted, a silent leak window against R1 (raised by both reviewers on PR #211). A + bounded TTL re-walks once an entry expires, so a `.hypignore` written (or removed) + mid-run is honored within the window. Not "don't cache `full`": that reintroduces the + per-exchange walk R6 forbids. The TTL is the interim leak bound. + - **Future enhancement (not V1):** `hyp ignore` / `hyp unignore` signal the running + daemon to invalidate and prime the affected `cwd`'s cache entry, collapsing apply + latency from "within the TTL" to zero. The CLI writes a forward-note pointing here. +- fs and the clock (`now`) are injected for tests; default to `node:fs` and `Date.now`. + +## Enforcement: four adapter drop-sites {#enforcement} + +Per [LLP 0050](./0050-ignore-enforced-in-adapters.decision.md) the adapters are the only +places that resolve a `cwd`. Each constructs/holds one resolver and drops ignored work. + +| | Claude | Codex | +|---|---|---| +| **Live** | `claude/src/projector.js` `createClaudeExchangeProjector` | `codex/src/exchange-projector.js` `createCodexExchangeProjector` | +| **Backfill** | `claude/src/backfill.js` | `codex/src/backfill.js` | + +### Live — projector returns `[]` {#live} + +The live projector already reads `session-context.jsonl` and resolves `cwd`/`repo_root` +per exchange. The gateway source's write guard is +`const messageRows = await projector.projectExchange(row); if (messageRows.length > 0) appendRows(...)` +(`ai-gateway/src/source.js:117`). So an ignored exchange is dropped by having the +projector **return `[]` early** — before building rows, after `cwd` is known. **No +gateway change** (LLP 0050 §Live); the response is already streamed, so the live call is +untouched ([LLP 0049](./0049-hypignore-usage-policy.spec.md#requirements) R2). + +```js +// in createClaudeExchangeProjector, once cwd is resolved for the exchange: +// @ref LLP 0050 [implements] — capture-seam drop, projector returns no rows +if (resolver.isIgnored(cwd)) { logIgnored({ component: 'claude', cwd, governedBy }); return [] } +``` + +### Backfill — skip ignored sessions {#backfill} + +`hyp backfill` reads local transcripts carrying `cwd`/`repo_root` per session; each +provider filters ignored sessions **before** projecting/writing, else a backfill silently +re-imports the exact sessions ignored live (LLP 0050 §Backfill, [LLP 0049](./0049-hypignore-usage-policy.spec.md#requirements) R1). +Same `// @ref LLP 0050 [implements]` drop, keyed on the session's `cwd`. + +Settlement (`claude/src/settle.js`, LLP 0027) is untouched — it only upgrades identity of +already-written rows and never sees an ignored exchange. + +## CLI: `hyp ignore` / `hyp unignore` {#cli} + +A kernel verb pair ([LLP 0009](./0009-cli-registry.spec.md)) registered alongside the +existing core verbs (`src/core/cli/core_commands.js` / `core_verbs.js`). + +- `hyp ignore [path]` — write a self-documenting `.hypignore` (comment header + `ignore` + token) at the git **repo root** when `path`/cwd is in a repo, else at cwd; explicit + `path` overrides. Idempotent (LLP 0049 R5). +- `hyp unignore [path]` — remove the governing `.hypignore`. Idempotent (no-op when none). +- `hyp ignore --check [path]` — report whether `path` is ignored, which `.hypignore` + governs, and the **residual count** of already-cached rows from the scope + ([LLP 0049](./0049-hypignore-usage-policy.spec.md#prospective-only) — prospective-only, + no purge). The residual count is a cache query over rows whose `cwd`/`repo_root` is under + the scope; debuggable per the repo's log-driven ethos. + +Path/repo-root resolution reuses the existing repo-root helper the adapters already use to +stamp `repo_root`. + +## Telemetry {#telemetry} + +Log-driven (CLAUDE.md): on each drop emit a structured event — +`component` (`claude`/`codex`), `operation: 'usage_policy_drop'`, `class`, `governedBy` +(path), `cwd` (hashed/redacted, never raw customer paths in dev telemetry). On fail-safe, +warn with the `declared` token and the governing path. `--check` emits the resolved class ++ residual count. + +## Test plan {#tests} + +Traditional tests (deterministic, the bulk): +- `format.js`: empty ⇒ ignore; comment-only ⇒ ignore; `ignore` token; unknown token ⇒ + ignore + warn (fail-safe); `local-only` ⇒ ignore + warn in V1. +- `matcher.js`: nearest-ancestor wins; no `.hypignore` ⇒ full; deep walk to root; cache + returns a stable result; injected fs. +- adapter drops: a projector/backfill given an ignored `cwd` returns `[]`/skips; a + non-ignored `cwd` is unaffected (R2 — live call untouched). +- CLI: `ignore`/`unignore` idempotency (R5); `--check` reports governor + residual count. + +Hermetic smoke: `hypignore_capture_drop` — start the daemon, drive one exchange from a +`.hypignore`'d cwd and one from a clean cwd, assert only the clean row lands in the cache +and the drop event is emitted. + +## Out of scope (V1) {#out-of-scope} + +Carried verbatim from [LLP 0049 §non-goals](./0049-hypignore-usage-policy.spec.md#non-goals): +raw-proxy/OTEL folder-blindness (structural — no `cwd`); prospective-only (no retroactive +purge); no central/layered-config interaction; no ephemeral session opt-out. The +`local-only` class and session opt-out are [LLP 0051](./0051-usage-policy-future-extensions.decision.md); +the file-format fail-safe and single shared matcher are the forward-compat hooks that keep +them additive. + +## Annotation map (for the implementing change set) + +| Site | Annotation | +|------|-----------| +| `src/core/usage-policy/matcher.js` | `@ref LLP 0050 [implements]`, `@ref LLP 0049#scope [implements]` | +| `src/core/usage-policy/format.js` | `@ref LLP 0049#file-format [implements]`, `@ref LLP 0049#fail-safe [implements]` | +| claude/codex live projector drop | `@ref LLP 0050 [implements]` | +| claude/codex backfill skip | `@ref LLP 0050 [implements]` | +| `hyp ignore`/`unignore` verb | `@ref LLP 0049#cli [implements]` | diff --git a/llp/0053-hypignore-usage-policy.plan.md b/llp/0053-hypignore-usage-policy.plan.md new file mode 100644 index 0000000..6a3676e --- /dev/null +++ b/llp/0053-hypignore-usage-policy.plan.md @@ -0,0 +1,29 @@ +# LLP 0053: hypignore usage policy — implementation plan + +**Type:** plan +**Status:** Active +**Systems:** Sources, Gateway, CLI, Core +**Generated-by:** neutral +**Related:** LLP 0052, LLP 0049, LLP 0050 +**Date:** 2026-06-29 + +> Implementation steps for the `.hypignore` usage policy designed in +> [LLP 0052](./0052-hypignore-usage-policy.design.md). Small, independently-mergeable +> tasks: the shared core matcher first, then the two adapters and the CLI in parallel, +> then a hermetic smoke. V1 enforces only the capture seam (LLP 0050) — no cache schema, +> export driver, or gateway change. + +## Tasks +- id: T1 branch: task/hypignore-usage-policy/T1 deps: [] -- Core module `src/core/usage-policy/`: `format.js` (`parseHypignore` — strip `#`/blank lines, first token is the class; empty/comment-only ⇒ `ignore`; unknown/unimplemented token ⇒ `ignore` + a `warn` string, the privacy fail-safe), `matcher.js` (`createUsagePolicyResolver({readFileSync,existsSync})` — gitignore-style ancestor walk from a cwd to the nearest `.hypignore`, per-cwd memo cache, fs injected; `resolve(cwd)`→`{class,governedBy,declared}`, `isIgnored(cwd)`), `index.js` barrel, `types.d.ts` (`UsageClass`,`ResolveResult`,`UsagePolicyResolver`). Annotate `@ref LLP 0050 [implements]` on matcher, `@ref LLP 0049#file-format`/`#fail-safe`/`#scope [implements]`. Traditional tests in `test/core/usage-policy*.test.js` (empty⇒ignore, unknown⇒ignore+warn, nearest-ancestor wins, no file⇒full, cache stable). +- id: T2 branch: task/hypignore-usage-policy/T2 deps: [T1] -- Claude adapter capture-seam drop (`@ref LLP 0050 [implements]`): in `claude/src/projector.js` `createClaudeExchangeProjector`, once the exchange `cwd` is resolved, `if (resolver.isIgnored(cwd)) return []` before building rows (the `ai-gateway/src/source.js` write guard then persists nothing — R2, live call untouched); in `claude/src/backfill.js`, skip ignored sessions before projecting/writing (R1). Hold one resolver per projector/backfill run. Tests: ignored cwd ⇒ `[]`/skip, clean cwd unaffected. +- id: T3 branch: task/hypignore-usage-policy/T3 deps: [T1] -- Codex adapter capture-seam drop, symmetric to T2 (`@ref LLP 0050 [implements]`): `codex/src/exchange-projector.js` `createCodexExchangeProjector` returns `[]` for an ignored cwd; `codex/src/backfill.js` skips ignored sessions. Tests mirror T2. +- id: T4 branch: task/hypignore-usage-policy/T4 deps: [T1] -- CLI verbs (`@ref LLP 0049#cli [implements]`) in `src/core/cli/core_commands.js` + `core_verbs.js`: `hyp ignore [path]` writes a self-documenting `.hypignore` (comment header + `ignore` token) at the git repo root (else cwd; explicit path overrides), idempotent (R5); `hyp unignore [path]` removes the governing file, idempotent; `hyp ignore --check [path]` reports ignored?/governing file/residual already-cached row count (prospective-only, no purge). Reuse the existing repo-root helper. Tests for idempotency + `--check` output. +- id: T5 branch: task/hypignore-usage-policy/T5 deps: [T2, T3, T4] -- Hermetic smoke `hypaware-core/smoke/flows/hypignore_capture_drop.js`: drive one exchange from a `.hypignore`'d cwd and one from a clean cwd through the daemon; assert only the clean row lands in the cache and a `usage_policy_drop` event is emitted (stable `smoke_name`/`smoke_step`, log-driven). Register in the smoke flow index. + +## Notes + +- The design (LLP 0052) is already `Status: Active` (neutral-minted), so no status-flip task + is needed — merging the change set ships it. +- T2/T3/T4 are independent after T1 and run in parallel; T5 closes the loop end-to-end. +- No task touches the cache schema, the export driver, `@hypaware/ai-gateway`, or settlement + (`claude/src/settle.js`) — capture-seam only, per LLP 0050. diff --git a/src/core/cli/core_commands.js b/src/core/cli/core_commands.js index ac27d13..8892049 100644 --- a/src/core/cli/core_commands.js +++ b/src/core/cli/core_commands.js @@ -39,6 +39,8 @@ import { } from './remote_commands.js' import { CORE_VERBS } from './core_verbs.js' import { verbToCommand } from './verb_command.js' +import { createUsagePolicyResolver, findRepoRoot } from '../usage-policy/index.js' +import { executeQuerySql } from '../query/sql.js' // `query sql` migrated to a verb (LLP 0034 §verbs): it is registered by // `registerCoreVerbs` and projected into both a CLI command and an MCP @@ -264,10 +266,16 @@ function buildCoreCommands() { }, { name: 'ignore', - summary: 'Mark the current session as ignored by recording sources', - usage: 'hyp ignore', + summary: 'Write a .hypignore so HypAware never records this folder subtree (--check reports status)', + usage: 'hyp ignore [path] [--check] [--json]', run: runIgnore, }, + { + name: 'unignore', + summary: 'Remove the governing .hypignore so HypAware records this folder subtree again', + usage: 'hyp unignore [path]', + run: runUnignore, + }, { name: 'skills install', summary: 'Install registered skills into AI client directories', @@ -3796,6 +3804,25 @@ function expandClientName(requested, gateway) { return [requested] } +// The body written by `hyp ignore`: a self-documenting `.hypignore` whose +// first meaningful token is the `ignore` usage class. The comment header +// explains the file to whoever finds it in a checkout; the matcher only ever +// reads the token (LLP 0049 #file-format). +const HYPIGNORE_TEMPLATE = `# HypAware usage policy (.hypignore) +# +# This folder and everything beneath it is IGNORED: AI gateway exchanges +# (Claude / Codex) whose working directory is at or under this directory are +# never written to the local HypAware cache, for live capture and backfill +# alike. Recording is suppressed at the capture seam; the live LLM call is +# untouched (LLP 0049 / LLP 0050). +# +# Managed by \`hyp ignore\` / \`hyp unignore\`; \`hyp ignore --check\` reports +# status. Removing this file re-enables recording for the subtree. +# +# The token below names the usage class. V1 implements only \`ignore\`. +ignore +` + /** * Resolve `--client all` to every known client name from the descriptor map * (bundled+installed) for the disk-driven detach; otherwise return the @@ -3813,14 +3840,232 @@ function expandDetachClientNames(requested, descriptors) { } /** - * @param {string[]} _argv + * Parse `hyp ignore` / `hyp unignore` argv: an optional positional path and + * the `--check` / `--json` flags (`--check` is meaningful for `ignore` only). + * + * @param {string[]} argv + * @returns {{ check: boolean, json: boolean, path?: string, error?: string }} + */ +function parseIgnoreArgs(argv) { + /** @type {{ check: boolean, json: boolean, path?: string, error?: string }} */ + const r = { check: false, json: false } + for (const arg of argv) { + if (arg === '--check') { r.check = true; continue } + if (arg === '--json') { r.json = true; continue } + if (arg.startsWith('-')) { r.error = `unknown argument: ${arg}`; return r } + if (r.path !== undefined) { r.error = `unexpected extra argument: ${arg}`; return r } + r.path = arg + } + return r +} + +/** + * `hyp ignore [path] [--check]` + * + * Without `--check`, writes a self-documenting `.hypignore` (comment header + + * `ignore` token) so HypAware stops recording the folder subtree. The file + * lands at the git **repo root** when the target is inside a repo, else at the + * target directory; an explicit `path` overrides the default (cwd) target. The + * write is idempotent (LLP 0049 R5): a path already governed by an ancestor + * `.hypignore` is left as-is. With `--check`, reports status without writing. + * + * @ref LLP 0049#cli [implements]: the `hyp ignore` verb: write the dotfile at the repo root, idempotent, with a prospective-only `--check` + * @param {string[]} argv + * @param {CommandRunContext} ctx + */ +async function runIgnore(argv, ctx) { + const parsed = parseIgnoreArgs(argv) + if (parsed.error) { + ctx.stderr.write(`error: ${parsed.error}\n`) + return 2 + } + if (parsed.check) return runIgnoreCheck(parsed, ctx) + + // Resolve a relative `path` arg against the command-context cwd (matching the + // sibling verbs above), not the Node process cwd, so injected/remote/test + // dispatch writes/removes/checks the tree the caller actually pointed at. + const base = path.resolve(ctx.cwd ?? process.cwd(), parsed.path ?? '.') + // Idempotent (R5): a fresh resolver reflects disk. Any governing ancestor + // `.hypignore` already ignores `base` (V1 has no un-ignore directive, any + // `.hypignore` resolves to `ignore`), so re-ignoring is a no-op success + // rather than a redundant nested file. + const existing = createUsagePolicyResolver().resolve(base) + if (existing.governedBy) { + ctx.stdout.write(`already ignored (governed by ${existing.governedBy})\n`) + return 0 + } + + // Default target: the repo root when `base` is in a git repo, else `base`. + // An explicit `path` overrides: write exactly where the caller pointed. + const targetDir = parsed.path ? base : (findRepoRoot(base) ?? base) + const file = path.join(targetDir, '.hypignore') + try { + await fs.writeFile(file, HYPIGNORE_TEMPLATE) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + ctx.stderr.write(`error: could not write ${file}: ${message}\n`) + return 1 + } + getLogger('usage-policy').info('usage_policy.ignore_write', { + [Attr.COMPONENT]: 'cmd-ignore', + [Attr.OPERATION]: 'usage_policy.ignore_write', + status: 'ok', + }) + // A running daemon holds its own usage-policy resolver, so this new file is + // honored within the matcher's cache TTL, not instantly (matcher.js + // CACHE_TTL_MS). Future enhancement: signal the daemon here to invalidate and + // prime this cwd's cache entry so the drop applies with zero latency. + ctx.stdout.write(`wrote ${file}\n`) + return 0 +} + +/** + * `hyp unignore [path]` + * + * Removes the nearest governing `.hypignore`, re-enabling recording for the + * subtree. Idempotent (LLP 0049 R5): unignoring a path that no `.hypignore` + * governs succeeds as a no-op. + * + * @ref LLP 0049#cli [implements]: the `hyp unignore` verb: remove the governing dotfile, idempotent + * @param {string[]} argv + * @param {CommandRunContext} ctx + */ +async function runUnignore(argv, ctx) { + const parsed = parseIgnoreArgs(argv) + if (parsed.error) { + ctx.stderr.write(`error: ${parsed.error}\n`) + return 2 + } + if (parsed.check) { + ctx.stderr.write('error: --check is only valid for `hyp ignore`\n') + return 2 + } + + // Resolve a relative `path` arg against the command-context cwd (matching the + // sibling verbs above), not the Node process cwd, so injected/remote/test + // dispatch writes/removes/checks the tree the caller actually pointed at. + const base = path.resolve(ctx.cwd ?? process.cwd(), parsed.path ?? '.') + const { governedBy } = createUsagePolicyResolver().resolve(base) + if (!governedBy) { + ctx.stdout.write(`not ignored (no .hypignore governs ${base})\n`) + return 0 + } + try { + await fs.rm(governedBy, { force: true }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + ctx.stderr.write(`error: could not remove ${governedBy}: ${message}\n`) + return 1 + } + getLogger('usage-policy').info('usage_policy.unignore_remove', { + [Attr.COMPONENT]: 'cmd-unignore', + [Attr.OPERATION]: 'usage_policy.unignore_remove', + status: 'ok', + }) + ctx.stdout.write(`removed ${governedBy}\n`) + return 0 +} + +/** + * `hyp ignore --check [path]` + * + * Reports whether `path` (default cwd) is currently ignored, which + * `.hypignore` governs, and the residual count of already-cached rows from the + * scope. This is prospective-only: `--check` never purges; it just surfaces + * the residue so the rule stays debuggable (LLP 0049 #prospective-only). + * + * @ref LLP 0049#prospective-only [implements]: `--check` reports the residual already-cached row count; it never deletes + * @param {{ json: boolean, path?: string }} parsed * @param {CommandRunContext} ctx + * @returns {Promise} */ -async function runIgnore(_argv, ctx) { - ctx.stdout.write('(session ignore is contributed by recording-source plugins)\n') +async function runIgnoreCheck(parsed, ctx) { + // Resolve a relative `path` arg against the command-context cwd (matching the + // sibling verbs above), not the Node process cwd, so injected/remote/test + // dispatch writes/removes/checks the tree the caller actually pointed at. + const base = path.resolve(ctx.cwd ?? process.cwd(), parsed.path ?? '.') + const result = createUsagePolicyResolver().resolve(base) + const ignored = result.class === 'ignore' + const scopeDir = result.governedBy ? path.dirname(result.governedBy) : base + const residual = ignored ? await countResidualCachedRows(scopeDir, ctx) : 0 + + if (parsed.json) { + ctx.stdout.write( + JSON.stringify({ + path: base, + ignored, + governedBy: result.governedBy, + class: result.class, + declared: result.declared, + residualCachedRows: residual, + }) + '\n' + ) + return 0 + } + + ctx.stdout.write(`path: ${base}\n`) + ctx.stdout.write(`ignored: ${ignored ? 'yes' : 'no'}\n`) + ctx.stdout.write(`governed-by: ${result.governedBy ?? '(none)'}\n`) + ctx.stdout.write(`residual-cached-rows: ${residual === null ? 'unknown' : residual}\n`) return 0 } +/** + * Count already-cached `ai_gateway_messages` rows whose `cwd`/`repo_root` lies + * under `scopeDir`: the residue an `ignore` does NOT purge (prospective-only). + * + * A LIKE pushes a *superset* filter into the scan (squirreling's LIKE treats + * `_`/`%` as wildcards, so a path containing them can only over-match, never + * under-match), then an exact `startsWith` refine in JS removes the false + * positives so the reported count is precise. Best-effort: when the dataset is + * not registered (the gateway plugin is inactive) or the cache cannot be read, + * returns `null` so the caller renders `unknown` rather than failing. + * + * @param {string} scopeDir + * @param {CommandRunContext} ctx + * @returns {Promise} + */ +async function countResidualCachedRows(scopeDir, ctx) { + const lit = scopeDir.replace(/'/g, "''") + const likePrefix = `${scopeDir}/`.replace(/'/g, "''") + const sql = + `SELECT cwd, repo_root FROM ai_gateway_messages ` + + `WHERE cwd = '${lit}' OR cwd LIKE '${likePrefix}%' ` + + `OR repo_root = '${lit}' OR repo_root LIKE '${likePrefix}%'` + try { + const out = await executeQuerySql({ + query: sql, + registry: ctx.query, + storage: /** @type {ExtendedQueryStorageService} */ (ctx.storage), + refresh: 'never', + config: ctx.config, + }) + let n = 0 + for (const row of out.rows ?? []) { + const cwd = row.cwd == null ? '' : String(row.cwd) + const repoRoot = row.repo_root == null ? '' : String(row.repo_root) + if (isUnderDir(cwd, scopeDir) || isUnderDir(repoRoot, scopeDir)) n += 1 + } + return n + } catch { + return null + } +} + +/** + * True when `p` is `dir` itself or a path strictly beneath it. + * + * @param {string} p + * @param {string} dir + * @returns {boolean} + */ +function isUnderDir(p, dir) { + if (p === '') return false + if (p === dir) return true + const prefix = dir.endsWith('/') ? dir : `${dir}/` + return p.startsWith(prefix) +} + /** * `hyp skills install [--client ]` * diff --git a/src/core/usage-policy/drop.js b/src/core/usage-policy/drop.js new file mode 100644 index 0000000..8b831b4 --- /dev/null +++ b/src/core/usage-policy/drop.js @@ -0,0 +1,37 @@ +// @ts-check + +/** + * @import { UsagePolicyDrop } from './types.js' + */ + +/** + * Terminal sentinel an adapter's exchange projector returns when an ancestor + * `.hypignore` resolves the exchange's cwd to `ignore`: this exchange must + * never be recorded. + * + * It is deliberately NOT a bare `undefined`. `undefined` is a projector's "I + * decline, try the next matching projector" signal, so a drop expressed as + * `undefined` would (a) fall through to any later overlapping projector, which + * could then record the very exchange the user asked to suppress, and (b) be + * logged by the gateway as a `no_projector_match` miss, mislabeling a + * successful privacy drop as a projection failure. The gateway dispatcher + * recognizes this sentinel, stops the projector walk on it, and logs it as an + * intentional usage-policy drop. + * + * A frozen singleton, compared by reference identity via `isUsagePolicyDrop`. + * + * @ref LLP 0050 [implements]: the capture-seam drop is terminal and observable as a drop, not a projection miss. + */ +export const USAGE_POLICY_DROP = Object.freeze( + /** @type {UsagePolicyDrop} */ ({ usagePolicyDrop: true }) +) + +/** + * Narrow a projector return value to the terminal usage-policy drop sentinel. + * + * @param {unknown} value + * @returns {value is UsagePolicyDrop} + */ +export function isUsagePolicyDrop(value) { + return value === USAGE_POLICY_DROP +} diff --git a/src/core/usage-policy/format.js b/src/core/usage-policy/format.js new file mode 100644 index 0000000..87c17fc --- /dev/null +++ b/src/core/usage-policy/format.js @@ -0,0 +1,68 @@ +// @ts-check + +/** + * @import { UsageClass, ParseResult } from '../../../src/core/usage-policy/types.js' + */ + +// V1 implements exactly the `ignore` class. The set grows additively when +// `local-only` ships (LLP 0051); until then any other token hits the fail-safe. +const IMPLEMENTED = new Set(['ignore']) + +/** + * Parse a `.hypignore` body into a usage class. + * + * Strip `#` comments and blank lines; the first remaining token names the + * class. An empty or comment-only file means `ignore`, preserving the skill + * notes' promise that an empty `.hypignore` opts the tree out. A token the + * running version does not implement resolves to `ignore` (the most + * restrictive class) and surfaces a `warn` string for the caller to log: + * the safe failure for a privacy control is "suppress more", never + * "record-and-export something the user flagged". + * + * Reserved in-file path patterns are parsed-but-ignored in V1: only the first + * token of the first meaningful line is read. + * + * @ref LLP 0049#file-format [implements]: strip # comments and blanks; first token is the class; empty/comment-only => ignore + * @ref LLP 0049#fail-safe [implements]: unknown/unimplemented class token => ignore (most restrictive) + warn + * @param {string} body + * @returns {ParseResult} + */ +export function parseHypignore(body) { + const token = firstToken(body) + if (token === null) return { class: 'ignore', declared: null } + if (IMPLEMENTED.has(token)) { + return { class: /** @type {UsageClass} */ (token), declared: token } + } + return { + class: 'ignore', + declared: token, + warn: `unimplemented .hypignore usage class "${token}"; treating as "ignore" (most restrictive)`, + } +} + +/** + * First non-comment, non-blank token of a `.hypignore` body, or null when the + * body is empty or comment-only. + * + * @param {string} body + * @returns {string|null} + */ +function firstToken(body) { + for (const rawLine of String(body).split(/\r?\n/)) { + const line = stripComment(rawLine).trim() + if (line === '') continue + return line.split(/\s+/)[0] + } + return null +} + +/** + * Drop an inline `#` comment from a line. + * + * @param {string} line + * @returns {string} + */ +function stripComment(line) { + const hash = line.indexOf('#') + return hash === -1 ? line : line.slice(0, hash) +} diff --git a/src/core/usage-policy/index.js b/src/core/usage-policy/index.js new file mode 100644 index 0000000..f623839 --- /dev/null +++ b/src/core/usage-policy/index.js @@ -0,0 +1,14 @@ +// @ts-check + +// Public API for the `.hypignore` folder-scoped usage policy (LLP 0049/0050/0052). +// The shared, cwd-agnostic matcher lives in core; the Claude/Codex adapters +// import it exactly as they import `src/core/observability`. +export { parseHypignore } from './format.js' +export { createUsagePolicyResolver } from './matcher.js' +// The terminal capture-seam drop sentinel (LLP 0050): an adapter projector +// returns it for an `.hypignore`-ignored exchange, and the gateway dispatcher +// stops on it (never falls through to a later projector) and logs it as a drop. +export { USAGE_POLICY_DROP, isUsagePolicyDrop } from './drop.js' +// Repo-root resolution for the `hyp ignore` CLI (LLP 0049 #cli): place a +// single repo-wide `.hypignore` at the git toplevel. +export { findRepoRoot } from './repo_root.js' diff --git a/src/core/usage-policy/matcher.js b/src/core/usage-policy/matcher.js new file mode 100644 index 0000000..718c02b --- /dev/null +++ b/src/core/usage-policy/matcher.js @@ -0,0 +1,132 @@ +// @ts-check + +import nodeFs from 'node:fs' +import path from 'node:path' + +import { parseHypignore } from './format.js' + +/** + * @import { ResolveResult, UsagePolicyResolver } from '../../../src/core/usage-policy/types.js' + */ + +const HYPIGNORE_FILENAME = '.hypignore' + +// How long a resolved `cwd` is trusted before its ancestor walk is re-run. A +// short TTL keeps the capture hot path bounded (at most one walk per cwd per +// window, R6) while bounding staleness the other way: a long-lived daemon +// resolver that cached a cwd as `full` picks up a newly written `.hypignore` +// within this window instead of never, until restart (R1). The value is the +// interim leak bound; a future CLI-to-daemon signal would drive it to zero. +const CACHE_TTL_MS = 5_000 + +/** + * Create a usage-policy resolver: given an exchange's `cwd`, walk ancestor + * directories to the nearest `.hypignore` and resolve it to a usage class. + * + * Because V1 has only the `ignore` class and no un-ignore directive, the walk + * collapses to "any `.hypignore` found walking up from a `cwd` governs". The + * resolver is `cwd`-agnostic path logic only: it never inspects rows, so only + * the calling adapter need know which field carries the `cwd`. + * + * Results are memoized per absolute `cwd` with a short TTL, so the capture hot + * path does at most one ancestor walk per `cwd` per TTL window (R6) rather than + * one per exchange. The TTL also bounds staleness: a long-lived daemon resolver + * that cached a `cwd` as `full` re-walks once the entry expires, so a + * `.hypignore` written (or removed) mid-run is honored within the TTL instead + * of only after a daemon restart (R1). `hyp ignore --check` still constructs a + * fresh resolver, so it always reflects disk immediately. + * + * Future enhancement (not V1): `hyp ignore` / `hyp unignore` could signal the + * running daemon to invalidate and prime the affected `cwd`'s cache entry, + * collapsing the apply latency from "within the TTL" to zero. Until that path + * exists, the TTL is the leak bound. + * + * fs, the clock, and the TTL are injected for tests; fs defaults to `node:fs`, + * the clock to `Date.now`, and the TTL to `CACHE_TTL_MS`. + * + * @ref LLP 0050 [implements]: the single shared matcher for all four adapter call sites; no per-adapter copies + * @ref LLP 0049#scope [implements]: gitignore-style ancestor walk from cwd, nearest .hypignore wins; per-cwd cache (R6) + * @ref LLP 0052#matcher [implements]: bounded-TTL staleness so a mid-run .hypignore is honored without a daemon restart + * @param {object} [deps] + * @param {(path: string, encoding: 'utf8') => string} [deps.readFileSync] + * @param {(path: string) => boolean} [deps.existsSync] + * @param {() => number} [deps.now] injectable clock in ms; defaults to Date.now + * @param {number} [deps.ttlMs] cache entry lifetime in ms; defaults to CACHE_TTL_MS + * @returns {UsagePolicyResolver} + */ +export function createUsagePolicyResolver({ + readFileSync = nodeFs.readFileSync, + existsSync = nodeFs.existsSync, + now = Date.now, + ttlMs = CACHE_TTL_MS, +} = {}) { + /** @type {Map} */ + const cache = new Map() + + /** + * @param {string} cwd + * @returns {ResolveResult} + */ + function resolve(cwd) { + const key = path.resolve(cwd) + const at = now() + const cached = cache.get(key) + if (cached && cached.expiresAt > at) return cached.result + const result = walk(key) + cache.set(key, { result, expiresAt: at + ttlMs }) + return result + } + + /** + * @param {string} startDir + * @returns {ResolveResult} + */ + function walk(startDir) { + let dir = startDir + while (true) { + const candidate = path.join(dir, HYPIGNORE_FILENAME) + if (existsSync(candidate)) { + const parsed = parseHypignore(safeRead(candidate)) + // Carry `warn` only on a fail-safe clamp, so a plain `ignore` result + // stays `{ class, governedBy, declared }` with no `warn` key. + return { + class: parsed.class, + governedBy: candidate, + declared: parsed.declared, + ...(parsed.warn ? { warn: parsed.warn } : {}), + } + } + const parent = path.dirname(dir) + if (parent === dir) break // reached the filesystem root + dir = parent + } + // Nothing governs: the implicit `full` default (LLP 0049 #classes). + return { class: 'full', governedBy: null, declared: null } + } + + /** + * Read a governing `.hypignore`, failing safe to an empty body (which the + * format parses as `ignore`) when the file exists but cannot be read: an + * uninterpretable privacy signal must suppress, never record. + * + * @param {string} file + * @returns {string} + */ + function safeRead(file) { + try { + return String(readFileSync(file, 'utf8')) + } catch { + return '' + } + } + + /** + * @param {string} cwd + * @returns {boolean} + */ + function isIgnored(cwd) { + return resolve(cwd).class === 'ignore' + } + + return { resolve, isIgnored } +} diff --git a/src/core/usage-policy/repo_root.js b/src/core/usage-policy/repo_root.js new file mode 100644 index 0000000..3568d1a --- /dev/null +++ b/src/core/usage-policy/repo_root.js @@ -0,0 +1,35 @@ +// @ts-check + +import nodeFs from 'node:fs' +import path from 'node:path' + +const GIT_ENTRY = '.git' + +/** + * Find the git repository root governing `startDir`: the nearest ancestor + * (inclusive) that contains a `.git` entry: a directory for an ordinary + * clone, a file for a linked worktree or submodule. Returns `null` when + * `startDir` is not inside a git repository. + * + * This is the repo-root resolution the `hyp ignore` CLI reuses to drop a + * single repo-wide `.hypignore` at the toplevel, mirroring what the + * Claude/Codex adapters derive with `git rev-parse --show-toplevel` when they + * stamp `repo_root` (LLP 0049 #cli). It is kept as dependency-free, + * fs-injectable path logic (an ancestor walk in the same shape as the + * `.hypignore` matcher) so the CLI need not spawn git and so it stays + * hermetically unit-testable. + * + * @param {string} startDir + * @param {object} [fs] + * @param {(p: string) => boolean} [fs.existsSync] + * @returns {string | null} + */ +export function findRepoRoot(startDir, { existsSync = nodeFs.existsSync } = {}) { + let dir = path.resolve(startDir) + while (true) { + if (existsSync(path.join(dir, GIT_ENTRY))) return dir + const parent = path.dirname(dir) + if (parent === dir) return null // reached the filesystem root + dir = parent + } +} diff --git a/src/core/usage-policy/types.d.ts b/src/core/usage-policy/types.d.ts new file mode 100644 index 0000000..bb58083 --- /dev/null +++ b/src/core/usage-policy/types.d.ts @@ -0,0 +1,43 @@ +// Shared types for the `.hypignore` folder-scoped usage policy. +// See LLP 0049 (spec), LLP 0050 (enforcement decision), LLP 0052 (design). + +// V1 ships `ignore`; `local-only` is reserved (LLP 0051) and `full` is the +// implicit default when nothing governs (LLP 0049 #classes). +export type UsageClass = 'ignore' | 'local-only' | 'full' + +// The result of parsing a single `.hypignore` body. `declared` is the raw +// token read before the fail-safe; `warn` is present only when the declared +// token was unknown/unimplemented and was clamped to `ignore`. +export interface ParseResult { + class: UsageClass + declared: string | null + warn?: string +} + +// The result of resolving a `cwd` against the nearest governing `.hypignore`. +// `class` is the resolved, implemented class (`full` when nothing governs); +// `governedBy` is the absolute path of the nearest governing file, or null; +// `declared` is the raw token before fail-safe (null when nothing governs or +// the file was empty/comment-only); `warn` is carried from the parse and is +// present only on a fail-safe clamp, so adapters can warn on it (R3). +export interface ResolveResult { + class: UsageClass + governedBy: string | null + declared: string | null + warn?: string +} + +export interface UsagePolicyResolver { + resolve(cwd: string): ResolveResult + isIgnored(cwd: string): boolean +} + +// Terminal sentinel an adapter's exchange projector returns to express an +// intentional `.hypignore` usage-policy drop (the exchange must never be +// recorded). Distinct from a bare `undefined` "this projector declined" return +// so the gateway dispatcher stops the projector walk on it and logs it as a +// privacy drop, not a `no_projector_match` miss (LLP 0050). Compared by +// reference identity against the `USAGE_POLICY_DROP` singleton. +export interface UsagePolicyDrop { + readonly usagePolicyDrop: true +} diff --git a/test/core/ignore-command.test.js b/test/core/ignore-command.test.js new file mode 100644 index 0000000..af175b2 --- /dev/null +++ b/test/core/ignore-command.test.js @@ -0,0 +1,267 @@ +// @ts-check + +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { asyncRow } from 'squirreling' + +import { registerCoreCommands } from '../../src/core/cli/core_commands.js' +import { createCommandRegistry } from '../../src/core/registry/commands.js' + +/** + * @import { CommandRegistration, CommandRunContext } from '../../collectivus-plugin-kernel-types.js' + */ + +// `hyp ignore` / `hyp unignore` write and remove a `.hypignore` to gate folder +// capture (LLP 0049 #cli). The tests run the real command bodies against a +// real temp tree, so idempotency and `--check` reporting are exercised +// end-to-end through the same registry the dispatcher uses. + +/** @returns {{ write(chunk: unknown): boolean, text(): string }} */ +function makeBuf() { + let value = '' + return { + write(chunk) { + value += String(chunk) + return true + }, + text() { + return value + }, + } +} + +/** @param {string} name */ +function getCommand(name) { + const registry = createCommandRegistry() + registerCoreCommands(registry) + const command = registry.get(name) + assert.ok(command, `${name} is registered`) + return /** @type {CommandRegistration} */ (command) +} + +/** + * Run a registered command body the same way dispatch would, against a fake + * CommandRunContext rooted at `cwd`. + * + * @param {string} name + * @param {string[]} argv + * @param {{ cwd: string, query?: unknown, storage?: unknown }} opts + */ +async function run(name, argv, opts) { + const stdout = makeBuf() + const stderr = makeBuf() + const ctx = /** @type {any} */ ({ + stdout, + stderr, + cwd: opts.cwd, + env: {}, + config: { version: 2 }, + query: opts.query ?? { getDataset: () => undefined, listDatasets: () => [] }, + storage: opts.storage ?? { cacheRoot: path.join(opts.cwd, '.cache'), pendingInfo: async () => ({ pending: false }) }, + }) + const code = await getCommand(name).run(argv, /** @type {CommandRunContext} */ (ctx)) + return { code, stdout: stdout.text(), stderr: stderr.text() } +} + +/** @param {(dir: string) => Promise | void} fn */ +async function withTempTree(fn) { + const dir = mkdtempSync(path.join(tmpdir(), 'hypign-')) + try { + await fn(dir) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +/* --------------------------------- ignore -------------------------------- */ + +test('hyp ignore writes a self-documenting .hypignore at the git repo root', async () => { + await withTempTree(async (root) => { + mkdirSync(path.join(root, '.git')) + const sub = path.join(root, 'src', 'deep') + mkdirSync(sub, { recursive: true }) + + const res = await run('ignore', [], { cwd: sub }) + assert.equal(res.code, 0) + + const file = path.join(root, '.hypignore') + assert.ok(existsSync(file), 'wrote .hypignore at the repo root, not the cwd') + assert.ok(!existsSync(path.join(sub, '.hypignore')), 'did not write a nested file') + const body = readFileSync(file, 'utf8') + assert.match(body, /^ignore$/m, 'first meaningful token is the ignore class') + assert.match(body, /HypAware usage policy/, 'has a self-documenting comment header') + assert.match(res.stdout, new RegExp(`wrote ${file.replace(/[.\\]/g, '\\$&')}`)) + }) +}) + +test('hyp ignore without a repo writes .hypignore at the cwd', async () => { + await withTempTree(async (root) => { + // No `.git` anywhere under the temp tree => fall back to the cwd. + const res = await run('ignore', [], { cwd: root }) + assert.equal(res.code, 0) + assert.ok(existsSync(path.join(root, '.hypignore'))) + }) +}) + +test('hyp ignore [path] writes exactly at the explicit path, overriding the repo root', async () => { + await withTempTree(async (root) => { + mkdirSync(path.join(root, '.git')) + const target = path.join(root, 'pkg') + mkdirSync(target) + + const res = await run('ignore', [target], { cwd: root }) + assert.equal(res.code, 0) + assert.ok(existsSync(path.join(target, '.hypignore')), 'explicit path overrides repo-root placement') + assert.ok(!existsSync(path.join(root, '.hypignore'))) + }) +}) + +test('hyp ignore is idempotent: re-ignoring an already-ignored path is a no-op success', async () => { + await withTempTree(async (root) => { + mkdirSync(path.join(root, '.git')) + const sub = path.join(root, 'a', 'b') + mkdirSync(sub, { recursive: true }) + + const first = await run('ignore', [], { cwd: sub }) + assert.equal(first.code, 0) + const file = path.join(root, '.hypignore') + const before = readFileSync(file, 'utf8') + + const second = await run('ignore', [], { cwd: sub }) + assert.equal(second.code, 0, 'second ignore still succeeds (R5)') + assert.match(second.stdout, /already ignored/) + assert.match(second.stdout, new RegExp(file.replace(/[.\\]/g, '\\$&'))) + assert.equal(readFileSync(file, 'utf8'), before, 'the existing file is not rewritten or clobbered') + }) +}) + +/* -------------------------------- unignore ------------------------------- */ + +test('hyp unignore removes the governing .hypignore and is idempotent', async () => { + await withTempTree(async (root) => { + mkdirSync(path.join(root, '.git')) + const file = path.join(root, '.hypignore') + writeFileSync(file, 'ignore\n') + const sub = path.join(root, 'x') + mkdirSync(sub) + + const first = await run('unignore', [], { cwd: sub }) + assert.equal(first.code, 0) + assert.match(first.stdout, /removed/) + assert.ok(!existsSync(file), 'the governing file is gone') + + const second = await run('unignore', [], { cwd: sub }) + assert.equal(second.code, 0, 'unignoring an unignored path still succeeds (R5)') + assert.match(second.stdout, /not ignored/) + }) +}) + +/* ------------------------------ ignore --check --------------------------- */ + +test('hyp ignore --check reports an ignored path, its governor, and residual count', async () => { + await withTempTree(async (root) => { + const file = path.join(root, '.hypignore') + writeFileSync(file, 'ignore\n') + + const res = await run('ignore', ['--check'], { cwd: root }) + assert.equal(res.code, 0) + assert.match(res.stdout, /ignored: yes/) + assert.match(res.stdout, new RegExp(`governed-by: ${file.replace(/[.\\]/g, '\\$&')}`)) + // No `ai_gateway_messages` dataset registered in this ctx => residual is + // reported as `unknown` rather than failing the command. + assert.match(res.stdout, /residual-cached-rows: unknown/) + }) +}) + +test('hyp ignore --check reports a clean path as not ignored with zero residue', async () => { + await withTempTree(async (root) => { + const res = await run('ignore', ['--check'], { cwd: root }) + assert.equal(res.code, 0) + assert.match(res.stdout, /ignored: no/) + assert.match(res.stdout, /governed-by: \(none\)/) + assert.match(res.stdout, /residual-cached-rows: 0/) + }) +}) + +test('hyp ignore --check --json emits a machine-readable status', async () => { + await withTempTree(async (root) => { + const file = path.join(root, '.hypignore') + writeFileSync(file, 'ignore\n') + + const res = await run('ignore', ['--check', '--json'], { cwd: root }) + assert.equal(res.code, 0) + const parsed = JSON.parse(res.stdout) + assert.equal(parsed.ignored, true) + assert.equal(parsed.governedBy, file) + assert.equal(parsed.class, 'ignore') + }) +}) + +test('hyp ignore --check counts already-cached rows under the scope (LIKE superset, refined exactly)', async () => { + await withTempTree(async (root) => { + const scope = path.join(root, 'my_app') // underscore => LIKE wildcard trap + mkdirSync(scope) + writeFileSync(path.join(scope, '.hypignore'), 'ignore\n') + + // `my_app` LIKE-matches the sibling `myXapp` (squirreling maps `_` -> any + // single char), so the exact JS refine must exclude it. + const sibling = path.join(root, 'myXapp') + const rows = [ + { cwd: scope, repo_root: scope }, // exact scope: counts + { cwd: path.join(scope, 'src', 'a'), repo_root: scope }, // under scope: counts + { cwd: path.join('/outside', 'zone'), repo_root: path.join(scope, 'deep') }, // repo_root under: counts + { cwd: path.join(sibling, 'y'), repo_root: sibling }, // LIKE false-positive: excluded + { cwd: '/elsewhere/unrelated', repo_root: '/elsewhere/unrelated' }, // unrelated: excluded + ] + + const { query, storage } = makeAiGatewayCache(rows) + const res = await run('ignore', ['--check', '--json'], { cwd: scope, query, storage }) + assert.equal(res.code, 0) + const parsed = JSON.parse(res.stdout) + assert.equal(parsed.ignored, true) + assert.equal(parsed.residualCachedRows, 3) + }) +}) + +/* -------------------------------- helpers -------------------------------- */ + +/** + * Build a minimal in-memory `ai_gateway_messages` dataset + registry/storage + * so `executeQuerySql` can run the residual-count query against fixed rows. + * + * @param {Record[]} data + */ +function makeAiGatewayCache(data) { + const columns = ['cwd', 'repo_root'] + const dataset = { + name: 'ai_gateway_messages', + plugin: 'test', + schema: { columns: columns.map((name) => ({ name, type: 'string' })) }, + discoverPartitions: async () => [], + createDataSource: () => ({ + numRows: data.length, + columns, + /** @param {{ columns?: string[] }} [opts] */ + scan(opts) { + const cols = opts?.columns ?? columns + return { + async *rows() { + for (const obj of data) yield asyncRow(/** @type {any} */ (obj), cols) + }, + appliedWhere: false, + appliedLimitOffset: false, + } + }, + }), + } + const query = { + getDataset: (/** @type {string} */ name) => (name === 'ai_gateway_messages' ? dataset : undefined), + listDatasets: () => [dataset], + } + const storage = { cacheRoot: '/tmp/hypaware-ignore-test', pendingInfo: async () => ({ pending: false }) } + return { query, storage } +} diff --git a/test/core/usage-policy.test.js b/test/core/usage-policy.test.js new file mode 100644 index 0000000..52d7002 --- /dev/null +++ b/test/core/usage-policy.test.js @@ -0,0 +1,235 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { parseHypignore, createUsagePolicyResolver, findRepoRoot } from '../../src/core/usage-policy/index.js' + +// --- format.js: parseHypignore ------------------------------------------- + +test('parseHypignore: empty body => ignore (the empty-file opt-out)', () => { + const result = parseHypignore('') + assert.equal(result.class, 'ignore') + assert.equal(result.declared, null) + assert.equal(result.warn, undefined) +}) + +test('parseHypignore: comment-only/blank body => ignore', () => { + const result = parseHypignore('# just a note\n\n \n#another\n') + assert.equal(result.class, 'ignore') + assert.equal(result.declared, null) + assert.equal(result.warn, undefined) +}) + +test('parseHypignore: recognized `ignore` token => ignore, no warn', () => { + const result = parseHypignore('# header\nignore\n') + assert.equal(result.class, 'ignore') + assert.equal(result.declared, 'ignore') + assert.equal(result.warn, undefined) +}) + +test('parseHypignore: unknown token => ignore + warn (fail-safe)', () => { + const result = parseHypignore('mystery-class\n') + assert.equal(result.class, 'ignore') + assert.equal(result.declared, 'mystery-class') + assert.match(String(result.warn), /mystery-class/) + assert.match(String(result.warn), /ignore/) +}) + +test('parseHypignore: reserved `local-only` => ignore + warn in V1 (fail-safe)', () => { + const result = parseHypignore('local-only\n') + assert.equal(result.class, 'ignore') + assert.equal(result.declared, 'local-only') + assert.match(String(result.warn), /local-only/) +}) + +test('parseHypignore: first token wins; trailing path patterns are parsed-but-ignored', () => { + const result = parseHypignore('ignore secrets/\n# trailing comment\n') + assert.equal(result.class, 'ignore') + assert.equal(result.declared, 'ignore') +}) + +// --- matcher.js: createUsagePolicyResolver ------------------------------- + +/** + * Build an injectable fs over a fixed map of `.hypignore` file -> contents. + * Tracks read counts so the cache can be asserted. + * + * @param {Record} files + */ +function fakeFs(files) { + const reads = /** @type {Record} */ ({}) + return { + reads, + /** @param {string} p */ + existsSync: (p) => Object.prototype.hasOwnProperty.call(files, p), + /** @param {string} p */ + readFileSync: (p) => { + reads[p] = (reads[p] ?? 0) + 1 + return files[p] ?? '' + }, + } +} + +test('resolve: no .hypignore anywhere => full, governedBy null', () => { + const fs = fakeFs({}) + const resolver = createUsagePolicyResolver(fs) + const result = resolver.resolve('/work/repo/sub') + assert.equal(result.class, 'full') + assert.equal(result.governedBy, null) + assert.equal(result.declared, null) + assert.equal(resolver.isIgnored('/work/repo/sub'), false) +}) + +test('resolve: nearest ancestor .hypignore wins', () => { + const fs = fakeFs({ + '/work/repo/.hypignore': '', + '/work/repo/sub/.hypignore': 'ignore\n', + }) + const resolver = createUsagePolicyResolver(fs) + + const deep = resolver.resolve('/work/repo/sub/deeper/leaf') + assert.equal(deep.class, 'ignore') + assert.equal(deep.governedBy, '/work/repo/sub/.hypignore') + + const shallow = resolver.resolve('/work/repo/other') + assert.equal(shallow.class, 'ignore') + assert.equal(shallow.governedBy, '/work/repo/.hypignore') +}) + +test('resolve: walks all the way to the filesystem root', () => { + const fs = fakeFs({ '/.hypignore': 'ignore\n' }) + const resolver = createUsagePolicyResolver(fs) + const result = resolver.resolve('/a/b/c/d/e') + assert.equal(result.class, 'ignore') + assert.equal(result.governedBy, '/.hypignore') +}) + +test('resolve: unimplemented class in a governing file fails safe to ignore', () => { + const fs = fakeFs({ '/work/repo/.hypignore': 'local-only\n' }) + const resolver = createUsagePolicyResolver(fs) + const result = resolver.resolve('/work/repo/sub') + assert.equal(result.class, 'ignore') + assert.equal(result.declared, 'local-only') + assert.equal(result.governedBy, '/work/repo/.hypignore') +}) + +test('resolve: a present-but-unreadable .hypignore fails closed to ignore (privacy-protecting)', () => { + // safeRead clamps a read error to an empty body, which the format parses as + // `ignore`: an uninterpretable privacy signal must suppress, never record. + // Without the try/catch this throws; with it the cwd resolves to `ignore`. + const resolver = createUsagePolicyResolver({ + existsSync: () => true, + readFileSync: () => { throw new Error('EACCES') }, + }) + const result = resolver.resolve('/work/repo/sub') + assert.equal(result.class, 'ignore', 'an unreadable governing .hypignore must fail closed to ignore') + assert.equal(result.governedBy, '/work/repo/sub/.hypignore', 'the nearest existing file governs') + assert.equal(resolver.isIgnored('/work/repo/sub'), true) +}) + +test('resolve: per-cwd cache is stable and reads the file once', () => { + const fs = fakeFs({ '/work/repo/.hypignore': 'ignore\n' }) + const resolver = createUsagePolicyResolver(fs) + + const first = resolver.resolve('/work/repo/sub') + const second = resolver.resolve('/work/repo/sub') + assert.equal(first, second) // same memoized object + assert.deepEqual(first, { class: 'ignore', governedBy: '/work/repo/.hypignore', declared: 'ignore' }) + assert.equal(fs.reads['/work/repo/.hypignore'], 1) + + // isIgnored shares the same cache: still one read. + assert.equal(resolver.isIgnored('/work/repo/sub'), true) + assert.equal(fs.reads['/work/repo/.hypignore'], 1) +}) + +test('resolve: relative cwd is normalized before caching', () => { + const fs = fakeFs({}) + const resolver = createUsagePolicyResolver(fs) + // Should not throw and should resolve against an absolute key. + const result = resolver.resolve('.') + assert.equal(result.class, 'full') +}) + +test('resolve: a .hypignore written after a cwd was cached `full` is honored once the TTL elapses', () => { + // The privacy-critical staleness direction (R1): a long-lived daemon + // resolver must not keep recording a folder forever just because it cached + // `full` before the user ran `hyp ignore`. + const files = /** @type {Record} */ ({}) + let clock = 1_000 + const resolver = createUsagePolicyResolver({ + existsSync: (p) => Object.prototype.hasOwnProperty.call(files, p), + readFileSync: (p) => files[p] ?? '', + now: () => clock, + ttlMs: 5_000, + }) + + // First resolve: nothing governs => full, cached with a 5s expiry. + assert.equal(resolver.resolve('/work/repo/sub').class, 'full') + + // User writes a .hypignore. Within the TTL the cached `full` still wins. + files['/work/repo/.hypignore'] = 'ignore\n' + clock += 1_000 + assert.equal(resolver.resolve('/work/repo/sub').class, 'full') + + // Once the entry expires, the walk re-runs and the new file is honored, + // without a daemon restart. + clock += 5_000 + assert.equal(resolver.resolve('/work/repo/sub').class, 'ignore') + assert.equal(resolver.isIgnored('/work/repo/sub'), true) +}) + +test('resolve: removing a .hypignore is honored once the TTL elapses (unignore)', () => { + // The inverse direction: after `hyp unignore` the subtree records again + // within the TTL rather than staying suppressed until restart. + const files = /** @type {Record} */ ({ '/work/repo/.hypignore': 'ignore\n' }) + let clock = 1_000 + const resolver = createUsagePolicyResolver({ + existsSync: (p) => Object.prototype.hasOwnProperty.call(files, p), + readFileSync: (p) => files[p] ?? '', + now: () => clock, + ttlMs: 5_000, + }) + + assert.equal(resolver.resolve('/work/repo/sub').class, 'ignore') + + delete files['/work/repo/.hypignore'] + clock += 5_001 + assert.equal(resolver.resolve('/work/repo/sub').class, 'full') +}) + +test('createUsagePolicyResolver defaults fs to node:fs when none injected', () => { + // A directory tree with no .hypignore resolves to full without throwing. + const resolver = createUsagePolicyResolver() + const result = resolver.resolve(process.cwd()) + assert.ok(result.class === 'full' || result.class === 'ignore') + assert.equal(typeof resolver.isIgnored(process.cwd()), 'boolean') +}) + +// --- repo_root.js: findRepoRoot ------------------------------------------ + +/** @param {string[]} present Absolute paths existsSync should report true for. */ +function fakeExistsFs(present) { + const set = new Set(present) + return { existsSync: (/** @type {string} */ p) => set.has(p) } +} + +test('findRepoRoot: nearest ancestor with a .git entry is the repo root', () => { + const fs = fakeExistsFs(['/work/repo/.git']) + assert.equal(findRepoRoot('/work/repo/src/deep', fs), '/work/repo') +}) + +test('findRepoRoot: the start dir itself can be the repo root', () => { + const fs = fakeExistsFs(['/work/repo/.git']) + assert.equal(findRepoRoot('/work/repo', fs), '/work/repo') +}) + +test('findRepoRoot: returns null when no ancestor has a .git', () => { + const fs = fakeExistsFs([]) + assert.equal(findRepoRoot('/work/repo/src', fs), null) +}) + +test('findRepoRoot: defaults fs to node:fs without throwing', () => { + const result = findRepoRoot(process.cwd()) + assert.ok(result === null || typeof result === 'string') +}) diff --git a/test/plugins/ai-gateway-message-projector.test.js b/test/plugins/ai-gateway-message-projector.test.js index 30768db..bcbebb0 100644 --- a/test/plugins/ai-gateway-message-projector.test.js +++ b/test/plugins/ai-gateway-message-projector.test.js @@ -10,10 +10,12 @@ import { createAiGatewayConversationState, createAiGatewayMessageProjector, } from '../../hypaware-core/plugins-workspace/ai-gateway/src/message_projector.js' +import { USAGE_POLICY_DROP } from '../../src/core/usage-policy/index.js' /** * @import { AiGatewayExchangeInput, AiGatewayExchangeProjectorContext, AiGatewayProjectedExchange } from '../../collectivus-plugin-kernel-types.js' * @import { ExtendedQueryStorageService } from '../../src/core/cache/types.js' + * @import { UsagePolicyDrop } from '../../src/core/usage-policy/types.js' */ const EXPECTED_COLUMNS = [ @@ -165,6 +167,63 @@ test('projector returning undefined or an empty messages array is skipped', asyn assert.equal(rows[0].provider, 'ok') }) +test('a usage-policy drop is terminal: dispatch stops, writes no row, and is logged as a drop (not no_projector_match)', async () => { + // @ref LLP 0050 [tests]: an intentional `.hypignore` drop returns the + // USAGE_POLICY_DROP sentinel. It must STOP the projector walk (no later + // matching projector may record the suppressed exchange), write zero rows, + // and be logged as a drop rather than a `no_projector_match` miss. + /** @type {Array<{ level: string, message: string, fields: Record }>} */ + const logs = [] + const log = collectingLogger(logs) + let secondConsulted = 0 + const projector = createAiGatewayMessageProjector({ + gatewayId: 'gw-test', + projectors: [ + // Higher priority: the .hypignore-governed adapter drops the exchange. + registered('drop', { priority: 20, project: () => USAGE_POLICY_DROP }), + // Lower priority but ALSO matching. A spy: it must never be consulted, or + // it could record the very exchange the user asked to suppress. + registered('would-record', { + priority: 10, + project: () => { secondConsulted++; return projection('would-record') }, + }), + ], + log, + }) + const rows = await projector.projectExchange(exchange()) + assert.equal(rows.length, 0, 'a usage-policy drop writes no row') + assert.equal(secondConsulted, 0, 'a terminal drop must NOT fall through to a second matching projector') + assert.ok( + !logs.some((entry) => entry.fields?.reason === 'no_projector_match'), + 'a privacy drop must not be logged as a no_projector_match miss', + ) + assert.ok( + logs.some((entry) => entry.message === 'aigw.usage_policy_drop' && entry.fields?.reason === 'usage_policy_drop'), + 'a drop is logged with the usage_policy_drop reason', + ) +}) + +test('a bare undefined decline still falls through to the next matching projector (only the drop sentinel is terminal)', async () => { + // Guardrail: the terminal contract applies ONLY to the drop sentinel. A + // projector that genuinely declines with bare `undefined` must still let the + // next matching projector win, and a normal exchange still projects. + let secondConsulted = 0 + const projector = createAiGatewayMessageProjector({ + gatewayId: 'gw-test', + projectors: [ + registered('declines', { priority: 20, project: () => undefined }), + registered('records', { + priority: 10, + project: () => { secondConsulted++; return projection('records') }, + }), + ], + }) + const rows = await projector.projectExchange(exchange()) + assert.equal(secondConsulted, 1, 'a declining projector must still let the next matching one be consulted') + assert.ok(rows.length > 0, 'a normal exchange still projects rows') + assert.equal(rows[0].provider, 'records') +}) + test('projector returning an invalid shape is skipped and the next one is tried', async () => { /** @type {Array<{ level: string, message: string, fields: Record }>} */ const logs = [] @@ -819,7 +878,7 @@ function projection(provider) { * @param {{ * priority?: number, * match?: (input: AiGatewayExchangeInput) => boolean, - * project: (input: AiGatewayExchangeInput, ctx: AiGatewayExchangeProjectorContext) => AiGatewayProjectedExchange | Promise | undefined, + * project: (input: AiGatewayExchangeInput, ctx: AiGatewayExchangeProjectorContext) => AiGatewayProjectedExchange | UsagePolicyDrop | Promise | undefined, * }} body */ function registered(name, body) { diff --git a/test/plugins/claude-usage-policy-drop.test.js b/test/plugins/claude-usage-policy-drop.test.js new file mode 100644 index 0000000..0ebdd90 --- /dev/null +++ b/test/plugins/claude-usage-policy-drop.test.js @@ -0,0 +1,308 @@ +// @ts-check + +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { createAiGatewayMessageProjector } from '../../hypaware-core/plugins-workspace/ai-gateway/src/message_projector.js' +import { createClaudeExchangeProjector } from '../../hypaware-core/plugins-workspace/claude/src/projector.js' +import { createClaudeBackfillProvider } from '../../hypaware-core/plugins-workspace/claude/src/backfill.js' +import { appendSessionContext } from '../../hypaware-core/plugins-workspace/claude/src/session_context.js' +import { createUsagePolicyResolver } from '../../src/core/usage-policy/index.js' + +/** + * @ref LLP 0050 [tests]: the `.hypignore` capture-seam drop lives in the + * Claude adapter. These tests prove both Claude drop-sites consult the shared + * resolver and suppress an ignored `cwd` BEFORE any row is written: the live + * projector returns no rows, and backfill skips the session. A clean `cwd` is + * unaffected (LLP 0049#requirements R1/R2). + * + * @import { BackfillEvent, BackfillItem, BackfillRunContext } from '../../collectivus-plugin-kernel-types.js' + */ + +const IGNORED_ROOT = '/work/ignored-repo' +const CLEAN_ROOT = '/work/clean-repo' + +// --------------------------------------------------------------------------- +// Live projector +// --------------------------------------------------------------------------- + +test('live projector returns no rows when the exchange cwd is governed by .hypignore', async () => { + const env = await stageEnv() + try { + await writeTranscript(env, 'sess-ign', transcriptPair('sess-ign')) + // The hook-written record stamps the ignored cwd onto the session. + await appendSessionContext(env.stateFile, { + session_id: 'sess-ign', + transcript_path: undefined, + git_branch: undefined, + cwd: path.join(IGNORED_ROOT, 'src'), + ts: '2026-05-22T09:59:00.000Z', + }) + + const rows = await projectViaGateway(env, { + sessionId: 'sess-ign', + resolver: resolverIgnoring(IGNORED_ROOT), + }) + + assert.equal(rows.length, 0, 'an ignored cwd must drop every row at the capture seam') + } finally { + await env.cleanup() + } +}) + +test('live projector records normally when the exchange cwd is not ignored', async () => { + const env = await stageEnv() + try { + await writeTranscript(env, 'sess-clean', transcriptPair('sess-clean')) + await appendSessionContext(env.stateFile, { + session_id: 'sess-clean', + transcript_path: undefined, + git_branch: undefined, + cwd: path.join(CLEAN_ROOT, 'src'), + ts: '2026-05-22T09:59:00.000Z', + }) + + // Same resolver as the drop case: only IGNORED_ROOT is governed, so a + // clean cwd resolves to `full` and the exchange is recorded. + const rows = await projectViaGateway(env, { + sessionId: 'sess-clean', + resolver: resolverIgnoring(IGNORED_ROOT), + }) + + assert.equal(rows.length, 2, 'a clean cwd must be unaffected: user + assistant rows land') + assert.deepEqual(rows.map((r) => r.role).sort(), ['assistant', 'user']) + } finally { + await env.cleanup() + } +}) + +test('live projector with no resolved cwd records normally (no folder to match)', async () => { + const env = await stageEnv() + try { + await writeTranscript(env, 'sess-nocwd', transcriptPair('sess-nocwd')) + // No session-context record => no cwd => the ignore check is skipped even + // with a resolver that would ignore everything it is asked about. + const rows = await projectViaGateway(env, { + sessionId: 'sess-nocwd', + resolver: { resolve: () => ({ class: 'ignore', governedBy: '/x/.hypignore', declared: 'ignore' }), isIgnored: () => true }, + }) + + assert.equal(rows.length, 2, 'with no cwd there is nothing to match, so capture proceeds') + } finally { + await env.cleanup() + } +}) + +// --------------------------------------------------------------------------- +// Backfill +// --------------------------------------------------------------------------- + +test('backfill skips an ignored session and yields only the clean one', async () => { + const env = await stageEnv() + try { + // cwd rides each transcript line; one session is under the ignored root, + // the other under a clean root. + await writeTranscript(env, 'sess-bf-ign', transcriptPair('sess-bf-ign', path.join(IGNORED_ROOT, 'pkg'))) + await writeTranscript(env, 'sess-bf-clean', transcriptPair('sess-bf-clean', path.join(CLEAN_ROOT, 'pkg'))) + + const provider = createClaudeBackfillProvider({ + homeDir: env.homeDir, + stateFile: env.stateFile, + resolver: resolverIgnoring(IGNORED_ROOT), + // Hermetic: never shell git for the clean session's repo derivation. + deriveRepo: async () => ({}), + }) + const { ctx, entries: logs } = runContext() + const items = await collectItems(provider.run(ctx)) + + assert.equal(items.length, 1, 'only the clean session is imported') + assert.equal(items[0].provenance?.native_id, 'sess-bf-clean') + + // The drop is observable, and the scan summary counts only the kept session. + assert.ok( + logs.some((e) => e.message === 'claude.backfill.usage_policy_drop' && e.fields?.session_id === 'sess-bf-ign'), + 'an ignored session emits a usage_policy_drop event' + ) + const scanDone = logs.find((e) => e.message === 'claude.backfill.scan_complete') + assert.equal(scanDone?.fields?.sessions_projected, 1) + } finally { + await env.cleanup() + } +}) + +test('backfill imports every session when none are ignored', async () => { + const env = await stageEnv() + try { + await writeTranscript(env, 'sess-bf-a', transcriptPair('sess-bf-a', path.join(CLEAN_ROOT, 'a'))) + await writeTranscript(env, 'sess-bf-b', transcriptPair('sess-bf-b', path.join(CLEAN_ROOT, 'b'))) + + const provider = createClaudeBackfillProvider({ + homeDir: env.homeDir, + stateFile: env.stateFile, + resolver: resolverIgnoring(IGNORED_ROOT), + deriveRepo: async () => ({}), + }) + const { ctx } = runContext() + const items = await collectItems(provider.run(ctx)) + + assert.equal(items.length, 2, 'no ignored session means no drop') + assert.deepEqual( + items.map((i) => i.provenance?.native_id).sort(), + ['sess-bf-a', 'sess-bf-b'] + ) + } finally { + await env.cleanup() + } +}) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * A resolver whose only governing `.hypignore` lives at `/.hypignore` + * and resolves to `ignore`. Built on the REAL core matcher with an injected fs, + * so these tests exercise the production ancestor-walk path, not a stub. + * + * @param {string} ignoredRoot + */ +function resolverIgnoring(ignoredRoot) { + const marker = path.join(ignoredRoot, '.hypignore') + return createUsagePolicyResolver({ + existsSync: (p) => p === marker, + readFileSync: () => 'ignore\n', + }) +} + +/** + * @returns {Promise<{ homeDir: string, stateFile: string, cleanup: () => Promise }>} + */ +async function stageEnv() { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-usage-policy-')) + const stateDir = path.join(homeDir, 'state') + await fs.mkdir(stateDir, { recursive: true }) + return { + homeDir, + stateFile: path.join(stateDir, 'session-context.jsonl'), + cleanup: () => fs.rm(homeDir, { recursive: true, force: true }), + } +} + +/** + * A user turn + an assistant turn, native-DAG wired. `cwd`, when given, rides + * every transcript line (the only repo signal a backfill session carries). + * + * @param {string} sessionId + * @param {string} [cwd] + * @returns {Record[]} + */ +function transcriptPair(sessionId, cwd) { + const base = cwd ? { cwd } : {} + return [ + { ...base, sessionId, uuid: 'u-1', parentUuid: null, type: 'user', message: { role: 'user', content: 'hello' }, timestamp: '2026-05-22T10:00:00.000Z' }, + { ...base, sessionId, uuid: 'a-1', parentUuid: 'u-1', type: 'assistant', message: { role: 'assistant', id: 'msg_1', content: [{ type: 'text', text: 'hi' }] }, timestamp: '2026-05-22T10:00:01.000Z' }, + ] +} + +/** + * @param {{ homeDir: string }} env + * @param {string} sessionId + * @param {Record[]} rows + */ +async function writeTranscript(env, sessionId, rows) { + const dir = path.join(env.homeDir, '.claude', 'projects', 'some-repo') + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile( + path.join(dir, `${sessionId}.jsonl`), + rows.map((r) => JSON.stringify(r)).join('\n') + '\n', + 'utf8' + ) +} + +/** + * Build the projector with the injected resolver, wrap it in the gateway + * dispatcher (the production path), and project one synthetic exchange. + * + * @param {{ homeDir: string, stateFile: string }} env + * @param {{ sessionId: string, resolver: import('../../src/core/usage-policy/types.js').UsagePolicyResolver }} call + * @returns {Promise[]>} + */ +async function projectViaGateway(env, call) { + const projector = createClaudeExchangeProjector({ + homeDir: env.homeDir, + stateFile: env.stateFile, + resolver: call.resolver, + }) + const dispatcher = createAiGatewayMessageProjector({ + gatewayId: 'gw-test', + projectors: [{ ...projector, _seq: 0 }], + }) + return dispatcher.projectExchange({ + exchange_id: 'ex-1', + ts_start: '2026-05-22T10:00:05.000Z', + ts_end: '2026-05-22T10:00:05.250Z', + duration_ms: 250, + upstream: 'anthropic', + provider: null, + method: 'POST', + path: '/v1/messages', + status_code: 200, + request_bytes: 100, + response_bytes: 200, + is_sse: false, + stream_event_count: 0, + request_headers: JSON.stringify({ 'anthropic-version': '2023-06-01', 'user-agent': 'claude-cli/1.0' }), + request_body: JSON.stringify({ + model: 'claude-3-opus', + metadata: { user_id: JSON.stringify({ session_id: call.sessionId }) }, + messages: [{ role: 'user', content: 'hello' }], + }), + response_headers: JSON.stringify({ 'content-type': 'application/json' }), + response_body: JSON.stringify({ id: 'msg_1', role: 'assistant', content: [{ type: 'text', text: 'hi' }], stop_reason: 'end_turn' }), + error: null, + metadata: JSON.stringify({ dev_run_id: 'run-1' }), + stream_events: [], + }) +} + +function captureLog() { + /** @type {Array<{ level: string, message: string, fields?: Record }>} */ + const entries = [] + /** @param {string} level */ + const at = (level) => (/** @type {string} */ message, /** @type {Record=} */ fields) => { + entries.push({ level, message, fields }) + } + return { entries, log: { debug: at('debug'), info: at('info'), warn: at('warn'), error: at('error') } } +} + +/** + * @returns {{ ctx: BackfillRunContext, entries: any[] }} + */ +function runContext() { + const { entries, log } = captureLog() + /** @type {BackfillRunContext} */ + const ctx = { + env: {}, + cacheRoot: path.join(os.tmpdir(), 'claude-usage-policy-cache-unused'), + dryRun: false, + log, + storage: /** @type {any} */ ({}), + } + return { ctx, entries } +} + +/** + * @param {AsyncIterable} iterable + * @returns {Promise} + */ +async function collectItems(iterable) { + /** @type {BackfillItem[]} */ + const items = [] + for await (const yielded of iterable) { + if (yielded.type !== 'event') items.push(/** @type {BackfillItem} */ (yielded)) + } + return items +} diff --git a/test/plugins/codex-backfill.test.js b/test/plugins/codex-backfill.test.js index e117c20..0b1abea 100644 --- a/test/plugins/codex-backfill.test.js +++ b/test/plugins/codex-backfill.test.js @@ -12,6 +12,23 @@ import { aiGatewayBackfillMaterializer, } from '../../hypaware-core/plugins-workspace/ai-gateway/src/dataset.js' import { createCodexBackfillProvider } from '../../hypaware-core/plugins-workspace/codex/src/backfill.js' +import { createUsagePolicyResolver } from '../../src/core/usage-policy/index.js' + +/** + * A real usage-policy resolver wired to an injected fs that reports exactly one + * governing `.hypignore` (class `ignore`) at `ignoredDir`. Mirrors the T2 + * @hypaware/claude backfill drop test: exercise the shared matcher, not a stub. + * @ref LLP 0050 [tests]: the codex backfill capture-seam skip + * + * @param {string} ignoredDir + */ +function ignoringResolver(ignoredDir) { + const hypignore = path.join(ignoredDir, '.hypignore') + return createUsagePolicyResolver({ + existsSync: (p) => p === hypignore, + readFileSync: () => 'ignore\n', + }) +} /** * End-to-end tests for the `@hypaware/codex` backfill provider. They run the @@ -318,6 +335,56 @@ test('modern rollout projects into canonical ai_gateway_messages rows', async () } }) +// @ref LLP 0050 [tests]: capture-seam drop for backfill: a session whose +// recorded cwd is .hypignore-ignored is skipped before projecting or yielding +// any row, so `hyp backfill` never re-imports sessions ignored live (R1). +test('backfill skips a session whose cwd is .hypignore-ignored', async () => { + const env = await stageEnv() + try { + // modernConversation stamps meta.cwd = '/work/repo'. + await writeModernRollout(env, '2026/05/25/rollout-1.jsonl', modernConversation('sess-ignored')) + + const provider = createCodexBackfillProvider({ + homeDir: env.homeDir, + resolver: ignoringResolver('/work/repo'), + }) + const { ctx, entries: logs } = runContext() + const { items } = await collect(provider.run(ctx)) + + assert.equal(items.length, 0, 'no rows yielded for an ignored session') + const drop = logs.find((e) => e.message === 'codex.backfill.usage_policy_drop') + assert.ok(drop, 'expected a usage_policy_drop log entry') + assert.equal(drop?.fields?.operation, 'usage_policy_drop') + assert.equal(drop?.fields?.conversation_id, 'sess-ignored') + assert.equal(drop?.fields?.governed_by, '/work/repo/.hypignore') + const scanDone = logs.find((e) => e.message === 'codex.backfill.scan_complete') + assert.equal(scanDone?.fields?.sessions_projected, 0) + assert.equal(scanDone?.fields?.sessions_ignored, 1) + } finally { + await env.cleanup() + } +}) + +test('backfill is unaffected when a different cwd is ignored', async () => { + const env = await stageEnv() + try { + await writeModernRollout(env, '2026/05/25/rollout-1.jsonl', modernConversation('sess-clean')) + + const provider = createCodexBackfillProvider({ + homeDir: env.homeDir, + // Ignore an unrelated directory; the session's '/work/repo' is clean. + resolver: ignoringResolver('/work/other'), + }) + const { ctx } = runContext() + const { items } = await collect(provider.run(ctx)) + + assert.equal(items.length, 1, 'a non-ignored session still backfills') + assert.equal(value(items[0]).conversation_id, 'sess-clean') + } finally { + await env.cleanup() + } +}) + test('token_count event folds per-turn usage (net of cache) onto the turn assistant message', async () => { const env = await stageEnv() try { diff --git a/test/plugins/codex-exchange-projector.test.js b/test/plugins/codex-exchange-projector.test.js index e4b0526..a825b20 100644 --- a/test/plugins/codex-exchange-projector.test.js +++ b/test/plugins/codex-exchange-projector.test.js @@ -1,11 +1,147 @@ // @ts-check import assert from 'node:assert/strict' +import path from 'node:path' import test from 'node:test' import { createCodexExchangeProjector, } from '../../hypaware-core/plugins-workspace/codex/src/exchange-projector.js' +import { createUsagePolicyResolver, USAGE_POLICY_DROP } from '../../src/core/usage-policy/index.js' + +/** + * A real usage-policy resolver wired to an injected fs that reports exactly one + * governing `.hypignore` (class `ignore`) at `ignoredDir`. Mirrors how the + * @hypaware/claude projector's drop is tested (T2): exercise the actual shared + * matcher, not a hand-rolled stub. + * @ref LLP 0050 [tests]: the codex live projector's capture-seam drop + * + * @param {string} ignoredDir + */ +function ignoringResolver(ignoredDir) { + const hypignore = path.join(ignoredDir, '.hypignore') + return createUsagePolicyResolver({ + existsSync: (p) => p === hypignore, + readFileSync: () => 'ignore\n', + }) +} + +/** + * Like `ignoringResolver`, but the governing `.hypignore` declares an + * unimplemented class (`local-only`), so the matcher fail-safe clamps it to + * `ignore` and carries a `warn` (R3). Used to assert the drop escalates to + * warn level with the declared token. + * + * @param {string} ignoredDir + */ +function clampingResolver(ignoredDir) { + const hypignore = path.join(ignoredDir, '.hypignore') + return createUsagePolicyResolver({ + existsSync: (p) => p === hypignore, + readFileSync: () => 'local-only\n', + }) +} + +// @ref LLP 0050 [tests]: capture-seam drop: an ignored cwd yields no rows so +// the gateway write guard persists nothing; a clean cwd is unaffected (R1/R2). +test('project() returns no projection when the exchange cwd is .hypignore-ignored', () => { + const projector = createCodexExchangeProjector({ + env: {}, + resolver: ignoringResolver('/work/ignored'), + }) + const projection = projector.project(exchange({ + path: '/v1/chat/completions', + request_body: JSON.stringify({ + cwd: '/work/ignored/sub', + messages: [{ role: 'user', content: 'secret' }], + }), + response_body: JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'ok' } }] }), + }), context()) + // The drop returns the terminal USAGE_POLICY_DROP sentinel (not a bare + // `undefined` decline), so the dispatcher stops the projector walk and logs + // it as a drop. Either way the gateway write guard persists nothing. + assert.equal(projection, USAGE_POLICY_DROP) +}) + +test('project() is unaffected when the exchange cwd is not ignored', () => { + const projector = createCodexExchangeProjector({ + env: {}, + resolver: ignoringResolver('/work/ignored'), + }) + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/v1/chat/completions', + request_body: JSON.stringify({ + cwd: '/work/clean', + messages: [{ role: 'user', content: 'hi' }], + }), + response_body: JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'ok' } }] }), + }), context())) + assert.ok(projection) + assert.deepEqual(projection.messages.map((/** @type {any} */ m) => m.role), ['user', 'assistant']) +}) + +test('project() emits a usage_policy_drop log on an ignored cwd', () => { + /** @type {Array<{ message: string, fields?: Record }>} */ + const infos = [] + const projector = createCodexExchangeProjector({ + env: {}, + resolver: ignoringResolver('/work/ignored'), + }) + const log = { + debug() {}, + warn() {}, + error() {}, + /** @param {string} message @param {Record=} fields */ + info: (message, fields) => { infos.push({ message, fields }) }, + } + projector.project(exchange({ + path: '/v1/chat/completions', + request_body: JSON.stringify({ + cwd: '/work/ignored', + messages: [{ role: 'user', content: 'secret' }], + }), + }), { log }) + const drop = infos.find((e) => e.message === 'plugin.codex.usage_policy_drop') + assert.ok(drop, 'expected a usage_policy_drop log entry') + assert.equal(drop.fields?.operation, 'usage_policy_drop') + assert.equal(drop.fields?.governed_by, '/work/ignored/.hypignore') + assert.equal(drop.fields?.declared, 'ignore', 'an intended ignore carries declared=ignore') +}) + +test('project() escalates a fail-safe clamp to a warn-level drop with the declared token (R3)', () => { + /** @type {Array<{ message: string, fields?: Record }>} */ + const infos = [] + /** @type {Array<{ message: string, fields?: Record }>} */ + const warns = [] + const projector = createCodexExchangeProjector({ + env: {}, + resolver: clampingResolver('/work/ignored'), + }) + const log = { + debug() {}, + error() {}, + /** @param {string} message @param {Record=} fields */ + info: (message, fields) => { infos.push({ message, fields }) }, + /** @param {string} message @param {Record=} fields */ + warn: (message, fields) => { warns.push({ message, fields }) }, + } + const projection = projector.project(exchange({ + path: '/v1/chat/completions', + request_body: JSON.stringify({ + cwd: '/work/ignored', + messages: [{ role: 'user', content: 'secret' }], + }), + }), { log }) + + // Still dropped (privacy fail-safe) via the terminal sentinel, but now + // observable as a clamp. + assert.equal(projection, USAGE_POLICY_DROP) + assert.equal(infos.length, 0, 'a fail-safe clamp does not log at info level') + const drop = warns.find((e) => e.message === 'plugin.codex.usage_policy_drop') + assert.ok(drop, 'a fail-safe clamp emits a warn-level usage_policy_drop') + assert.equal(drop.fields?.declared, 'local-only', 'the declared token is carried for diagnosis') + assert.match(String(drop.fields?.warn), /local-only/) +}) test('match() accepts the three transports it owns and rejects others', () => { const projector = createCodexExchangeProjector({ env: {} })