Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ad6cb56
Design: hypignore usage policy (LLP 0052 covers 0049)
philcunliffe Jun 30, 2026
1a4363f
Plan: hypignore usage policy (LLP 0053 for 0052)
philcunliffe Jun 30, 2026
93d3441
Core usage-policy module: .hypignore parser + shared resolver (LLP 00…
philcunliffe Jun 30, 2026
d9e53bb
Merge remote-tracking branch 'origin/task/hypignore-usage-policy/T1' …
philcunliffe Jun 30, 2026
1560ec2
Claude adapter: drop ignored cwds at the capture seam (.hypignore)
philcunliffe Jun 30, 2026
6712103
Codex adapter .hypignore capture-seam drop (LLP 0050)
philcunliffe Jun 30, 2026
b008222
CLI: hyp ignore / unignore / ignore --check for .hypignore (LLP 0049 …
philcunliffe Jun 30, 2026
c33dd29
Merge remote-tracking branch 'origin/task/hypignore-usage-policy/T2' …
philcunliffe Jun 30, 2026
de497f7
Merge remote-tracking branch 'origin/task/hypignore-usage-policy/T3' …
philcunliffe Jun 30, 2026
23e3375
Merge remote-tracking branch 'origin/task/hypignore-usage-policy/T4' …
philcunliffe Jun 30, 2026
74ab37d
Smoke: hypignore_capture_drop proves capture-seam drop end-to-end (LL…
philcunliffe Jun 30, 2026
7a201bf
Merge remote-tracking branch 'origin/task/hypignore-usage-policy/T5' …
philcunliffe Jun 30, 2026
77bfab9
hyp ignore/unignore/--check: resolve relative path arg against ctx.cwd
philcunliffe Jun 30, 2026
e40e545
hypignore: bounded-TTL policy cache + fail-safe warn + em-dash cleanu…
philcunliffe Jun 30, 2026
53e4e0d
Merge remote-tracking branch 'origin/master' into HEAD
philcunliffe Jun 30, 2026
2ac6b1e
Merge remote-tracking branch 'origin/master' into HEAD
philcunliffe Jun 30, 2026
030a8c2
hypignore drop: terminal, correctly-logged usage-policy drop contract
philcunliffe Jun 30, 2026
1acc523
Merge remote-tracking branch 'origin/master' into HEAD
philcunliffe Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion collectivus-plugin-kernel-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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
Expand All @@ -1471,7 +1478,11 @@ export interface AiGatewayExchangeProjector {
project(
input: AiGatewayExchangeInput,
ctx: AiGatewayExchangeProjectorContext
): AiGatewayProjectedExchange | Promise<AiGatewayProjectedExchange | undefined> | undefined
):
| AiGatewayProjectedExchange
| UsagePolicyDrop
| Promise<AiGatewayProjectedExchange | UsagePolicyDrop | undefined>
| undefined
}

export interface AiGatewayExchangeProjectorContext {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
*/

Expand Down Expand Up @@ -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.<adapter>.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) ?? '',
Expand Down Expand Up @@ -490,7 +507,7 @@ export function aiGatewayRowsFromProjectedExchange(projection, opts = {}) {
* @param {RegisteredProjector[]} projectors
* @param {AiGatewayExchangeInput} input
* @param {{ warn?: (m: string, f?: Record<string, unknown>) => void } | undefined} log
* @returns {Promise<AiGatewayProjectedExchange | undefined>}
* @returns {Promise<AiGatewayProjectedExchange | UsagePolicyDrop | undefined>}
*/
async function dispatchProjector(projectors, input, log) {
if (projectors.length === 0) return undefined
Expand All @@ -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', {
Expand Down
39 changes: 36 additions & 3 deletions hypaware-core/plugins-workspace/claude/src/backfill.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
*/

/**
Expand Down Expand Up @@ -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}
*/
Expand All @@ -78,14 +81,18 @@ 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,
plugin: pluginName,
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 })
},
}
}
Expand All @@ -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<BackfillItem>}
*/
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
Expand Down Expand Up @@ -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,
})
Expand Down
35 changes: 35 additions & 0 deletions hypaware-core/plugins-workspace/claude/src/projector.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
*/

/**
Expand Down Expand Up @@ -80,6 +82,7 @@ import {
* projectsDir?: string,
* clientName?: string,
* logger?: { warn(message: string, fields?: Record<string, unknown>): void, debug?: (m: string, f?: Record<string, unknown>) => void },
* resolver?: UsagePolicyResolver,
* }} opts
* @returns {AiGatewayExchangeProjector}
*/
Expand All @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 34 additions & 2 deletions hypaware-core/plugins-workspace/codex/src/backfill.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
*/

/**
Expand Down Expand Up @@ -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}
*/
Expand All @@ -83,14 +86,17 @@ 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,
plugin: pluginName,
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 })
},
}
}
Expand Down Expand Up @@ -131,11 +137,12 @@ function defaultUnsupportedLocations(homeDir) {
* sessionsDir: string,
* unsupportedLocations: Array<{ kind: string, path: string }>,
* clientName: string,
* resolver: UsagePolicyResolver,
* }} args
* @returns {AsyncGenerator<BackfillItem | BackfillEvent>}
*/
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)

Expand All @@ -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)) {
Expand All @@ -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),
Expand Down Expand Up @@ -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',
})
Expand Down
Loading